test_runtime_files.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. import json
  2. from pathlib import Path
  3. from content_agent.errors import ErrorCode
  4. from content_agent.integrations.runtime_files import LocalRuntimeFileStore
  5. from content_agent.run_service import RunService
  6. from content_agent.schemas import RunStartRequest
  7. from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
  8. def _start_mock_run(tmp_path, **kwargs):
  9. service = RunService(
  10. runtime_root=tmp_path / "runtime" / "v1",
  11. query_variant_client=FakeQueryVariantClient(),
  12. )
  13. kwargs.setdefault("source", str(REAL_SOURCE_FIXTURE))
  14. state = service.start_run(RunStartRequest(platform_mode="mock", **kwargs))
  15. assert state["status"] == "success"
  16. return service, state["run_id"]
  17. def test_runtime_files_are_parseable_and_consistent(tmp_path):
  18. service, run_id = _start_mock_run(tmp_path)
  19. run_dir = service.runtime.run_dir(run_id)
  20. for json_file in [
  21. "source_context.json",
  22. "pattern_seed_pack.json",
  23. "final_output.json",
  24. "strategy_review.json",
  25. ]:
  26. data = json.loads((run_dir / json_file).read_text(encoding="utf-8"))
  27. assert data["schema_version"] == "runtime_record.v1"
  28. for jsonl_file in [
  29. "search_queries.jsonl",
  30. "discovered_content_items.jsonl",
  31. "content_media_records.jsonl",
  32. "pattern_recall_evidence.jsonl",
  33. "rule_decisions.jsonl",
  34. "walk_actions.jsonl",
  35. "run_events.jsonl",
  36. "source_path_records.jsonl",
  37. "search_clues.jsonl",
  38. ]:
  39. for line in (run_dir / jsonl_file).read_text(encoding="utf-8").splitlines():
  40. json.loads(line)
  41. items = service.read_jsonl(run_id, "discovered_content_items.jsonl")
  42. decisions = service.read_jsonl(run_id, "rule_decisions.jsonl")
  43. paths = service.read_jsonl(run_id, "source_path_records.jsonl")
  44. final_output = service.read_json(run_id, "final_output.json")
  45. source_context = service.read_json(run_id, "source_context.json")
  46. policy_run_id = service.read_json(run_id, "pattern_seed_pack.json")["policy_run_id"]
  47. content_ids = {item["platform_content_id"] for item in items}
  48. decision_target_ids = {decision["decision_target_id"] for decision in decisions}
  49. assert content_ids == decision_target_ids
  50. media_records = service.read_jsonl(run_id, "content_media_records.jsonl")
  51. recall_evidence = service.read_jsonl(run_id, "pattern_recall_evidence.jsonl")
  52. walk_actions = service.read_jsonl(run_id, "walk_actions.jsonl")
  53. for row in [*items, *media_records, *recall_evidence, *decisions, *walk_actions, *paths]:
  54. assert row["policy_run_id"] == policy_run_id
  55. assert row["record_schema_version"] == "runtime_record.v1"
  56. assert row["raw_payload"]["run_id"] == run_id
  57. assert {row["walk_status"] for row in walk_actions} <= {
  58. "success",
  59. "pending",
  60. "failed",
  61. "skipped",
  62. "rule_blocked",
  63. }
  64. assert all(row["walk_action_id"].startswith("wa_") for row in walk_actions)
  65. for media_record in media_records:
  66. assert media_record["platform"] == "douyin"
  67. assert final_output["policy_run_id"] == policy_run_id
  68. assert final_output["policy"]["policy_bundle_id"] == "douyin_policy_bundle_v1"
  69. assert final_output["policy"]["rule_pack_source_ref"]["file"].endswith(
  70. "douyin_rule_packs.v1.json"
  71. )
  72. assert final_output["walk_strategy"]["walk_strategy_version"] == "V1.0"
  73. assert "policy_run_id" not in source_context
  74. assert {decision["decision_action"] for decision in decisions} <= {
  75. "ADD_TO_CONTENT_POOL",
  76. "KEEP_CONTENT_FOR_REVIEW",
  77. "REJECT_CONTENT",
  78. }
  79. assert {decision["search_query_effect_status"] for decision in decisions} <= {
  80. "success",
  81. "pending",
  82. "failed",
  83. "rule_blocked",
  84. }
  85. search_clues = service.read_jsonl(run_id, "search_clues.jsonl")
  86. assert {clue["search_query_effect_status"] for clue in search_clues} <= {
  87. "success",
  88. "pending",
  89. "failed",
  90. "rule_blocked",
  91. }
  92. path_ids = {path["source_path_record_id"] for path in paths}
  93. for asset in final_output["content_assets"]:
  94. assert set(asset["source_path_record_ids"]) <= path_ids
  95. evidence_pack = source_context["ext_data"]["evidence_pack"]
  96. assert evidence_pack["pattern_source_system"] == "pg_pattern_v2"
  97. assert evidence_pack["pattern_execution_id"] == 581
  98. assert evidence_pack["mining_config_id"] == 2082
  99. assert evidence_pack["itemset_ids"] == [1608352]
  100. validation = service.validate_run(run_id)
  101. assert validation["status"] == "pass"
  102. def test_content_media_records_replace_same_video_row(tmp_path):
  103. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  104. runtime.prepare_run("run_001")
  105. base = {
  106. "record_schema_version": "runtime_record.v1",
  107. "run_id": "run_001",
  108. "policy_run_id": "policy_run_001",
  109. "platform": "douyin",
  110. "platform_content_id": "content_001",
  111. "content_media_status": "metadata_only",
  112. "content_metadata_source": "douyin_keyword_search",
  113. "play_url": "https://source.example/video.mp4",
  114. "local_path": None,
  115. "oss_url": None,
  116. "raw_payload": {"run_id": "run_001", "platform_content_id": "content_001"},
  117. "created_at": "2026-06-15T00:00:00+00:00",
  118. }
  119. runtime.append_jsonl("run_001", "content_media_records.jsonl", [base])
  120. runtime.append_jsonl(
  121. "run_001",
  122. "content_media_records.jsonl",
  123. [
  124. {
  125. **base,
  126. "content_media_status": "oss_uploaded",
  127. "oss_url": "https://res.example/video.mp4",
  128. "raw_payload": {
  129. **base["raw_payload"],
  130. "oss_object_key": "crawler/video/content_001.mp4",
  131. },
  132. }
  133. ],
  134. )
  135. rows = runtime.read_jsonl("run_001", "content_media_records.jsonl")
  136. assert len(rows) == 1
  137. assert rows[0]["content_media_status"] == "oss_uploaded"
  138. assert rows[0]["oss_url"] == "https://res.example/video.mp4"
  139. def test_all_platform_query_failure_writes_failed_query_runtime_records(tmp_path):
  140. service = RunService(
  141. runtime_root=tmp_path / "runtime" / "v1",
  142. query_variant_client=FakeQueryVariantClient(),
  143. )
  144. service._platform_client = lambda platform, platform_mode: _AllFailurePlatformClient()
  145. state = service.start_run(
  146. RunStartRequest(platform_mode="real", source=str(REAL_SOURCE_FIXTURE))
  147. )
  148. assert state["status"] == "failed"
  149. assert state["error_code"] == ErrorCode.PLATFORM_REQUEST_FAILED.value
  150. search_queries = service.read_jsonl(state["run_id"], "search_queries.jsonl")
  151. search_clues = service.read_jsonl(state["run_id"], "search_clues.jsonl")
  152. run_events = service.read_jsonl(state["run_id"], "run_events.jsonl")
  153. assert len(search_queries) == len(state["error_detail"]["query_failures"])
  154. assert {query["search_query_effect_status"] for query in search_queries} == {"failed"}
  155. assert all(query["raw_payload"]["query_failure"]["status"] == "failed" for query in search_queries)
  156. assert {clue["search_query_effect_status"] for clue in search_clues} == {"failed"}
  157. assert {clue["result_count"] for clue in search_clues} == {0}
  158. assert {clue["query_aggregation_id"] for clue in search_clues} == {"platform_query_failure"}
  159. platform_failures = [
  160. event for event in run_events if event["event_type"] == "platform_query_failed"
  161. ]
  162. assert len(platform_failures) == len(search_queries)
  163. assert {event["error_code"] for event in platform_failures} == {
  164. ErrorCode.PLATFORM_REQUEST_FAILED.value
  165. }
  166. def test_runtime_validation_catches_summary_drift(tmp_path):
  167. service, run_id = _start_mock_run(tmp_path)
  168. final_output_path = service.runtime.run_dir(run_id) / "final_output.json"
  169. final_output = json.loads(final_output_path.read_text(encoding="utf-8"))
  170. final_output["summary"]["pooled_content_count"] = 99
  171. final_output_path.write_text(
  172. json.dumps(final_output, ensure_ascii=False, indent=2) + "\n",
  173. encoding="utf-8",
  174. )
  175. validation = service.validate_run(run_id)
  176. assert validation["status"] == "fail"
  177. assert any(finding["check_id"] == "summary_mismatch" for finding in validation["findings"])
  178. def test_local_runtime_replaces_pattern_recall_evidence_by_id(tmp_path):
  179. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  180. runtime.prepare_run("run_001")
  181. runtime.append_jsonl(
  182. "run_001",
  183. "pattern_recall_evidence.jsonl",
  184. [
  185. {
  186. "run_id": "run_001",
  187. "policy_run_id": "policy_001",
  188. "recall_evidence_id": "recall_001",
  189. "recall_status": "pending",
  190. }
  191. ],
  192. )
  193. runtime.append_jsonl(
  194. "run_001",
  195. "pattern_recall_evidence.jsonl",
  196. [
  197. {
  198. "run_id": "run_001",
  199. "policy_run_id": "policy_001",
  200. "recall_evidence_id": "recall_001",
  201. "recall_status": "matched",
  202. }
  203. ],
  204. )
  205. rows = runtime.read_jsonl("run_001", "pattern_recall_evidence.jsonl")
  206. assert len(rows) == 1
  207. assert rows[0]["recall_status"] == "matched"
  208. def test_runtime_validation_catches_missing_policy_run_id(tmp_path):
  209. service, run_id = _start_mock_run(tmp_path)
  210. decisions_path = service.runtime.run_dir(run_id) / "rule_decisions.jsonl"
  211. decisions = [
  212. json.loads(line)
  213. for line in decisions_path.read_text(encoding="utf-8").splitlines()
  214. if line.strip()
  215. ]
  216. decisions[0].pop("policy_run_id")
  217. decisions_path.write_text(
  218. "".join(
  219. json.dumps(decision, ensure_ascii=False, separators=(",", ":")) + "\n"
  220. for decision in decisions
  221. ),
  222. encoding="utf-8",
  223. )
  224. validation = service.validate_run(run_id)
  225. assert validation["status"] == "fail"
  226. assert any(finding["check_id"] == "policy_run_id_mismatch" for finding in validation["findings"])
  227. def test_runtime_validation_catches_missing_record_schema_version(tmp_path):
  228. service, run_id = _start_mock_run(tmp_path)
  229. queries_path = service.runtime.run_dir(run_id) / "search_queries.jsonl"
  230. queries = [
  231. json.loads(line)
  232. for line in queries_path.read_text(encoding="utf-8").splitlines()
  233. if line.strip()
  234. ]
  235. queries[0].pop("record_schema_version")
  236. queries_path.write_text(
  237. "".join(
  238. json.dumps(query, ensure_ascii=False, separators=(",", ":")) + "\n"
  239. for query in queries
  240. ),
  241. encoding="utf-8",
  242. )
  243. validation = service.validate_run(run_id)
  244. assert validation["status"] == "fail"
  245. assert any(
  246. finding["check_id"] == "record_schema_version_missing"
  247. for finding in validation["findings"]
  248. )
  249. class _AllFailurePlatformClient:
  250. def search(self, search_query: dict) -> list[dict]:
  251. raise RuntimeError("platform unavailable")
  252. def test_runtime_validation_catches_missing_raw_payload(tmp_path):
  253. service, run_id = _start_mock_run(tmp_path)
  254. media_path = service.runtime.run_dir(run_id) / "content_media_records.jsonl"
  255. media_records = [
  256. json.loads(line)
  257. for line in media_path.read_text(encoding="utf-8").splitlines()
  258. if line.strip()
  259. ]
  260. media_records[0].pop("raw_payload")
  261. media_path.write_text(
  262. "".join(
  263. json.dumps(media_record, ensure_ascii=False, separators=(",", ":")) + "\n"
  264. for media_record in media_records
  265. ),
  266. encoding="utf-8",
  267. )
  268. validation = service.validate_run(run_id)
  269. assert validation["status"] == "fail"
  270. assert any(finding["check_id"] == "raw_payload_missing" for finding in validation["findings"])
  271. def test_runtime_validation_catches_forbidden_raw_payload_key(tmp_path):
  272. service, run_id = _start_mock_run(tmp_path)
  273. media_path = service.runtime.run_dir(run_id) / "content_media_records.jsonl"
  274. media_records = [
  275. json.loads(line)
  276. for line in media_path.read_text(encoding="utf-8").splitlines()
  277. if line.strip()
  278. ]
  279. media_records[0]["raw_payload"]["secret"] = "should_not_be_stored"
  280. media_path.write_text(
  281. "".join(
  282. json.dumps(media_record, ensure_ascii=False, separators=(",", ":")) + "\n"
  283. for media_record in media_records
  284. ),
  285. encoding="utf-8",
  286. )
  287. validation = service.validate_run(run_id)
  288. assert validation["status"] == "fail"
  289. assert any(
  290. finding["check_id"] == "raw_payload_forbidden_key"
  291. for finding in validation["findings"]
  292. )
  293. def test_runtime_validation_catches_missing_pattern_recall_evidence(tmp_path):
  294. service, run_id = _start_mock_run(tmp_path)
  295. items_path = service.runtime.run_dir(run_id) / "discovered_content_items.jsonl"
  296. items = [
  297. json.loads(line)
  298. for line in items_path.read_text(encoding="utf-8").splitlines()
  299. if line.strip()
  300. ]
  301. items[0]["pattern_match_result"].pop("pattern_recall_evidence_id", None)
  302. items_path.write_text(
  303. "".join(
  304. json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n"
  305. for item in items
  306. ),
  307. encoding="utf-8",
  308. )
  309. validation = service.validate_run(run_id)
  310. assert validation["status"] == "fail"
  311. assert any(
  312. finding["check_id"] == "pattern_recall_evidence_missing"
  313. for finding in validation["findings"]
  314. )
  315. def test_runtime_validation_allows_missing_decode_case_ids_in_source_evidence(tmp_path):
  316. service, run_id = _start_mock_run(tmp_path)
  317. run_dir = service.runtime.run_dir(run_id)
  318. decisions_path = run_dir / "rule_decisions.jsonl"
  319. decisions = [
  320. json.loads(line)
  321. for line in decisions_path.read_text(encoding="utf-8").splitlines()
  322. if line.strip()
  323. ]
  324. for decision in decisions:
  325. decision["source_evidence"].pop("decode_case_ids", None)
  326. decisions_path.write_text(
  327. "".join(
  328. json.dumps(decision, ensure_ascii=False, separators=(",", ":")) + "\n"
  329. for decision in decisions
  330. ),
  331. encoding="utf-8",
  332. )
  333. final_output_path = run_dir / "final_output.json"
  334. final_output = json.loads(final_output_path.read_text(encoding="utf-8"))
  335. for section in ["content_assets", "reject_records", "decision_records"]:
  336. for row in final_output.get(section, []):
  337. row.get("source_evidence", {}).pop("decode_case_ids", None)
  338. final_output_path.write_text(
  339. json.dumps(final_output, ensure_ascii=False, indent=2) + "\n",
  340. encoding="utf-8",
  341. )
  342. validation = service.validate_run(run_id)
  343. assert validation["status"] == "pass"
  344. def test_runtime_validation_catches_missing_final_decision_record(tmp_path):
  345. service, run_id = _start_mock_run(tmp_path)
  346. final_output_path = service.runtime.run_dir(run_id) / "final_output.json"
  347. final_output = json.loads(final_output_path.read_text(encoding="utf-8"))
  348. final_output["decision_records"] = final_output["decision_records"][:-1]
  349. final_output_path.write_text(
  350. json.dumps(final_output, ensure_ascii=False, indent=2) + "\n",
  351. encoding="utf-8",
  352. )
  353. validation = service.validate_run(run_id)
  354. assert validation["status"] == "fail"
  355. assert any(finding["check_id"] == "final_decision_missing" for finding in validation["findings"])
  356. def test_runtime_validation_catches_platform_content_id_source_pollution(tmp_path):
  357. service, run_id = _start_mock_run(tmp_path)
  358. final_output_path = service.runtime.run_dir(run_id) / "final_output.json"
  359. final_output = json.loads(final_output_path.read_text(encoding="utf-8"))
  360. source_evidence = final_output["decision_records"][0]["source_evidence"]
  361. source_evidence["source_post_id"] = source_evidence["discovered_platform_content_id"]
  362. final_output_path.write_text(
  363. json.dumps(final_output, ensure_ascii=False, indent=2) + "\n",
  364. encoding="utf-8",
  365. )
  366. validation = service.validate_run(run_id)
  367. assert validation["status"] == "fail"
  368. assert any(
  369. finding["check_id"] == "source_evidence_content_pollution"
  370. for finding in validation["findings"]
  371. )
  372. def test_runtime_validation_catches_reject_source_path_break(tmp_path):
  373. service, run_id = _start_mock_run(tmp_path)
  374. paths_path = service.runtime.run_dir(run_id) / "source_path_records.jsonl"
  375. paths = [
  376. json.loads(line)
  377. for line in paths_path.read_text(encoding="utf-8").splitlines()
  378. if line.strip()
  379. ]
  380. paths = [
  381. path
  382. for path in paths
  383. if not (
  384. path.get("source_path_type") == "search_query_to_content"
  385. and path.get("to_node_id") == "7390000000000000099"
  386. )
  387. ]
  388. paths_path.write_text(
  389. "".join(json.dumps(path, ensure_ascii=False, separators=(",", ":")) + "\n" for path in paths),
  390. encoding="utf-8",
  391. )
  392. validation = service.validate_run(run_id)
  393. assert validation["status"] == "fail"
  394. assert any(finding["check_id"] == "source_path_broken" for finding in validation["findings"])
  395. def test_real_source_fixture_keeps_upstream_evidence_pack(tmp_path):
  396. source_path = Path("tests/fixtures/real_case_source/source_context.json")
  397. service, run_id = _start_mock_run(tmp_path, source=str(source_path))
  398. source_context = service.read_json(run_id, "source_context.json")
  399. evidence_pack = source_context["ext_data"]["evidence_pack"]
  400. decisions = service.read_jsonl(run_id, "rule_decisions.jsonl")
  401. assert evidence_pack["pattern_source_system"] == "pg_pattern_v2"
  402. assert evidence_pack["source_certainty"] == "db_validated"
  403. assert evidence_pack["validation_status"] == "passed"
  404. assert evidence_pack["source_post_id"] == "51978710"
  405. assert evidence_pack["pattern_execution_id"] == 581
  406. assert evidence_pack["mining_config_id"] == 2082
  407. assert evidence_pack["itemset_ids"] == [1608352]
  408. assert evidence_pack["upstream_run_id"] == "f405f129-3341-4f4a-98e6-fd3f73632adb"
  409. assert evidence_pack["support"] == 0.0045734552921411235
  410. assert evidence_pack["absolute_support"] == 49
  411. assert evidence_pack["decode_case_ids"] == []
  412. assert decisions[0]["source_evidence"]["source_certainty"] == "db_validated"
  413. assert (
  414. decisions[0]["source_evidence"]["discovered_platform_content_id"]
  415. not in evidence_pack["matched_post_ids"]
  416. )
  417. assert service.validate_run(run_id)["status"] == "pass"
  418. def test_demand_content_json_array_source_is_adapted_to_source_context(tmp_path):
  419. source_path = tmp_path / "demand_content.json"
  420. source_path.write_text(
  421. json.dumps(
  422. [
  423. {
  424. "id": 123,
  425. "merge_leve2": "PG Pattern V2 需求测试",
  426. "name": "爱国情感,人物故事",
  427. "suggestion": None,
  428. "score": 1.0,
  429. "dt": "20260604",
  430. "ext_data": {
  431. "evidence_pack": {
  432. "source_kind": "pattern_itemset",
  433. "pattern_source_system": "pg_pattern_v2",
  434. "case_id_type": "post_id",
  435. "source_post_id": "51978710",
  436. "pattern_execution_id": 581,
  437. "mining_config_id": 2082,
  438. "itemset_ids": [1608352],
  439. "itemset_items": [{"itemset_id": 1608352}],
  440. "category_bindings": [{"category_id": 76006}],
  441. "element_bindings": [{"category_id": 76006}],
  442. "support": 0.0045734552921411235,
  443. "absolute_support": 49,
  444. "matched_post_ids": ["51978710"],
  445. "video_ids": ["51978710"],
  446. "case_ids": ["51978710"],
  447. "decode_case_ids": [],
  448. "seed_terms": ["爱国情感", "人物故事"],
  449. "source_certainty": "db_validated",
  450. "validation_status": "passed",
  451. }
  452. },
  453. }
  454. ],
  455. ensure_ascii=False,
  456. ),
  457. encoding="utf-8",
  458. )
  459. service, run_id = _start_mock_run(tmp_path, source=str(source_path))
  460. source_context = service.read_json(run_id, "source_context.json")
  461. evidence_pack = source_context["ext_data"]["evidence_pack"]
  462. assert source_context["demand_content_id"] == "123"
  463. assert evidence_pack["pattern_source_system"] == "pg_pattern_v2"
  464. assert evidence_pack["pattern_execution_id"] == 581
  465. assert evidence_pack["mining_config_id"] == 2082
  466. assert evidence_pack["itemset_ids"] == [1608352]
  467. assert service.validate_run(run_id)["status"] == "pass"
  468. def test_old_mysql_source_system_is_rejected(tmp_path):
  469. source_path = tmp_path / "source_context.json"
  470. source_path.write_text(
  471. json.dumps(
  472. {
  473. "run_id": "old_run",
  474. "demand_content_id": "old",
  475. "merge_leve2": "历史样例",
  476. "name": "旧 MySQL 样例",
  477. "ext_data": {
  478. "evidence_pack": {
  479. "source_kind": "pattern_itemset",
  480. "pattern_source_system": "mysql_topic_pattern",
  481. "case_id_type": "post_id",
  482. "source_post_id": "51978710",
  483. "pattern_execution_id": 581,
  484. "mining_config_id": 2082,
  485. "itemset_ids": [1608352],
  486. "itemset_items": [{"itemset_id": 1608352}],
  487. "category_bindings": [{"category_id": 76006}],
  488. "support": 0.0045734552921411235,
  489. "absolute_support": 49,
  490. "matched_post_ids": ["51978710"],
  491. "video_ids": ["51978710"],
  492. "case_ids": ["51978710"],
  493. "decode_case_ids": [],
  494. "seed_terms": ["爱国情感", "人物故事"],
  495. "source_certainty": "db_validated",
  496. "validation_status": "passed",
  497. }
  498. },
  499. },
  500. ensure_ascii=False,
  501. ),
  502. encoding="utf-8",
  503. )
  504. service = RunService(runtime_root=tmp_path / "runtime" / "v1")
  505. state = service.start_run(RunStartRequest(platform_mode="mock", source=str(source_path)))
  506. assert state["status"] == "failed"
  507. assert state["error_code"] == ErrorCode.INVALID_SOURCE.value
  508. assert state["errors"] == ["invalid source"]