test_phase_two_workspace.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. from __future__ import annotations
  2. import asyncio
  3. from dataclasses import replace
  4. import pytest
  5. from sqlalchemy import func, select
  6. from script_build_host.domain.artifacts import ArtifactKind, ArtifactState
  7. from script_build_host.domain.phase_two_artifacts import CandidateLineageV1, ParagraphArtifactV1
  8. from script_build_host.domain.workspaces import CandidateWriteContext, WorkspaceError
  9. from script_build_host.infrastructure.legacy_tables import (
  10. script_build_element,
  11. script_build_paragraph,
  12. script_build_paragraph_element,
  13. script_build_task_plan_step,
  14. )
  15. from script_build_host.infrastructure.tables import artifact_version_table
  16. from script_build_host.repositories.sqlalchemy import (
  17. SqlAlchemyScriptBusinessArtifactRepository,
  18. )
  19. from script_build_host.repositories.workspace import (
  20. SqlAlchemyCandidateWorkspaceRepository,
  21. )
  22. def _context(attempt: str, *, task: str = "task-1", build: int = 1) -> CandidateWriteContext:
  23. return CandidateWriteContext(
  24. script_build_id=build,
  25. task_id=task,
  26. attempt_id=attempt,
  27. spec_version=1,
  28. objective="produce the opening increment",
  29. input_refs=("script-build://artifact-versions/90",),
  30. write_scope=("script-build://scopes/opening",),
  31. )
  32. def _lineage(
  33. *,
  34. base_ref: str | None = None,
  35. base_digest: str | None = None,
  36. base_revision: int | None = None,
  37. ) -> CandidateLineageV1:
  38. return CandidateLineageV1(
  39. scope_ref="script-build://scopes/opening",
  40. input_snapshot_ref="script-build://inputs/11",
  41. input_closure_digest="sha256:" + "a" * 64,
  42. write_scope=("script-build://scopes/opening",),
  43. base_artifact_ref=base_ref,
  44. base_artifact_digest=base_digest,
  45. base_revision=base_revision,
  46. )
  47. @pytest.mark.asyncio
  48. async def test_workspace_allocates_positive_branch_and_freezes_full_paragraph(database) -> None:
  49. _, sessions = database
  50. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  51. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  52. context = _context("attempt-paragraph")
  53. workspace = await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  54. assert workspace.branch_id == workspace.artifact_version_id > 0
  55. assert (
  56. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH) == workspace
  57. )
  58. parent = await repository.create_paragraph(
  59. context,
  60. paragraph_index=1,
  61. name="opening",
  62. content_range={"topics": [1, 2]},
  63. theme_elements=({"维度": "主题", "原子点": "冲突", "维度类型": "主维度"},),
  64. form_elements=({"维度": "形式", "原子点": "对照", "维度类型": "主维度"},),
  65. )
  66. child = await repository.create_paragraph(
  67. context,
  68. paragraph_index=2,
  69. name="opening detail",
  70. content_range={"topics": [1]},
  71. level=2,
  72. parent_paragraph_id=parent,
  73. )
  74. await repository.batch_update_paragraphs(
  75. context,
  76. [
  77. {
  78. "paragraph_id": parent,
  79. "theme": "a concrete conflict",
  80. "form": "contrast",
  81. "function": "hook",
  82. "feeling": "curiosity",
  83. "description": "bounded description",
  84. "full_description": "fully realized opening",
  85. }
  86. ],
  87. )
  88. added = await repository.append_paragraph_atoms(
  89. context,
  90. paragraph_id=child,
  91. column="feeling_elements",
  92. atoms=({"维度": "情绪", "原子点": "期待"},),
  93. )
  94. assert added == 1
  95. version, reference = await repository.freeze(context, lineage=_lineage())
  96. assert version.state is ArtifactState.FROZEN
  97. assert reference.kind == ArtifactKind.PARAGRAPH.value
  98. assert isinstance(version.artifact, ParagraphArtifactV1)
  99. assert len(version.artifact.paragraphs) == 2
  100. assert version.artifact.paragraphs[0].full_description == "fully realized opening"
  101. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  102. await repository.create_paragraph(
  103. context,
  104. paragraph_index=3,
  105. name="late mutation",
  106. content_range={"topics": [2]},
  107. )
  108. replayed, replay_ref = await repository.freeze(context, lineage=_lineage())
  109. assert (replayed.artifact_version_id, replay_ref) == (
  110. version.artifact_version_id,
  111. reference,
  112. )
  113. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  114. await repository.freeze(
  115. context,
  116. lineage=CandidateLineageV1(
  117. scope_ref="script-build://scopes/opening",
  118. input_snapshot_ref="script-build://inputs/12",
  119. input_closure_digest="sha256:" + "b" * 64,
  120. write_scope=("script-build://scopes/opening",),
  121. ),
  122. )
  123. async with sessions() as session:
  124. plan = (
  125. (
  126. await session.execute(
  127. select(script_build_task_plan_step).where(
  128. script_build_task_plan_step.c.script_build_id == 1,
  129. script_build_task_plan_step.c.branch_id == workspace.branch_id,
  130. )
  131. )
  132. )
  133. .mappings()
  134. .one()
  135. )
  136. assert plan["step_key"] == "1"
  137. assert plan["round_index"] is None
  138. assert plan["status"] == "完成"
  139. @pytest.mark.asyncio
  140. async def test_semantic_write_scope_is_enforced_before_atomic_batch(database) -> None:
  141. _, sessions = database
  142. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  143. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  144. full_context = _context("write-scope")
  145. await repository.get_or_create(full_context, artifact_kind=ArtifactKind.PARAGRAPH)
  146. first = await repository.create_paragraph(
  147. full_context, paragraph_index=1, name="one", content_range={"topics": [1]}
  148. )
  149. second = await repository.create_paragraph(
  150. full_context, paragraph_index=2, name="two", content_range={"topics": [2]}
  151. )
  152. bounded = replace(full_context, write_scope=(f"script-build://writes/paragraphs/{first}",))
  153. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  154. await repository.batch_update_paragraphs(
  155. bounded,
  156. (
  157. {"paragraph_id": first, "description": "authorized but must roll back"},
  158. {"paragraph_id": second, "description": "outside scope"},
  159. ),
  160. )
  161. snapshot = await repository.snapshot(full_context)
  162. assert [item.description for item in snapshot.paragraphs] == [None, None]
  163. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  164. await repository.create_element(
  165. bounded,
  166. name="not allowed",
  167. dimension_primary="实质",
  168. dimension_secondary="detail",
  169. )
  170. @pytest.mark.asyncio
  171. async def test_content_range_scope_must_cover_every_key_and_value(database) -> None:
  172. _, sessions = database
  173. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  174. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  175. context = replace(
  176. _context("content-range-scope"),
  177. write_scope=("script-build://writes/content-ranges/topics/1",),
  178. )
  179. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  180. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  181. await repository.create_paragraph(
  182. context,
  183. paragraph_index=1,
  184. name="partially authorized",
  185. content_range={"topics": [1, 2], "sections": ["ending"]},
  186. )
  187. snapshot = await repository.snapshot(context)
  188. assert snapshot.paragraphs == ()
  189. @pytest.mark.asyncio
  190. async def test_discard_marks_workspace_and_legacy_plan_blocked(database) -> None:
  191. _, sessions = database
  192. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  193. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  194. context = _context("discarded-attempt")
  195. workspace = await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  196. await repository.create_paragraph(
  197. context, paragraph_index=1, name="draft", content_range={"topics": [1]}
  198. )
  199. discarded = await repository.discard(context, reason="validator hard defect")
  200. assert discarded.state is ArtifactState.DISCARDED
  201. assert (
  202. await repository.discard(context, reason="idempotent replay")
  203. ).state is ArtifactState.DISCARDED
  204. async with sessions() as session:
  205. plan = (
  206. (
  207. await session.execute(
  208. select(script_build_task_plan_step).where(
  209. script_build_task_plan_step.c.script_build_id == context.script_build_id,
  210. script_build_task_plan_step.c.branch_id == workspace.branch_id,
  211. )
  212. )
  213. )
  214. .mappings()
  215. .one()
  216. )
  217. assert plan["status"] == "受阻"
  218. assert plan["result_note"] == "validator hard defect"
  219. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  220. await repository.create_paragraph(
  221. context, paragraph_index=2, name="late", content_range={"topics": [2]}
  222. )
  223. @pytest.mark.asyncio
  224. async def test_element_and_link_batch_is_atomic_and_workspace_scoped(database) -> None:
  225. _, sessions = database
  226. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  227. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  228. paragraph_context = _context("base-paragraph")
  229. await repository.get_or_create(paragraph_context, artifact_kind=ArtifactKind.PARAGRAPH)
  230. paragraph_id = await repository.create_paragraph(
  231. paragraph_context,
  232. paragraph_index=1,
  233. name="body",
  234. content_range={"topics": [1]},
  235. )
  236. base, base_ref = await repository.freeze(paragraph_context, lineage=_lineage())
  237. element_context = _context("element-attempt", task="element-task")
  238. workspace = await repository.get_or_create(
  239. element_context,
  240. artifact_kind=ArtifactKind.ELEMENT_SET,
  241. base_artifact_ref=base_ref,
  242. )
  243. snapshot = await repository.snapshot(element_context)
  244. copied_paragraph = snapshot.paragraphs[0].paragraph_id
  245. assert copied_paragraph != paragraph_id
  246. assert snapshot.source_identity_map == (
  247. {
  248. "entity": "paragraph",
  249. "source_local_id": paragraph_id,
  250. "local_id": copied_paragraph,
  251. },
  252. )
  253. element_id = await repository.create_element(
  254. element_context,
  255. name="specific observation",
  256. dimension_primary="实质",
  257. dimension_secondary="detail",
  258. commonality_analysis={"shared": "grounded"},
  259. topic_support={"topic": 1},
  260. weight_score={"score": 0.8},
  261. support_elements=({"ref": "source-1"},),
  262. )
  263. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  264. await repository.batch_link(
  265. element_context,
  266. [(copied_paragraph, [element_id]), (999999, [element_id])],
  267. )
  268. async with sessions() as session:
  269. assert (
  270. await session.scalar(select(func.count()).select_from(script_build_paragraph_element))
  271. ) == 0
  272. assert await repository.batch_link(element_context, [(copied_paragraph, [element_id])]) == 1
  273. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  274. await repository.freeze(
  275. element_context,
  276. lineage=_lineage(
  277. base_ref=base_ref.uri,
  278. base_digest="sha256:" + "f" * 64,
  279. base_revision=base.artifact_version_id,
  280. ),
  281. )
  282. version, _ = await repository.freeze(
  283. element_context,
  284. lineage=_lineage(
  285. base_ref=base_ref.uri,
  286. base_digest=base_ref.digest,
  287. base_revision=base.artifact_version_id,
  288. ),
  289. )
  290. assert version.artifact.lineage.source_lineage[0]["source_local_id"] == paragraph_id
  291. assert workspace.branch_id == version.artifact_version_id
  292. @pytest.mark.asyncio
  293. async def test_wrong_adapter_cross_context_and_stale_base_are_rejected(database) -> None:
  294. _, sessions = database
  295. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  296. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  297. context = _context("element-only")
  298. await repository.get_or_create(context, artifact_kind=ArtifactKind.ELEMENT_SET)
  299. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  300. await repository.create_paragraph(
  301. context,
  302. paragraph_index=1,
  303. name="forbidden",
  304. content_range={"topics": [1]},
  305. )
  306. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  307. await repository.require(_context("element-only", task="other-task"))
  308. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  309. await repository.freeze(
  310. context,
  311. lineage=CandidateLineageV1(
  312. scope_ref="script-build://scopes/opening",
  313. input_snapshot_ref="script-build://inputs/11",
  314. input_closure_digest="sha256:" + "a" * 64,
  315. write_scope=("script-build://scopes/opening",),
  316. base_artifact_ref="script-build://artifact-versions/999",
  317. base_artifact_digest="sha256:" + "b" * 64,
  318. base_revision=999,
  319. ),
  320. )
  321. async with sessions() as session:
  322. counts = {
  323. "paragraph": await session.scalar(
  324. select(func.count()).select_from(script_build_paragraph)
  325. ),
  326. "element": await session.scalar(select(func.count()).select_from(script_build_element)),
  327. }
  328. assert counts == {"paragraph": 0, "element": 0}
  329. @pytest.mark.asyncio
  330. async def test_concurrent_workspace_replay_converges_on_one_attempt_and_branch(database) -> None:
  331. _, sessions = database
  332. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  333. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  334. context = _context("attempt-concurrent")
  335. first, second = await asyncio.gather(
  336. repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH),
  337. repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH),
  338. )
  339. assert first == second
  340. assert first.branch_id == first.artifact_version_id > 0
  341. async with sessions() as session:
  342. rows = (
  343. (
  344. await session.execute(
  345. select(artifact_version_table).where(
  346. artifact_version_table.c.attempt_id == context.attempt_id
  347. )
  348. )
  349. )
  350. .mappings()
  351. .all()
  352. )
  353. assert len(rows) == 1
  354. assert rows[0]["legacy_branch_id"] == rows[0]["id"]
  355. @pytest.mark.asyncio
  356. async def test_batch_update_validates_every_paragraph_before_mutating(database) -> None:
  357. _, sessions = database
  358. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  359. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  360. context = _context("attempt-atomic-update")
  361. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  362. first_id = await repository.create_paragraph(
  363. context,
  364. paragraph_index=1,
  365. name="original",
  366. content_range={"topics": [1]},
  367. )
  368. second_id = await repository.create_paragraph(
  369. context,
  370. paragraph_index=2,
  371. name="second",
  372. content_range={"topics": [2]},
  373. )
  374. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  375. await repository.batch_update_paragraphs(
  376. context,
  377. [
  378. {"paragraph_id": first_id, "name": "must roll back"},
  379. {"paragraph_id": 999_999, "name": "outside workspace"},
  380. ],
  381. )
  382. snapshot = await repository.snapshot(context)
  383. assert snapshot.paragraphs[0].name == "original"
  384. with pytest.raises(WorkspaceError, match="paragraph name"):
  385. await repository.batch_update_paragraphs(
  386. context,
  387. [
  388. {"paragraph_id": first_id, "description": "must still roll back"},
  389. {"paragraph_id": second_id, "name": ""},
  390. ],
  391. )
  392. snapshot = await repository.snapshot(context)
  393. assert snapshot.paragraphs[0].description is None
  394. assert snapshot.paragraphs[1].name == "second"
  395. @pytest.mark.asyncio
  396. async def test_element_patch_copies_complete_projection_and_freezes_change_manifest(
  397. database,
  398. ) -> None:
  399. _, sessions = database
  400. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  401. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  402. paragraph_context = _context("paragraph-base")
  403. await repository.get_or_create(paragraph_context, artifact_kind=ArtifactKind.PARAGRAPH)
  404. source_paragraph_id = await repository.create_paragraph(
  405. paragraph_context,
  406. paragraph_index=1,
  407. name="body",
  408. content_range={"topics": [1]},
  409. )
  410. paragraph_version, paragraph_ref = await repository.freeze(
  411. paragraph_context, lineage=_lineage()
  412. )
  413. element_context = _context("element-base", task="element-base-task")
  414. await repository.get_or_create(
  415. element_context,
  416. artifact_kind=ArtifactKind.ELEMENT_SET,
  417. base_artifact_ref=paragraph_ref,
  418. )
  419. copied_paragraph_id = (await repository.snapshot(element_context)).paragraphs[0].paragraph_id
  420. source_element_id = await repository.create_element(
  421. element_context,
  422. name="old detail",
  423. dimension_primary="实质",
  424. dimension_secondary="detail",
  425. )
  426. await repository.batch_link(element_context, [(copied_paragraph_id, [source_element_id])])
  427. element_version, element_ref = await repository.freeze(
  428. element_context,
  429. lineage=_lineage(
  430. base_ref=paragraph_ref.uri,
  431. base_digest=paragraph_ref.digest,
  432. base_revision=paragraph_version.artifact_version_id,
  433. ),
  434. )
  435. assert len(element_version.artifact.paragraphs) == 1
  436. assert len(element_version.artifact.paragraph_element_links) == 1
  437. replacement_context = _context("element-replacement", task="element-replacement-task")
  438. replacement = await repository.get_or_create(
  439. replacement_context,
  440. artifact_kind=ArtifactKind.ELEMENT_SET,
  441. base_artifact_ref=element_ref,
  442. )
  443. copied = await repository.snapshot(replacement_context)
  444. replacement_paragraph_id = copied.paragraphs[0].paragraph_id
  445. replacement_element_id = copied.elements[0].element_id
  446. assert replacement_paragraph_id not in {source_paragraph_id, copied_paragraph_id}
  447. assert replacement_element_id != source_element_id
  448. assert copied.links[0].paragraph_id == replacement_paragraph_id
  449. assert copied.links[0].element_id == replacement_element_id
  450. await repository.update_element(
  451. replacement_context,
  452. element_id=replacement_element_id,
  453. values={"name": "replaced detail", "is_active": False},
  454. )
  455. new_element_id = await repository.create_element(
  456. replacement_context,
  457. name="new detail",
  458. dimension_primary="实质",
  459. dimension_secondary="detail",
  460. )
  461. await repository.batch_link(replacement_context, [(replacement_paragraph_id, [new_element_id])])
  462. replacement_version, _ = await repository.freeze(
  463. replacement_context,
  464. lineage=_lineage(
  465. base_ref=element_ref.uri,
  466. base_digest=element_ref.digest,
  467. base_revision=element_version.artifact_version_id,
  468. ),
  469. )
  470. manifest = replacement_version.artifact.change_manifest
  471. assert manifest["created"]["element_ids"] == [new_element_id]
  472. assert manifest["created"]["links"] == [
  473. {"paragraph_id": replacement_paragraph_id, "element_id": new_element_id}
  474. ]
  475. assert manifest["updated"]["element_ids"] == [replacement_element_id]
  476. assert manifest["deactivated"]["element_ids"] == [replacement_element_id]
  477. assert manifest["deleted_links"] == [
  478. {"paragraph_id": copied_paragraph_id, "element_id": source_element_id}
  479. ]
  480. assert replacement.branch_id == replacement_version.artifact_version_id > 0
  481. for operation in (
  482. repository.create_element(
  483. replacement_context,
  484. name="late",
  485. dimension_primary="实质",
  486. dimension_secondary="detail",
  487. ),
  488. repository.update_element(
  489. replacement_context,
  490. element_id=new_element_id,
  491. values={"name": "late"},
  492. ),
  493. repository.batch_link(replacement_context, [(replacement_paragraph_id, [new_element_id])]),
  494. repository.delete_links(
  495. replacement_context,
  496. pairs=((replacement_paragraph_id, new_element_id),),
  497. ),
  498. ):
  499. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  500. await operation
  501. @pytest.mark.asyncio
  502. async def test_run_454_shape_roundtrips_without_branch_zero(database) -> None:
  503. """The legacy 5/11/13 detail shape remains expressible without old Branch/Round rows."""
  504. _, sessions = database
  505. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  506. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  507. context = _context("run-454-shape", task="compose-454")
  508. workspace = await repository.get_or_create(
  509. context, artifact_kind=ArtifactKind.STRUCTURED_SCRIPT
  510. )
  511. paragraph_ids = [
  512. await repository.create_paragraph(
  513. context,
  514. paragraph_index=index,
  515. name=f"paragraph-{index}",
  516. content_range={"topics": [index]},
  517. )
  518. for index in range(1, 6)
  519. ]
  520. element_ids = [
  521. await repository.create_element(
  522. context,
  523. name=f"element-{index}",
  524. dimension_primary="实质" if index <= 6 else "形式",
  525. dimension_secondary=f"dimension-{index}",
  526. )
  527. for index in range(1, 12)
  528. ]
  529. link_pairs = [(paragraph_ids[index % 5], [element_ids[index % 11]]) for index in range(11)]
  530. link_pairs.extend(
  531. [
  532. (paragraph_ids[0], [element_ids[1]]),
  533. (paragraph_ids[1], [element_ids[2]]),
  534. ]
  535. )
  536. assert await repository.batch_link(context, link_pairs) == 13
  537. version, reference = await repository.freeze(
  538. context,
  539. lineage=_lineage(),
  540. structured_script={
  541. "direction_ref": "script-build://artifact-versions/999",
  542. "source_artifact_refs": ["script-build://artifact-versions/888"],
  543. "evidence_refs": ["script-build://artifact-versions/777"],
  544. "acceptance_notes": ["legacy detail shape preserved"],
  545. },
  546. )
  547. loaded = await artifacts.read_by_ref(reference, script_build_id=1)
  548. assert loaded == version
  549. assert len(loaded.artifact.paragraphs) == 5
  550. assert len(loaded.artifact.elements) == 11
  551. assert len(loaded.artifact.paragraph_element_links) == 13
  552. assert workspace.branch_id == version.artifact_version_id > 0
  553. async with sessions() as session:
  554. paragraph_branches = set(
  555. (
  556. await session.execute(
  557. select(script_build_paragraph.c.branch_id).where(
  558. script_build_paragraph.c.script_build_id == 1
  559. )
  560. )
  561. ).scalars()
  562. )
  563. element_branches = set(
  564. (
  565. await session.execute(
  566. select(script_build_element.c.branch_id).where(
  567. script_build_element.c.script_build_id == 1
  568. )
  569. )
  570. ).scalars()
  571. )
  572. link_branches = set(
  573. (
  574. await session.execute(
  575. select(script_build_paragraph_element.c.branch_id).where(
  576. script_build_paragraph_element.c.script_build_id == 1
  577. )
  578. )
  579. ).scalars()
  580. )
  581. assert paragraph_branches == element_branches == link_branches == {workspace.branch_id}