motionloom 2.5.0 → 2.6.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/AGENTS.md +2 -0
- package/CHANGELOG.md +52 -1
- package/README.md +20 -8
- package/SECURITY.md +6 -7
- package/SKILL.md +5 -3
- package/agent-card.json +4 -4
- package/agent-surfaces.json +1 -1
- package/bin/motionloom.mjs +5 -4
- package/capability-registry.json +26 -26
- package/dev-lab/public/action-library.js +899 -0
- package/dev-lab/public/devlab.js +722 -0
- package/dev-lab/public/index.html +163 -0
- package/dev-lab/public/runtime-bridge.js +109 -0
- package/docs/CHECKLIST.md +15 -2
- package/docs/DEV-LAB-RUNTIME.md +160 -0
- package/docs/DEV-LAB-STATE-TRANSITIONS.md +121 -0
- package/docs/STATUS.md +3 -3
- package/docs/releases/2.5.1.md +21 -0
- package/docs/releases/2.6.0.md +38 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/package.json +12 -4
- package/requirements.txt +1 -0
- package/rig-adapter-registry.json +3 -3
- package/schemas/browser-review-candidate.schema.json +37 -0
- package/schemas/devlab-runtime.schema.json +140 -0
- package/schemas/devlab-state-machine.schema.json +103 -0
- package/scripts/capture-runtime-telemetry.py +16 -8
- package/scripts/devlab.py +3 -10
- package/scripts/docs-audit.py +13 -1
- package/scripts/package-consumer-smoke.mjs +76 -0
- package/scripts/review-hook.py +255 -19
- package/scripts/runtime-adapters.mjs +28 -3
- package/scripts/skill-doctor.py +27 -1
- package/tests/runtime-harness/index.html +24 -0
- package/tests/runtime-harness/main.jsx +111 -0
- package/tests/scripts/run_tests.py +41 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const expectedVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
|
|
11
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "motionloom-consumer-"));
|
|
12
|
+
const consumer = path.join(temporary, "consumer");
|
|
13
|
+
fs.mkdirSync(consumer);
|
|
14
|
+
fs.writeFileSync(
|
|
15
|
+
path.join(consumer, "package.json"),
|
|
16
|
+
`${JSON.stringify({ name: "motionloom-package-smoke", private: true }, null, 2)}\n`,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
function run(command, args, options = {}) {
|
|
20
|
+
const result = spawnSync(command, args, {
|
|
21
|
+
cwd: options.cwd || root,
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
env: process.env,
|
|
24
|
+
...options,
|
|
25
|
+
});
|
|
26
|
+
if (result.status !== 0) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`${command} ${args.join(" ")} failed (${result.status})\n${result.stdout || ""}\n${result.stderr || ""}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return result.stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const packOutput = JSON.parse(run("npm", ["pack", "--json", "--pack-destination", temporary]));
|
|
36
|
+
const tarball = path.join(temporary, packOutput[0].filename);
|
|
37
|
+
run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: consumer });
|
|
38
|
+
|
|
39
|
+
const bin = process.platform === "win32"
|
|
40
|
+
? path.join(consumer, "node_modules", ".bin", "motionloom.cmd")
|
|
41
|
+
: path.join(consumer, "node_modules", ".bin", "motionloom");
|
|
42
|
+
const installedRoot = path.dirname(fs.realpathSync(path.join(consumer, "node_modules", "motionloom", "package.json")));
|
|
43
|
+
const help = run(bin, ["--help"], { cwd: consumer });
|
|
44
|
+
const init = JSON.parse(run(bin, ["init", "--dry-run", "--json"], { cwd: consumer }));
|
|
45
|
+
const doctor = JSON.parse(run(bin, ["doctor", "--json"], { cwd: consumer }));
|
|
46
|
+
|
|
47
|
+
if (!help.startsWith(`MotionLoom ${expectedVersion}`)) throw new Error(`unexpected help version: ${help.split("\n")[0]}`);
|
|
48
|
+
if (fs.realpathSync(init.project_root || ".") !== fs.realpathSync(consumer)) {
|
|
49
|
+
throw new Error(`installed CLI used the wrong project_root: ${init.project_root}`);
|
|
50
|
+
}
|
|
51
|
+
if (doctor.status !== "pass") throw new Error(`installed doctor failed: ${JSON.stringify(doctor.errors)}`);
|
|
52
|
+
for (const relative of [
|
|
53
|
+
"requirements.txt",
|
|
54
|
+
"dev-lab/public/index.html",
|
|
55
|
+
"dev-lab/public/devlab.js",
|
|
56
|
+
"tests/runtime-harness/index.html",
|
|
57
|
+
]) {
|
|
58
|
+
if (!fs.existsSync(path.join(installedRoot, relative))) throw new Error(`tarball omitted ${relative}`);
|
|
59
|
+
}
|
|
60
|
+
const scene = "browser-review-smoke";
|
|
61
|
+
const consumerScene = path.join(consumer, "src", "output", scene);
|
|
62
|
+
fs.mkdirSync(path.dirname(consumerScene), { recursive: true });
|
|
63
|
+
fs.cpSync(path.join(installedRoot, "src", "output", scene), consumerScene, { recursive: true });
|
|
64
|
+
const candidatePath = path.join(consumerScene, "browser-review.json");
|
|
65
|
+
const candidate = JSON.parse(fs.readFileSync(candidatePath, "utf8"));
|
|
66
|
+
candidate.expires_at = "2099-01-01T00:00:00Z";
|
|
67
|
+
fs.writeFileSync(candidatePath, `${JSON.stringify(candidate, null, 2)}\n`);
|
|
68
|
+
run(bin, ["devlab", scene, "--prepare-only"], { cwd: consumer });
|
|
69
|
+
if (!fs.existsSync(path.join(installedRoot, "dev-lab", "public", "scenes", scene, "browser-review.json"))) {
|
|
70
|
+
throw new Error("installed Dev Lab did not prepare the consumer scene");
|
|
71
|
+
}
|
|
72
|
+
run(process.execPath, ["--input-type=module", "-e", "await import('playwright'); await import('vite');"], { cwd: consumer });
|
|
73
|
+
console.log(JSON.stringify({ status: "pass", project_root: init.project_root, installed_root: installedRoot }, null, 2));
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
76
|
+
}
|
package/scripts/review-hook.py
CHANGED
|
@@ -16,7 +16,10 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
|
16
16
|
|
|
17
17
|
ROOT = Path(__file__).resolve().parents[1]
|
|
18
18
|
SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
19
|
+
SAFE_ANIMATION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
19
20
|
CANDIDATE_TTL = timedelta(hours=24)
|
|
21
|
+
RUNTIME_DESCRIPTOR = "devlab-runtime.json"
|
|
22
|
+
RUNTIME_MODES = {"sprite-sequence", "iframe"}
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
def now() -> str:
|
|
@@ -37,18 +40,24 @@ def parse_time(value: str) -> datetime:
|
|
|
37
40
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
def candidate_id(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
def candidate_id(
|
|
44
|
+
task_id: str,
|
|
45
|
+
scene: str,
|
|
46
|
+
context_hash: str,
|
|
47
|
+
source_hash: str,
|
|
48
|
+
render_hash: str,
|
|
49
|
+
runtime_bundle_hash: str | None = None,
|
|
50
|
+
) -> str:
|
|
51
|
+
# Keep the legacy identity formula byte-for-byte when a scene has no live
|
|
52
|
+
# runtime descriptor so already-prepared snapshot candidates remain valid.
|
|
53
|
+
raw = f"{task_id}:{scene}:{context_hash}:{source_hash}:{render_hash}"
|
|
54
|
+
if runtime_bundle_hash:
|
|
55
|
+
raw += f":devlab-runtime:{runtime_bundle_hash}"
|
|
56
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:20]
|
|
43
57
|
|
|
44
58
|
|
|
45
59
|
def review_urls(lab_url: str, scene: str, task_id: str, candidate: str) -> tuple[str, str, str]:
|
|
46
|
-
"""Build an exact review route while anchoring artifact paths at its origin.
|
|
47
|
-
|
|
48
|
-
`--lab-url` is the actual browser route, e.g. `https://host/lab` for the
|
|
49
|
-
React Dev Lab or `http://127.0.0.1:3300/` for the bundled static lab.
|
|
50
|
-
Artifact and task paths intentionally remain same-origin root paths.
|
|
51
|
-
"""
|
|
60
|
+
"""Build an exact review route while anchoring artifact paths at its origin."""
|
|
52
61
|
parsed = urlsplit(lab_url.strip())
|
|
53
62
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
|
|
54
63
|
raise ValueError("lab-url must be an absolute http(s) Dev Lab route without query or fragment")
|
|
@@ -62,7 +71,11 @@ def review_urls(lab_url: str, scene: str, task_id: str, candidate: str) -> tuple
|
|
|
62
71
|
"artifact_base": f"{origin}/scenes/{scene}",
|
|
63
72
|
"task_base": f"{origin}/tasks/{task_id}",
|
|
64
73
|
})
|
|
65
|
-
return
|
|
74
|
+
return (
|
|
75
|
+
f"{review_base}/?{query}" if review_path != "/" else f"{origin}/?{query}",
|
|
76
|
+
f"{origin}/scenes/{scene}",
|
|
77
|
+
f"{origin}/tasks/{task_id}",
|
|
78
|
+
)
|
|
66
79
|
|
|
67
80
|
|
|
68
81
|
def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
|
|
@@ -84,6 +97,137 @@ def paths(task_dir: Path, task: dict) -> tuple[Path, Path, Path, Path]:
|
|
|
84
97
|
return scene_dir, source_path, render_meta, context_path
|
|
85
98
|
|
|
86
99
|
|
|
100
|
+
def safe_relative_file(scene_dir: Path, value: object, label: str) -> tuple[str, Path]:
|
|
101
|
+
if not isinstance(value, str) or not value or value.startswith(("/", "\\")) or "\\" in value:
|
|
102
|
+
raise ValueError(f"{label} must be a scene-relative path")
|
|
103
|
+
parts = Path(value).parts
|
|
104
|
+
if not parts or any(part in {"", ".", ".."} for part in parts):
|
|
105
|
+
raise ValueError(f"{label} contains unsafe path segments: {value!r}")
|
|
106
|
+
scene_root = scene_dir.resolve()
|
|
107
|
+
current = scene_root
|
|
108
|
+
for part in parts:
|
|
109
|
+
current = current / part
|
|
110
|
+
if current.is_symlink():
|
|
111
|
+
raise ValueError(f"{label} must not traverse symlinks: {value}")
|
|
112
|
+
resolved = (scene_root / value).resolve()
|
|
113
|
+
if not resolved.is_file() or scene_root not in resolved.parents:
|
|
114
|
+
raise ValueError(f"{label} must resolve to an existing file inside the scene: {value}")
|
|
115
|
+
return value, resolved
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def runtime_bundle(scene_dir: Path) -> dict | None:
|
|
119
|
+
descriptor_path = scene_dir / RUNTIME_DESCRIPTOR
|
|
120
|
+
if not descriptor_path.exists():
|
|
121
|
+
return None
|
|
122
|
+
if descriptor_path.is_symlink() or not descriptor_path.is_file():
|
|
123
|
+
raise ValueError("devlab-runtime.json must be a regular scene-local file")
|
|
124
|
+
descriptor = read_json(descriptor_path)
|
|
125
|
+
if descriptor.get("schema_version") != "1.0":
|
|
126
|
+
raise ValueError("devlab-runtime.json schema_version must be 1.0")
|
|
127
|
+
mode = descriptor.get("mode")
|
|
128
|
+
if mode not in RUNTIME_MODES:
|
|
129
|
+
raise ValueError(f"devlab-runtime.json mode is unsupported: {mode!r}")
|
|
130
|
+
files = descriptor.get("files")
|
|
131
|
+
if not isinstance(files, list) or not files:
|
|
132
|
+
raise ValueError("devlab-runtime.json requires a non-empty files array")
|
|
133
|
+
resolved_files: dict[str, Path] = {}
|
|
134
|
+
reserved_runtime_files = {RUNTIME_DESCRIPTOR, "browser-review.json", "review.json"}
|
|
135
|
+
for raw in files:
|
|
136
|
+
relative, resolved = safe_relative_file(scene_dir, raw, "runtime file")
|
|
137
|
+
if relative in reserved_runtime_files:
|
|
138
|
+
raise ValueError(f"devlab-runtime.json must not hash mutable review metadata as runtime bytes: {relative}")
|
|
139
|
+
if relative in resolved_files:
|
|
140
|
+
raise ValueError(f"devlab-runtime.json repeats runtime file: {relative}")
|
|
141
|
+
resolved_files[relative] = resolved
|
|
142
|
+
|
|
143
|
+
animations = descriptor.get("animations")
|
|
144
|
+
if not isinstance(animations, list) or not animations:
|
|
145
|
+
raise ValueError("devlab-runtime.json requires at least one animation")
|
|
146
|
+
animation_ids: list[str] = []
|
|
147
|
+
for animation in animations:
|
|
148
|
+
if not isinstance(animation, dict):
|
|
149
|
+
raise ValueError("devlab-runtime.json animations must be objects")
|
|
150
|
+
action_id = animation.get("id")
|
|
151
|
+
if not isinstance(action_id, str) or not SAFE_ANIMATION.fullmatch(action_id):
|
|
152
|
+
raise ValueError(f"devlab-runtime.json animation id is invalid: {action_id!r}")
|
|
153
|
+
if action_id in animation_ids:
|
|
154
|
+
raise ValueError(f"devlab-runtime.json repeats animation id: {action_id}")
|
|
155
|
+
animation_ids.append(action_id)
|
|
156
|
+
if mode == "sprite-sequence":
|
|
157
|
+
fps = animation.get("fps")
|
|
158
|
+
frames = animation.get("frames")
|
|
159
|
+
if not isinstance(fps, (int, float)) or isinstance(fps, bool) or fps <= 0:
|
|
160
|
+
raise ValueError(f"sprite animation {action_id} requires a positive fps")
|
|
161
|
+
if not isinstance(frames, list) or not frames:
|
|
162
|
+
raise ValueError(f"sprite animation {action_id} requires frames")
|
|
163
|
+
for frame in frames:
|
|
164
|
+
relative, _ = safe_relative_file(scene_dir, frame, f"frame for {action_id}")
|
|
165
|
+
if relative not in resolved_files:
|
|
166
|
+
raise ValueError(f"frame for {action_id} is not declared in runtime files: {relative}")
|
|
167
|
+
|
|
168
|
+
default_animation = descriptor.get("default_animation")
|
|
169
|
+
if default_animation not in animation_ids:
|
|
170
|
+
raise ValueError("devlab-runtime.json default_animation must reference a declared animation")
|
|
171
|
+
if mode == "iframe":
|
|
172
|
+
entrypoint, _ = safe_relative_file(scene_dir, descriptor.get("entrypoint"), "runtime entrypoint")
|
|
173
|
+
if entrypoint not in resolved_files:
|
|
174
|
+
raise ValueError("runtime entrypoint must be declared in runtime files")
|
|
175
|
+
|
|
176
|
+
controls = descriptor.get("controls")
|
|
177
|
+
if not isinstance(controls, dict):
|
|
178
|
+
raise ValueError("devlab-runtime.json controls object is required")
|
|
179
|
+
for control in ("play", "pause", "restart", "seek", "step", "speed", "loop"):
|
|
180
|
+
if not isinstance(controls.get(control), bool):
|
|
181
|
+
raise ValueError(f"devlab-runtime.json controls.{control} must be boolean")
|
|
182
|
+
review_policy = descriptor.get("review_policy")
|
|
183
|
+
if not isinstance(review_policy, dict) or not isinstance(review_policy.get("require_all_animations"), bool):
|
|
184
|
+
raise ValueError("devlab-runtime.json review_policy.require_all_animations must be boolean")
|
|
185
|
+
|
|
186
|
+
digest = hashlib.sha256()
|
|
187
|
+
digest.update(b"motionloom-devlab-runtime-v1\0")
|
|
188
|
+
digest.update(descriptor_path.read_bytes())
|
|
189
|
+
for relative in sorted(resolved_files):
|
|
190
|
+
digest.update(b"\0path\0")
|
|
191
|
+
digest.update(relative.encode("utf-8"))
|
|
192
|
+
digest.update(b"\0bytes\0")
|
|
193
|
+
digest.update(resolved_files[relative].read_bytes())
|
|
194
|
+
return {
|
|
195
|
+
"descriptor": descriptor,
|
|
196
|
+
"bundle_sha256": digest.hexdigest(),
|
|
197
|
+
"animations": animation_ids,
|
|
198
|
+
"mode": mode,
|
|
199
|
+
"files": sorted(resolved_files),
|
|
200
|
+
"review_policy": {"require_all_animations": review_policy["require_all_animations"]},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def runtime_review_payload(bundle: dict | None) -> dict:
|
|
205
|
+
if not bundle:
|
|
206
|
+
return {
|
|
207
|
+
"live": False,
|
|
208
|
+
"mode": "captured-evidence",
|
|
209
|
+
"checkpoints": [0, 50, 100],
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
"live": True,
|
|
213
|
+
"mode": bundle["mode"],
|
|
214
|
+
"descriptor": RUNTIME_DESCRIPTOR,
|
|
215
|
+
"bundle_sha256": bundle["bundle_sha256"],
|
|
216
|
+
"animations": bundle["animations"],
|
|
217
|
+
"review_policy": bundle["review_policy"],
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def review_instruction(bundle: dict | None) -> str:
|
|
222
|
+
if bundle:
|
|
223
|
+
actions = ", ".join(bundle["animations"])
|
|
224
|
+
return (
|
|
225
|
+
"Open this exact URL in the internal browser; exercise the live runtime controls, "
|
|
226
|
+
f"inspect the declared animations ({actions}), scrub/step the candidate, and ask the user to approve or request changes."
|
|
227
|
+
)
|
|
228
|
+
return "Open this exact URL in the internal browser; inspect frames 0/50/100 and ask the user to approve or request changes."
|
|
229
|
+
|
|
230
|
+
|
|
87
231
|
def prepare(args: argparse.Namespace) -> int:
|
|
88
232
|
task_dir = Path(args.task_dir).resolve()
|
|
89
233
|
task_path = task_dir / "task.json"
|
|
@@ -92,6 +236,7 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
92
236
|
if not scene:
|
|
93
237
|
raise ValueError("task.json requires scene")
|
|
94
238
|
scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
|
|
239
|
+
bundle = runtime_bundle(scene_dir)
|
|
95
240
|
memory_path = ROOT / ".motionloom" / "project-memory.json"
|
|
96
241
|
memory = read_json(memory_path, {}) if memory_path.is_file() else {}
|
|
97
242
|
memory_status = (memory.get("freshness") or {}).get("status")
|
|
@@ -101,7 +246,7 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
101
246
|
context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
|
|
102
247
|
source_hash = sha256(source_path)
|
|
103
248
|
render_hash = sha256(render_meta)
|
|
104
|
-
cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash)
|
|
249
|
+
cid = candidate_id(task["task_id"], scene, context_hash, source_hash, render_hash, bundle["bundle_sha256"] if bundle else None)
|
|
105
250
|
task_id = task["task_id"]
|
|
106
251
|
url, artifact_base, task_base = review_urls(args.lab_url, scene, task_id, cid)
|
|
107
252
|
candidate = {
|
|
@@ -115,6 +260,7 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
115
260
|
"source_sha256": source_hash,
|
|
116
261
|
"runtime": spec.get("framework", "unknown"),
|
|
117
262
|
"checkpoints": [0, 50, 100],
|
|
263
|
+
"runtime_review": runtime_review_payload(bundle),
|
|
118
264
|
"review_artifact": "review.json",
|
|
119
265
|
"requires_user_approval": True,
|
|
120
266
|
"prepared_at": now(),
|
|
@@ -126,27 +272,81 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
126
272
|
(scene_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
|
|
127
273
|
task["state"] = "review_required"
|
|
128
274
|
task["updated_at"] = now()
|
|
129
|
-
task["browser_review"] = {
|
|
275
|
+
task["browser_review"] = {
|
|
276
|
+
"required": True,
|
|
277
|
+
"status": "prepared",
|
|
278
|
+
"candidate_id": cid,
|
|
279
|
+
"candidate_path": "browser-review.json",
|
|
280
|
+
"review_artifact": "review.json",
|
|
281
|
+
"runtime_review": candidate["runtime_review"],
|
|
282
|
+
}
|
|
130
283
|
(task_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
|
|
131
284
|
(task_dir / "task.json").write_text(json.dumps(task, indent=2) + "\n", encoding="utf-8")
|
|
132
285
|
report_path = task_dir / "execution-report.json"
|
|
133
286
|
report = read_json(report_path)
|
|
134
287
|
report["status"] = "review_required"
|
|
135
288
|
report["browser_review"] = [item for item in report.get("browser_review", []) if item.get("candidate_id") != cid]
|
|
136
|
-
report["browser_review"].append({
|
|
289
|
+
report["browser_review"].append({
|
|
290
|
+
"candidate_id": cid,
|
|
291
|
+
"decision": "pending",
|
|
292
|
+
"evidence": ["browser-review.json"] + ([RUNTIME_DESCRIPTOR] if bundle else []),
|
|
293
|
+
"next_action": review_instruction(bundle),
|
|
294
|
+
})
|
|
137
295
|
report["next_agent"] = [item for item in report.get("next_agent", []) if item.get("id") != "browser-review"]
|
|
138
|
-
report["next_agent"].append({
|
|
296
|
+
report["next_agent"].append({
|
|
297
|
+
"id": "browser-review",
|
|
298
|
+
"summary": "Open the exact candidate in the internal Dev Lab browser.",
|
|
299
|
+
"status": "pending",
|
|
300
|
+
"agent": "browser-review-agent",
|
|
301
|
+
"skill": "browser-review",
|
|
302
|
+
"evidence_needed": ["review.json"],
|
|
303
|
+
"next_action": review_instruction(bundle),
|
|
304
|
+
})
|
|
139
305
|
report["generated_at"] = now()
|
|
140
306
|
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
141
307
|
handoff_path = task_dir / "handoff.json"
|
|
142
308
|
handoff = read_json(handoff_path)
|
|
143
309
|
if memory_path.is_file():
|
|
144
310
|
shutil.copy2(memory_path, task_dir / "project-memory.json")
|
|
145
|
-
handoff.update({
|
|
311
|
+
handoff.update({
|
|
312
|
+
"state": "review_required",
|
|
313
|
+
"to_agent": "browser-review-agent",
|
|
314
|
+
"summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.",
|
|
315
|
+
"next_actions": [{
|
|
316
|
+
"action": "Open internal Dev Lab candidate",
|
|
317
|
+
"kind": "browser_review",
|
|
318
|
+
"agent": "browser-review-agent",
|
|
319
|
+
"skill": "browser-review",
|
|
320
|
+
"url": url,
|
|
321
|
+
"artifact_base": artifact_base,
|
|
322
|
+
"task_base": task_base,
|
|
323
|
+
"candidate_id": cid,
|
|
324
|
+
"runtime_review": candidate["runtime_review"],
|
|
325
|
+
"requires_user_approval": True,
|
|
326
|
+
"evidence_needed": ["review.json"],
|
|
327
|
+
"output_artifacts": ["review.json"],
|
|
328
|
+
}],
|
|
329
|
+
"required_artifacts": sorted(set(
|
|
330
|
+
handoff.get("required_artifacts", [])
|
|
331
|
+
+ ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"]
|
|
332
|
+
+ ([RUNTIME_DESCRIPTOR] if bundle else [])
|
|
333
|
+
+ (["project-memory.json"] if memory_path.is_file() else [])
|
|
334
|
+
)),
|
|
335
|
+
})
|
|
146
336
|
handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
147
337
|
subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
148
338
|
subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
149
|
-
print(json.dumps({
|
|
339
|
+
print(json.dumps({
|
|
340
|
+
"status": "review_required",
|
|
341
|
+
"task_id": task["task_id"],
|
|
342
|
+
"candidate_id": cid,
|
|
343
|
+
"url": url,
|
|
344
|
+
"agent": "browser-review-agent",
|
|
345
|
+
"action": review_instruction(bundle),
|
|
346
|
+
"runtime_review": candidate["runtime_review"],
|
|
347
|
+
"requires_user_approval": True,
|
|
348
|
+
"output_artifacts": ["review.json"],
|
|
349
|
+
}, ensure_ascii=False))
|
|
150
350
|
return 0
|
|
151
351
|
|
|
152
352
|
|
|
@@ -156,8 +356,12 @@ def validate(args: argparse.Namespace) -> int:
|
|
|
156
356
|
candidate = read_json(task_dir / "browser-review.json")
|
|
157
357
|
scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
|
|
158
358
|
spec = read_json(scene_dir / "motion-spec.json")
|
|
359
|
+
bundle = runtime_bundle(scene_dir)
|
|
159
360
|
context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
|
|
160
|
-
expected = candidate_id(
|
|
361
|
+
expected = candidate_id(
|
|
362
|
+
task["task_id"], task["scene"], context_hash, sha256(source_path), sha256(render_meta),
|
|
363
|
+
bundle["bundle_sha256"] if bundle else None,
|
|
364
|
+
)
|
|
161
365
|
errors = []
|
|
162
366
|
if not candidate.get("expires_at"):
|
|
163
367
|
errors.append("browser-review candidate has no expiry")
|
|
@@ -168,7 +372,7 @@ def validate(args: argparse.Namespace) -> int:
|
|
|
168
372
|
except (TypeError, ValueError):
|
|
169
373
|
errors.append("browser-review candidate expiry is invalid")
|
|
170
374
|
if candidate.get("candidate_id") != expected:
|
|
171
|
-
errors.append("candidate_id does not match task, context, source and runtime
|
|
375
|
+
errors.append("candidate_id does not match task, context, source, runtime metadata and live runtime bundle")
|
|
172
376
|
if candidate.get("task_id") != task.get("task_id"):
|
|
173
377
|
errors.append("candidate task_id does not match task.json")
|
|
174
378
|
task_review = task.get("browser_review") or {}
|
|
@@ -184,6 +388,22 @@ def validate(args: argparse.Namespace) -> int:
|
|
|
184
388
|
errors.append("candidate context_sha256 is stale")
|
|
185
389
|
if candidate.get("status") not in {"prepared", "opened", "reviewed", "approved", "changes_requested", "expired"}:
|
|
186
390
|
errors.append(f"browser-review candidate status is invalid: {candidate.get('status')}")
|
|
391
|
+
|
|
392
|
+
runtime_review = candidate.get("runtime_review") or {}
|
|
393
|
+
if bundle:
|
|
394
|
+
if runtime_review.get("live") is not True:
|
|
395
|
+
errors.append("candidate does not declare the available live runtime")
|
|
396
|
+
if runtime_review.get("descriptor") != RUNTIME_DESCRIPTOR:
|
|
397
|
+
errors.append("candidate live runtime descriptor binding is missing")
|
|
398
|
+
if runtime_review.get("mode") != bundle["mode"]:
|
|
399
|
+
errors.append("candidate live runtime mode is stale")
|
|
400
|
+
if runtime_review.get("bundle_sha256") != bundle["bundle_sha256"]:
|
|
401
|
+
errors.append("candidate live runtime bundle hash is stale")
|
|
402
|
+
if runtime_review.get("animations") != bundle["animations"]:
|
|
403
|
+
errors.append("candidate live runtime animation set is stale")
|
|
404
|
+
elif runtime_review.get("live") is True:
|
|
405
|
+
errors.append("candidate claims live runtime but devlab-runtime.json is missing")
|
|
406
|
+
|
|
187
407
|
review = read_json(task_dir / "review.json", {})
|
|
188
408
|
if review and review.get("candidate_id") != candidate.get("candidate_id"):
|
|
189
409
|
errors.append("review.json approves a different candidate")
|
|
@@ -199,13 +419,29 @@ def validate(args: argparse.Namespace) -> int:
|
|
|
199
419
|
errors.append("review.json was recorded after candidate expiry")
|
|
200
420
|
except (TypeError, ValueError, KeyError):
|
|
201
421
|
errors.append("review.json reviewed_at or candidate expiry is invalid")
|
|
422
|
+
if review.get("decision") == "approved" and bundle and bundle["review_policy"]["require_all_animations"]:
|
|
423
|
+
inspected = set(review.get("animations_inspected") or [])
|
|
424
|
+
required = {
|
|
425
|
+
animation["id"] for animation in bundle["descriptor"]["animations"]
|
|
426
|
+
if animation.get("review_required", True)
|
|
427
|
+
}
|
|
428
|
+
missing = sorted(required - inspected)
|
|
429
|
+
if missing:
|
|
430
|
+
errors.append(f"approved review did not inspect required animations: {', '.join(missing)}")
|
|
202
431
|
if args.require_approved:
|
|
203
432
|
if candidate.get("status") != "approved":
|
|
204
433
|
errors.append("browser-review candidate is not approved")
|
|
205
434
|
if not review or review.get("decision") != "approved":
|
|
206
435
|
errors.append("review.json decision must be approved")
|
|
207
436
|
status = "pass" if not errors else "fail"
|
|
208
|
-
print(json.dumps({
|
|
437
|
+
print(json.dumps({
|
|
438
|
+
"status": status,
|
|
439
|
+
"task_id": task.get("task_id"),
|
|
440
|
+
"candidate_id": candidate.get("candidate_id"),
|
|
441
|
+
"candidate_status": candidate.get("status"),
|
|
442
|
+
"runtime_review": runtime_review,
|
|
443
|
+
"errors": errors,
|
|
444
|
+
}, ensure_ascii=False))
|
|
209
445
|
return 0 if not errors else 1
|
|
210
446
|
|
|
211
447
|
|
|
@@ -6,9 +6,11 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
7
|
import { chromium } from "playwright";
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
9
10
|
|
|
10
|
-
const ROOT = path.resolve(
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
12
|
const outputRoot = path.resolve(process.env.RUNTIME_EVIDENCE_DIR || path.join(ROOT, "artifacts/runtime-adapters"));
|
|
13
|
+
const outputPolicyRoot = path.resolve(process.env.MOTIONLOOM_RUNTIME_OUTPUT_ROOT || ROOT);
|
|
12
14
|
const port = Number(process.env.RUNTIME_HARNESS_PORT || 4179);
|
|
13
15
|
const supportedFrameworks = new Set(["rive", "gsap", "framer-motion"]);
|
|
14
16
|
const frameworks = (process.env.RUNTIME_FRAMEWORKS || "rive,gsap,framer-motion")
|
|
@@ -17,8 +19,31 @@ const unsupported = frameworks.filter((name) => !supportedFrameworks.has(name));
|
|
|
17
19
|
if (unsupported.length) {
|
|
18
20
|
throw new Error(`unsupported runtime framework(s): ${unsupported.join(", ")}`);
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
function canonicalPath(target) {
|
|
23
|
+
let existing = target;
|
|
24
|
+
const missing = [];
|
|
25
|
+
while (!fs.existsSync(existing)) {
|
|
26
|
+
const parent = path.dirname(existing);
|
|
27
|
+
if (parent === existing) break;
|
|
28
|
+
missing.unshift(path.basename(existing));
|
|
29
|
+
existing = parent;
|
|
30
|
+
}
|
|
31
|
+
const base = fs.existsSync(existing) ? fs.realpathSync(existing) : path.resolve(existing);
|
|
32
|
+
return path.resolve(base, ...missing);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isStrictChild(target, parent) {
|
|
36
|
+
const relative = path.relative(parent, target);
|
|
37
|
+
return Boolean(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const canonicalOutputRoot = canonicalPath(outputRoot);
|
|
41
|
+
const canonicalPolicyRoot = canonicalPath(outputPolicyRoot);
|
|
42
|
+
if (!isStrictChild(canonicalOutputRoot, canonicalPolicyRoot)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`RUNTIME_EVIDENCE_DIR must be a dedicated child of ${outputPolicyRoot}; ` +
|
|
45
|
+
"set MOTIONLOOM_RUNTIME_OUTPUT_ROOT explicitly to authorize another parent",
|
|
46
|
+
);
|
|
22
47
|
}
|
|
23
48
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
24
49
|
const runId = `${Date.now()}-${process.pid}`;
|
package/scripts/skill-doctor.py
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
import importlib.util
|
|
7
8
|
import json
|
|
8
9
|
import re
|
|
9
10
|
import sys
|
|
@@ -11,7 +12,15 @@ from pathlib import Path
|
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
-
REQUIRED_FILES = [
|
|
15
|
+
REQUIRED_FILES = [
|
|
16
|
+
"SKILL.md",
|
|
17
|
+
"agent-card.json",
|
|
18
|
+
"package.json",
|
|
19
|
+
"requirements.txt",
|
|
20
|
+
"dev-lab/public/index.html",
|
|
21
|
+
"dev-lab/public/devlab.js",
|
|
22
|
+
"tests/runtime-harness/index.html",
|
|
23
|
+
]
|
|
15
24
|
REQUIRED_DIRS = ["scripts", "templates", "references", "schemas"]
|
|
16
25
|
REQUIRED_SCRIPT_FILES = [
|
|
17
26
|
"scripts/report-contract.py",
|
|
@@ -135,9 +144,26 @@ def run() -> int:
|
|
|
135
144
|
for script in ("test", "validate", "doctor", "setup", "setup:dry", "status", "repair", "report", "report:check", "review", "memory:bootstrap", "memory:recover", "memory:validate", "devlab", "pack:dotlottie"):
|
|
136
145
|
if script not in package.get("scripts", {}):
|
|
137
146
|
warnings.append({"code": "missing_package_script", "message": f"package.json has no {script} script."})
|
|
147
|
+
runtime_dependencies = {
|
|
148
|
+
**package.get("dependencies", {}),
|
|
149
|
+
**package.get("optionalDependencies", {}),
|
|
150
|
+
}
|
|
151
|
+
for dependency in ("playwright", "vite"):
|
|
152
|
+
present = dependency in runtime_dependencies
|
|
153
|
+
checks.append({"id": f"runtime-dependency:{dependency}", "status": "pass" if present else "fail"})
|
|
154
|
+
if not present:
|
|
155
|
+
errors.append({"code": "missing_runtime_dependency", "message": f"package.json runtime dependencies omit {dependency}."})
|
|
138
156
|
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
|
139
157
|
errors.append({"code": "invalid_package_json", "message": str(exc)})
|
|
140
158
|
|
|
159
|
+
cryptography_available = importlib.util.find_spec("cryptography") is not None
|
|
160
|
+
checks.append({"id": "python-dependency:cryptography", "status": "pass" if cryptography_available else "fail"})
|
|
161
|
+
if not cryptography_available:
|
|
162
|
+
errors.append({
|
|
163
|
+
"code": "missing_python_dependency",
|
|
164
|
+
"message": "Python package cryptography is required; install dependencies from requirements.txt.",
|
|
165
|
+
})
|
|
166
|
+
|
|
141
167
|
result = {
|
|
142
168
|
"doctor_version": "1.0",
|
|
143
169
|
"skill_root": str(ROOT),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Animation Skill Runtime Harness</title>
|
|
7
|
+
<style>
|
|
8
|
+
html, body { margin: 0; min-height: 100%; background: #111827; color: #f8fafc; font-family: system-ui, sans-serif; }
|
|
9
|
+
body { display: grid; place-items: center; }
|
|
10
|
+
#root { width: 512px; height: 512px; display: grid; place-items: center; }
|
|
11
|
+
.stage { position: relative; width: 420px; height: 420px; overflow: hidden; background: #f8fafc; border-radius: 12px; }
|
|
12
|
+
.stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; border: 1px solid rgba(15,23,42,.2); border-radius: inherit; }
|
|
13
|
+
.gsap-box, .motion-box { width: 96px; height: 96px; position: absolute; left: 40px; top: 160px; border-radius: 18px; background: #2563eb; transform-origin: center; }
|
|
14
|
+
.gsap-box::after, .motion-box::after { content: ""; position: absolute; width: 16px; height: 16px; border-radius: 50%; right: 14px; top: 14px; background: #f59e0b; }
|
|
15
|
+
canvas { width: 420px; height: 420px; display: block; }
|
|
16
|
+
#status { position: fixed; left: 12px; bottom: 12px; font: 12px ui-monospace, monospace; color: #fbbf24; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div id="root"></div>
|
|
21
|
+
<div id="status">loading</div>
|
|
22
|
+
<script type="module" src="/tests/runtime-harness/main.jsx"></script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|