okstra 0.174.0 → 0.176.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 +18 -9
- package/bin/okstra +8 -4
- package/dist/cli-registry.d.mts +16 -0
- package/dist/cli-registry.mjs +560 -0
- package/dist/cli-registry.mjs.map +1 -0
- package/dist/commands/execute/agent-prompt.d.mts +1 -0
- package/{src → dist}/commands/execute/agent-prompt.mjs +10 -11
- package/dist/commands/execute/agent-prompt.mjs.map +1 -0
- package/dist/commands/execute/codex-dispatch.d.mts +3 -0
- package/dist/commands/execute/codex-dispatch.mjs +6 -0
- package/dist/commands/execute/codex-dispatch.mjs.map +1 -0
- package/dist/commands/execute/codex-run.d.mts +3 -0
- package/{src → dist}/commands/execute/codex-run.mjs +30 -37
- package/dist/commands/execute/codex-run.mjs.map +1 -0
- package/dist/commands/execute/convergence.d.mts +1 -0
- package/{src → dist}/commands/execute/convergence.mjs +15 -16
- package/dist/commands/execute/convergence.mjs.map +1 -0
- package/dist/commands/execute/error-log.d.mts +1 -0
- package/{src → dist}/commands/execute/error-log.mjs +2 -3
- package/dist/commands/execute/error-log.mjs.map +1 -0
- package/dist/commands/execute/git-reconcile.d.mts +1 -0
- package/{src → dist}/commands/execute/git-reconcile.mjs +10 -11
- package/dist/commands/execute/git-reconcile.mjs.map +1 -0
- package/dist/commands/execute/handoff.d.mts +1 -0
- package/{src → dist}/commands/execute/handoff.mjs +10 -11
- package/dist/commands/execute/handoff.mjs.map +1 -0
- package/dist/commands/execute/incremental-carry.d.mts +1 -0
- package/{src → dist}/commands/execute/incremental-carry.mjs +2 -3
- package/dist/commands/execute/incremental-carry.mjs.map +1 -0
- package/dist/commands/execute/incremental-scope.d.mts +1 -0
- package/{src → dist}/commands/execute/incremental-scope.mjs +2 -3
- package/dist/commands/execute/incremental-scope.mjs.map +1 -0
- package/dist/commands/execute/integrate-stages.d.mts +1 -0
- package/{src → dist}/commands/execute/integrate-stages.mjs +10 -11
- package/dist/commands/execute/integrate-stages.mjs.map +1 -0
- package/dist/commands/execute/pane-title.d.mts +1 -0
- package/dist/commands/execute/pane-title.mjs +20 -0
- package/dist/commands/execute/pane-title.mjs.map +1 -0
- package/dist/commands/execute/plan-items.d.mts +1 -0
- package/dist/commands/execute/plan-items.mjs +9 -0
- package/dist/commands/execute/plan-items.mjs.map +1 -0
- package/dist/commands/execute/plan-validate.d.mts +1 -0
- package/dist/commands/execute/plan-validate.mjs +68 -0
- package/dist/commands/execute/plan-validate.mjs.map +1 -0
- package/dist/commands/execute/plan-verify.d.mts +1 -0
- package/{src → dist}/commands/execute/plan-verify.mjs +13 -16
- package/dist/commands/execute/plan-verify.mjs.map +1 -0
- package/dist/commands/execute/render-bundle.d.mts +3 -0
- package/{src → dist}/commands/execute/render-bundle.mjs +65 -61
- package/dist/commands/execute/render-bundle.mjs.map +1 -0
- package/dist/commands/execute/run.d.mts +24 -0
- package/dist/commands/execute/run.mjs +196 -0
- package/dist/commands/execute/run.mjs.map +1 -0
- package/dist/commands/execute/spawn-followups.d.mts +1 -0
- package/{src → dist}/commands/execute/spawn-followups.mjs +6 -7
- package/dist/commands/execute/spawn-followups.mjs.map +1 -0
- package/dist/commands/execute/team.d.mts +3 -0
- package/{src → dist}/commands/execute/team.mjs +29 -36
- package/dist/commands/execute/team.mjs.map +1 -0
- package/dist/commands/execute/token-usage.d.mts +1 -0
- package/{src → dist}/commands/execute/token-usage.mjs +2 -3
- package/dist/commands/execute/token-usage.mjs.map +1 -0
- package/dist/commands/execute/wizard.d.mts +8 -0
- package/dist/commands/execute/wizard.mjs +191 -0
- package/dist/commands/execute/wizard.mjs.map +1 -0
- package/dist/commands/execute/worker-audit-check.d.mts +1 -0
- package/{src → dist}/commands/execute/worker-audit-check.mjs +11 -12
- package/dist/commands/execute/worker-audit-check.mjs.map +1 -0
- package/dist/commands/execute/worker-dispatch.d.mts +7 -0
- package/{src → dist}/commands/execute/worker-dispatch.mjs +34 -46
- package/dist/commands/execute/worker-dispatch.mjs.map +1 -0
- package/dist/commands/execute/worker-state.d.mts +1 -0
- package/{src → dist}/commands/execute/worker-state.mjs +15 -16
- package/dist/commands/execute/worker-state.mjs.map +1 -0
- package/dist/commands/execute/worktree-lookup.d.mts +1 -0
- package/{src → dist}/commands/execute/worktree-lookup.mjs +38 -37
- package/dist/commands/execute/worktree-lookup.mjs.map +1 -0
- package/dist/commands/execute/worktree-status.d.mts +1 -0
- package/{src → dist}/commands/execute/worktree-status.mjs +52 -46
- package/dist/commands/execute/worktree-status.mjs.map +1 -0
- package/dist/commands/inspect/code-review.d.mts +1 -0
- package/{src → dist}/commands/inspect/code-review.mjs +16 -19
- package/dist/commands/inspect/code-review.mjs.map +1 -0
- package/dist/commands/inspect/container.d.mts +1 -0
- package/{src → dist}/commands/inspect/container.mjs +11 -13
- package/dist/commands/inspect/container.mjs.map +1 -0
- package/dist/commands/inspect/context-cost.d.mts +1 -0
- package/{src → dist}/commands/inspect/context-cost.mjs +11 -13
- package/dist/commands/inspect/context-cost.mjs.map +1 -0
- package/dist/commands/inspect/design-prep.d.mts +1 -0
- package/{src → dist}/commands/inspect/design-prep.mjs +10 -11
- package/dist/commands/inspect/design-prep.mjs.map +1 -0
- package/dist/commands/inspect/error-report.d.mts +1 -0
- package/{src → dist}/commands/inspect/error-report.mjs +11 -13
- package/dist/commands/inspect/error-report.mjs.map +1 -0
- package/dist/commands/inspect/error-zip.d.mts +1 -0
- package/{src → dist}/commands/inspect/error-zip.mjs +11 -12
- package/dist/commands/inspect/error-zip.mjs.map +1 -0
- package/dist/commands/inspect/log-report.d.mts +1 -0
- package/{src → dist}/commands/inspect/log-report.mjs +11 -13
- package/dist/commands/inspect/log-report.mjs.map +1 -0
- package/dist/commands/inspect/profile-show.d.mts +1 -0
- package/{src → dist}/commands/inspect/profile-show.mjs +10 -11
- package/dist/commands/inspect/profile-show.mjs.map +1 -0
- package/dist/commands/inspect/recap.d.mts +1 -0
- package/{src → dist}/commands/inspect/recap.mjs +11 -13
- package/dist/commands/inspect/recap.mjs.map +1 -0
- package/dist/commands/inspect/resolve-task-key.d.mts +1 -0
- package/{src → dist}/commands/inspect/resolve-task-key.mjs +11 -13
- package/dist/commands/inspect/resolve-task-key.mjs.map +1 -0
- package/dist/commands/inspect/rollup.d.mts +1 -0
- package/{src → dist}/commands/inspect/rollup.mjs +11 -13
- package/dist/commands/inspect/rollup.mjs.map +1 -0
- package/dist/commands/inspect/run-audit.d.mts +1 -0
- package/{src → dist}/commands/inspect/run-audit.mjs +11 -12
- package/dist/commands/inspect/run-audit.mjs.map +1 -0
- package/dist/commands/inspect/set-work-status.d.mts +1 -0
- package/{src → dist}/commands/inspect/set-work-status.mjs +11 -13
- package/dist/commands/inspect/set-work-status.mjs.map +1 -0
- package/dist/commands/inspect/stage-map.d.mts +1 -0
- package/dist/commands/inspect/stage-map.mjs +110 -0
- package/dist/commands/inspect/stage-map.mjs.map +1 -0
- package/dist/commands/inspect/task-list.d.mts +1 -0
- package/dist/commands/inspect/task-list.mjs +103 -0
- package/dist/commands/inspect/task-list.mjs.map +1 -0
- package/dist/commands/inspect/task-show.d.mts +1 -0
- package/dist/commands/inspect/task-show.mjs +108 -0
- package/dist/commands/inspect/task-show.mjs.map +1 -0
- package/dist/commands/inspect/time-report.d.mts +1 -0
- package/{src → dist}/commands/inspect/time-report.mjs +11 -13
- package/dist/commands/inspect/time-report.mjs.map +1 -0
- package/dist/commands/inspect/usage-report.d.mts +1 -0
- package/{src → dist}/commands/inspect/usage-report.mjs +11 -13
- package/dist/commands/inspect/usage-report.mjs.map +1 -0
- package/dist/commands/inspect/user-response.d.mts +1 -0
- package/{src → dist}/commands/inspect/user-response.mjs +10 -11
- package/dist/commands/inspect/user-response.mjs.map +1 -0
- package/dist/commands/inspect/worker-liveness.d.mts +1 -0
- package/{src → dist}/commands/inspect/worker-liveness.mjs +11 -13
- package/dist/commands/inspect/worker-liveness.mjs.map +1 -0
- package/dist/commands/lifecycle/check-project.d.mts +21 -0
- package/dist/commands/lifecycle/check-project.mjs +175 -0
- package/dist/commands/lifecycle/check-project.mjs.map +1 -0
- package/dist/commands/lifecycle/config.d.mts +1 -0
- package/dist/commands/lifecycle/config.mjs +369 -0
- package/dist/commands/lifecycle/config.mjs.map +1 -0
- package/dist/commands/lifecycle/doctor.d.mts +10 -0
- package/dist/commands/lifecycle/doctor.mjs +317 -0
- package/dist/commands/lifecycle/doctor.mjs.map +1 -0
- package/dist/commands/lifecycle/install.d.mts +17 -0
- package/dist/commands/lifecycle/install.mjs +1077 -0
- package/dist/commands/lifecycle/install.mjs.map +1 -0
- package/dist/commands/lifecycle/migrate.d.mts +1 -0
- package/{src → dist}/commands/lifecycle/migrate.mjs +10 -11
- package/dist/commands/lifecycle/migrate.mjs.map +1 -0
- package/dist/commands/lifecycle/model.d.mts +1 -0
- package/dist/commands/lifecycle/model.mjs +22 -0
- package/dist/commands/lifecycle/model.mjs.map +1 -0
- package/dist/commands/lifecycle/paths.d.mts +1 -0
- package/dist/commands/lifecycle/paths.mjs +102 -0
- package/dist/commands/lifecycle/paths.mjs.map +1 -0
- package/dist/commands/lifecycle/preflight.d.mts +1 -0
- package/dist/commands/lifecycle/preflight.mjs +124 -0
- package/dist/commands/lifecycle/preflight.mjs.map +1 -0
- package/dist/commands/lifecycle/setup.d.mts +1 -0
- package/dist/commands/lifecycle/setup.mjs +297 -0
- package/dist/commands/lifecycle/setup.mjs.map +1 -0
- package/dist/commands/lifecycle/uninstall.d.mts +4 -0
- package/dist/commands/lifecycle/uninstall.mjs +262 -0
- package/dist/commands/lifecycle/uninstall.mjs.map +1 -0
- package/dist/commands/manager.d.mts +3 -0
- package/{src → dist}/commands/manager.mjs +25 -32
- package/dist/commands/manager.mjs.map +1 -0
- package/dist/commands/memory/memory.d.mts +1 -0
- package/dist/commands/memory/memory.mjs +569 -0
- package/dist/commands/memory/memory.mjs.map +1 -0
- package/dist/commands/pr/pr.d.mts +33 -0
- package/dist/commands/pr/pr.mjs +296 -0
- package/dist/commands/pr/pr.mjs.map +1 -0
- package/dist/commands/report/agent-activity.d.mts +1 -0
- package/{src → dist}/commands/report/agent-activity.mjs +11 -12
- package/dist/commands/report/agent-activity.mjs.map +1 -0
- package/dist/commands/report/finalize.d.mts +4 -0
- package/{src → dist}/commands/report/finalize.mjs +25 -32
- package/dist/commands/report/finalize.mjs.map +1 -0
- package/dist/commands/report/inject-report-index.d.mts +1 -0
- package/{src → dist}/commands/report/inject-report-index.mjs +6 -7
- package/dist/commands/report/inject-report-index.mjs.map +1 -0
- package/dist/commands/report/render-final-report.d.mts +1 -0
- package/{src → dist}/commands/report/render-final-report.mjs +6 -7
- package/dist/commands/report/render-final-report.mjs.map +1 -0
- package/dist/commands/report/render-views.d.mts +1 -0
- package/{src → dist}/commands/report/render-views.mjs +6 -7
- package/dist/commands/report/render-views.mjs.map +1 -0
- package/dist/commands/report/translate.d.mts +1 -0
- package/{src → dist}/commands/report/translate.mjs +6 -7
- package/dist/commands/report/translate.mjs.map +1 -0
- package/dist/lib/helper-scripts.d.mts +3 -0
- package/{src → dist}/lib/helper-scripts.mjs +11 -14
- package/dist/lib/helper-scripts.mjs.map +1 -0
- package/dist/lib/host-registry-client.d.mts +25 -0
- package/dist/lib/host-registry-client.mjs +152 -0
- package/dist/lib/host-registry-client.mjs.map +1 -0
- package/dist/lib/install-assets.d.mts +8 -0
- package/{src → dist}/lib/install-assets.mjs +17 -20
- package/dist/lib/install-assets.mjs.map +1 -0
- package/dist/lib/okstra-dirs.d.mts +4 -0
- package/{src → dist}/lib/okstra-dirs.mjs +3 -6
- package/dist/lib/okstra-dirs.mjs.map +1 -0
- package/dist/lib/paths.d.mts +4 -0
- package/dist/lib/paths.mjs +78 -0
- package/dist/lib/paths.mjs.map +1 -0
- package/dist/lib/proc.d.mts +3 -0
- package/dist/lib/proc.mjs +31 -0
- package/dist/lib/proc.mjs.map +1 -0
- package/dist/lib/python-helper.d.mts +8 -0
- package/dist/lib/python-helper.mjs +123 -0
- package/dist/lib/python-helper.mjs.map +1 -0
- package/dist/lib/runtime-manifest.d.mts +7 -0
- package/dist/lib/runtime-manifest.mjs +73 -0
- package/dist/lib/runtime-manifest.mjs.map +1 -0
- package/dist/lib/skill-catalog.d.mts +4 -0
- package/dist/lib/skill-catalog.mjs +50 -0
- package/dist/lib/skill-catalog.mjs.map +1 -0
- package/dist/lib/types.d.mts +129 -0
- package/dist/lib/types.mjs +2 -0
- package/dist/lib/types.mjs.map +1 -0
- package/dist/lib/version.d.mts +2 -0
- package/dist/lib/version.mjs +27 -0
- package/dist/lib/version.mjs.map +1 -0
- package/docs/architecture/storage-model.md +6 -2
- package/docs/architecture.md +29 -20
- package/docs/cli.md +40 -16
- package/docs/contributor-change-matrix.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +2 -3
- package/docs/for-ai/skills/okstra-rollup.md +1 -0
- package/docs/project-structure-overview.md +72 -62
- package/docs/task-process/README.md +12 -8
- package/docs/task-process/common-flow.md +13 -16
- package/docs/task-process/error-analysis.md +9 -10
- package/docs/task-process/final-verification.md +7 -7
- package/docs/task-process/implementation-planning.md +9 -9
- package/docs/task-process/implementation.md +6 -6
- package/docs/task-process/release-handoff.md +8 -7
- package/docs/task-process/requirements-discovery.md +8 -8
- package/package.json +11 -5
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +2 -1
- package/runtime/agents/workers/report-writer-worker.md +3 -1
- package/runtime/agents/workers/translator-worker.md +3 -1
- package/runtime/bin/lib/okstra/cli.sh +12 -0
- package/runtime/bin/lib/okstra/globals.sh +3 -0
- package/runtime/bin/lib/okstra/interactive.sh +12 -6
- package/runtime/bin/lib/okstra/usage.sh +7 -6
- package/runtime/bin/okstra-error-log.py +27 -0
- package/runtime/bin/okstra-provider-exec.py +217 -5
- package/runtime/bin/okstra-spawn-followups.py +4 -2
- package/runtime/bin/okstra.sh +10 -0
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/lead/context-loader.md +1 -2
- package/runtime/prompts/lead/convergence.md +27 -18
- package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
- package/runtime/prompts/lead/plan-body-verification.md +10 -3
- package/runtime/prompts/lead/report-writer.md +15 -1
- package/runtime/prompts/lead/team-contract.md +17 -13
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +0 -3
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -7
- package/runtime/prompts/profiles/change-impact-analysis.md +20 -0
- package/runtime/prompts/profiles/error-analysis.md +27 -1
- package/runtime/prompts/profiles/feature-analysis.md +20 -0
- package/runtime/prompts/profiles/final-verification.md +20 -1
- package/runtime/prompts/profiles/implementation-option-selection.md +20 -0
- package/runtime/prompts/profiles/implementation-planning.md +26 -0
- package/runtime/prompts/profiles/implementation.md +19 -0
- package/runtime/prompts/profiles/improvement-discovery.md +21 -1
- package/runtime/prompts/profiles/project-analysis.md +20 -0
- package/runtime/prompts/profiles/release-handoff.md +4 -0
- package/runtime/prompts/profiles/requirements-discovery.md +27 -1
- package/runtime/prompts/wizard/prompts.ko.json +35 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +8 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +8 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +25 -5
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +4 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +79 -11
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +95 -25
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +59 -14
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +55 -9
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +51 -12
- package/runtime/python/okstra_ctl/adapters/runtime/__init__.py +1 -0
- package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +27 -0
- package/runtime/python/okstra_ctl/adapters/runtime/cli_wrapper.py +49 -0
- package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +74 -0
- package/runtime/python/okstra_ctl/agent_activity.py +5 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +608 -14
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +249 -50
- package/runtime/python/okstra_ctl/application/open_worker.py +29 -0
- package/runtime/python/okstra_ctl/application/resolve_assignment.py +53 -12
- package/runtime/python/okstra_ctl/assignment_environment.py +102 -0
- package/runtime/python/okstra_ctl/assignment_resolver.py +790 -0
- package/runtime/python/okstra_ctl/attempt_evidence.py +342 -0
- package/runtime/python/okstra_ctl/convergence.py +409 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +248 -15
- package/runtime/python/okstra_ctl/convergence_store.py +170 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +1204 -155
- package/runtime/python/okstra_ctl/dispatch_state.py +696 -11
- package/runtime/python/okstra_ctl/doctor.py +14 -0
- package/runtime/python/okstra_ctl/domain/host.py +34 -1
- package/runtime/python/okstra_ctl/domain/provider.py +212 -6
- package/runtime/python/okstra_ctl/domain/role.py +125 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +18 -0
- package/runtime/python/okstra_ctl/domain/worker_exec.py +34 -1
- package/runtime/python/okstra_ctl/domain/worker_role.py +13 -3
- package/runtime/python/okstra_ctl/domain/worker_runtime.py +44 -0
- package/runtime/python/okstra_ctl/domain/worker_stream.py +24 -0
- package/runtime/python/okstra_ctl/error_log_write.py +9 -0
- package/runtime/python/okstra_ctl/error_report.py +21 -1
- package/runtime/python/okstra_ctl/execution_identity.py +529 -0
- package/runtime/python/okstra_ctl/execution_manifest.py +1281 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +619 -0
- package/runtime/python/okstra_ctl/implementation_outcome.py +21 -5
- package/runtime/python/okstra_ctl/index.py +6 -2
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +43 -3
- package/runtime/python/okstra_ctl/legacy_model_selection.py +578 -0
- package/runtime/python/okstra_ctl/manager_sync.py +4 -1
- package/runtime/python/okstra_ctl/model_cli.py +278 -0
- package/runtime/python/okstra_ctl/model_defaults.py +46 -0
- package/runtime/python/okstra_ctl/model_discovery.py +26 -7
- package/runtime/python/okstra_ctl/model_pool.py +501 -0
- package/runtime/python/okstra_ctl/models.py +47 -27
- package/runtime/python/okstra_ctl/mutation_recovery.py +293 -0
- package/runtime/python/okstra_ctl/next_phase.py +236 -0
- package/runtime/python/okstra_ctl/pane_title.py +151 -0
- package/runtime/python/okstra_ctl/ports/host_model.py +48 -17
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
- package/runtime/python/okstra_ctl/ports/worker_runtime.py +26 -0
- package/runtime/python/okstra_ctl/recap.py +4 -1
- package/runtime/python/okstra_ctl/registry/provider_registry.py +8 -4
- package/runtime/python/okstra_ctl/render.py +124 -57
- package/runtime/python/okstra_ctl/render_final_report.py +19 -0
- package/runtime/python/okstra_ctl/report_contract.py +39 -0
- package/runtime/python/okstra_ctl/report_finalize.py +16 -0
- package/runtime/python/okstra_ctl/report_html/render.py +1 -0
- package/runtime/python/okstra_ctl/role_requirements.py +210 -0
- package/runtime/python/okstra_ctl/rollup.py +4 -1
- package/runtime/python/okstra_ctl/run.py +1210 -258
- package/runtime/python/okstra_ctl/run_index_row.py +7 -1
- package/runtime/python/okstra_ctl/stage_fix_carry.py +17 -1
- package/runtime/python/okstra_ctl/team.py +14 -18
- package/runtime/python/okstra_ctl/time_report.py +38 -4
- package/runtime/python/okstra_ctl/usage_identity.py +86 -0
- package/runtime/python/okstra_ctl/usage_report.py +15 -3
- package/runtime/python/okstra_ctl/wizard.py +1449 -66
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +68 -65
- package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -2
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +40 -9
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +16 -1
- package/runtime/python/okstra_ctl/worker_request.py +7 -1
- package/runtime/python/okstra_ctl/worker_runner.py +130 -5
- package/runtime/python/okstra_ctl/workers.py +7 -5
- package/runtime/python/okstra_ctl/workflow.py +18 -32
- package/runtime/python/okstra_ctl/worktree.py +3 -3
- package/runtime/python/okstra_ctl/worktree_registry.py +5 -4
- package/runtime/python/okstra_ctl/write_policy.py +571 -0
- package/runtime/python/okstra_project/state.py +54 -6
- package/runtime/schemas/convergence-groups-v2.0.schema.json +128 -0
- package/runtime/schemas/execution-manifest-v2.schema.json +340 -0
- package/runtime/schemas/final-report-v2.0.schema.json +68 -5
- package/runtime/skills/okstra-inspect/facets/recap.md +2 -0
- package/runtime/skills/okstra-inspect/facets/report.md +1 -1
- package/runtime/skills/okstra-inspect/facets/status.md +15 -13
- package/runtime/skills/okstra-rollup/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +14 -14
- package/runtime/templates/implementation-worker-preamble.md +3 -18
- package/runtime/templates/project-docs/task-index.template.md +0 -1
- package/runtime/templates/report-writer-prompt-preamble.md +2 -0
- package/runtime/templates/reports/final-report-v2.template.md +7 -1
- package/runtime/templates/reports/html/base.template.html +21 -0
- package/runtime/templates/reports/html/i18n/en.json +4 -0
- package/runtime/templates/reports/html/i18n/ko.json +4 -0
- package/runtime/templates/reports/html/macros/forms.html +5 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +1 -1
- package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +6 -35
- package/runtime/validators/lib/fixtures.sh +49 -16
- package/runtime/validators/validate-run.py +486 -134
- package/runtime/validators/validate_session_conformance.py +25 -7
- package/src/cli-registry.mjs +0 -539
- package/src/commands/execute/codex-dispatch.mjs +0 -10
- package/src/commands/execute/plan-items.mjs +0 -9
- package/src/commands/execute/plan-validate.mjs +0 -68
- package/src/commands/execute/run.mjs +0 -208
- package/src/commands/execute/wizard.mjs +0 -167
- package/src/commands/inspect/stage-map.mjs +0 -98
- package/src/commands/inspect/task-list.mjs +0 -97
- package/src/commands/inspect/task-show.mjs +0 -100
- package/src/commands/lifecycle/check-project.mjs +0 -187
- package/src/commands/lifecycle/config.mjs +0 -385
- package/src/commands/lifecycle/doctor.mjs +0 -308
- package/src/commands/lifecycle/install.mjs +0 -1170
- package/src/commands/lifecycle/paths.mjs +0 -104
- package/src/commands/lifecycle/preflight.mjs +0 -125
- package/src/commands/lifecycle/setup.mjs +0 -333
- package/src/commands/lifecycle/uninstall.mjs +0 -279
- package/src/commands/memory/memory.mjs +0 -524
- package/src/commands/pr/pr.mjs +0 -261
- package/src/lib/host-registry-client.mjs +0 -176
- package/src/lib/paths.mjs +0 -86
- package/src/lib/proc.mjs +0 -31
- package/src/lib/python-helper.mjs +0 -142
- package/src/lib/runtime-manifest.mjs +0 -70
- package/src/lib/skill-catalog.mjs +0 -53
- package/src/lib/version.mjs +0 -20
- /package/{src → dist}/commands/pr/default.md +0 -0
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
"""Append-only persistence and validation for execution identity v2."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from .domain.provider import HostModelBinding, ServedModelAttestation
|
|
13
|
+
from .domain.worker_exec import WorkerWriteCapability
|
|
14
|
+
from .execution_identity import (
|
|
15
|
+
Attempt,
|
|
16
|
+
EXECUTION_IDENTITY_VERSION,
|
|
17
|
+
ExecutionManifest,
|
|
18
|
+
ExecutionManifestError,
|
|
19
|
+
Invocation,
|
|
20
|
+
ParticipantAssignment,
|
|
21
|
+
RoleExecution,
|
|
22
|
+
V2_SCHEMA_VERSION,
|
|
23
|
+
execution_label,
|
|
24
|
+
identity_surface_from_payload,
|
|
25
|
+
read_identity_surface,
|
|
26
|
+
synthesize_v1_execution_view,
|
|
27
|
+
validate_task_role_execution_index,
|
|
28
|
+
)
|
|
29
|
+
from .final_report_schema import SchemaError, load_schema, validate as validate_schema
|
|
30
|
+
from .run_context import task_mutex
|
|
31
|
+
from .write_policy import (
|
|
32
|
+
WritePolicyError,
|
|
33
|
+
validate_write_enforcement_payload,
|
|
34
|
+
validate_write_policy_payload,
|
|
35
|
+
write_policy_digest,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_IDENTITY_KEYS = (
|
|
40
|
+
"schemaVersion",
|
|
41
|
+
"executionIdentityVersion",
|
|
42
|
+
"entryMode",
|
|
43
|
+
"participantAssignments",
|
|
44
|
+
"roleExecutions",
|
|
45
|
+
"invocations",
|
|
46
|
+
"attempts",
|
|
47
|
+
"attemptEvidenceSeals",
|
|
48
|
+
"mutationRecoveryDecisions",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_execution_manifest(path: Path) -> ExecutionManifest:
|
|
53
|
+
manifest, _payload = read_execution_manifest_view(path)
|
|
54
|
+
return manifest
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_execution_manifest_view(
|
|
58
|
+
path: Path,
|
|
59
|
+
) -> tuple[ExecutionManifest, Mapping[str, Any]]:
|
|
60
|
+
"""Read one validated manifest snapshot with its enclosing run authority."""
|
|
61
|
+
surface = read_identity_surface(path, surface="run-manifest")
|
|
62
|
+
if surface.legacy:
|
|
63
|
+
return synthesize_v1_execution_view(surface.payload), surface.payload
|
|
64
|
+
validate_execution_manifest_payload(surface.payload)
|
|
65
|
+
return _execution_manifest_from_payload(surface.payload), surface.payload
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_execution_manifest(path: Path, manifest: ExecutionManifest) -> None:
|
|
69
|
+
if manifest.legacy:
|
|
70
|
+
raise ExecutionManifestError("cannot write a v1 memory view")
|
|
71
|
+
if path.exists():
|
|
72
|
+
raise ExecutionManifestError(f"execution manifest already exists: {path}")
|
|
73
|
+
payload = manifest.to_payload()
|
|
74
|
+
validate_execution_manifest_payload(payload)
|
|
75
|
+
_write_json_atomic(path, payload, create_only=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def append_role_execution(
|
|
79
|
+
path: Path,
|
|
80
|
+
row: RoleExecution,
|
|
81
|
+
*,
|
|
82
|
+
task_key: str,
|
|
83
|
+
task_manifest_path: Path | None = None,
|
|
84
|
+
run_ref: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
with task_mutex(task_key):
|
|
87
|
+
manifest = read_execution_manifest(path)
|
|
88
|
+
if manifest.legacy:
|
|
89
|
+
raise ExecutionManifestError("cannot append execution identity to v1 data")
|
|
90
|
+
task_payload = None
|
|
91
|
+
if task_manifest_path is not None and run_ref is not None:
|
|
92
|
+
task_payload = _read_json_object(task_manifest_path)
|
|
93
|
+
_validated_existing_task_index(task_manifest_path, task_payload)
|
|
94
|
+
_validate_run_ref_for_manifest(
|
|
95
|
+
path,
|
|
96
|
+
task_manifest_path,
|
|
97
|
+
task_payload,
|
|
98
|
+
run_ref,
|
|
99
|
+
)
|
|
100
|
+
_validate_next_role_execution(manifest, row)
|
|
101
|
+
updated = _replace_manifest(
|
|
102
|
+
manifest,
|
|
103
|
+
role_executions=(*manifest.role_executions, row),
|
|
104
|
+
)
|
|
105
|
+
if task_payload is not None:
|
|
106
|
+
_validate_new_task_index_row(updated, row)
|
|
107
|
+
_write_manifest_over_existing(path, updated)
|
|
108
|
+
if (
|
|
109
|
+
task_manifest_path is not None
|
|
110
|
+
and run_ref is not None
|
|
111
|
+
and task_payload is not None
|
|
112
|
+
):
|
|
113
|
+
_append_task_index_unlocked(
|
|
114
|
+
task_manifest_path,
|
|
115
|
+
run_ref,
|
|
116
|
+
row,
|
|
117
|
+
task_payload,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def reserve_derived_role_invocation(
|
|
122
|
+
path: Path,
|
|
123
|
+
*,
|
|
124
|
+
source_role_execution_ref: str,
|
|
125
|
+
role: str,
|
|
126
|
+
invocation_factory: Callable[[RoleExecution], Invocation],
|
|
127
|
+
task_key: str,
|
|
128
|
+
task_manifest_path: Path,
|
|
129
|
+
run_ref: str,
|
|
130
|
+
) -> tuple[RoleExecution, Invocation]:
|
|
131
|
+
"""Reserve one derived role and logical invocation under the task lock."""
|
|
132
|
+
with task_mutex(task_key):
|
|
133
|
+
manifest = read_execution_manifest(path)
|
|
134
|
+
if manifest.legacy:
|
|
135
|
+
raise ExecutionManifestError("cannot append execution identity to v1 data")
|
|
136
|
+
task_payload = _read_json_object(task_manifest_path)
|
|
137
|
+
task_payload_before_recovery = dict(task_payload)
|
|
138
|
+
_rebuild_task_identity_index_unlocked(task_manifest_path, task_payload)
|
|
139
|
+
_validate_run_ref_for_manifest(
|
|
140
|
+
path,
|
|
141
|
+
task_manifest_path,
|
|
142
|
+
task_payload,
|
|
143
|
+
run_ref,
|
|
144
|
+
)
|
|
145
|
+
source = next(
|
|
146
|
+
(
|
|
147
|
+
item
|
|
148
|
+
for item in manifest.role_executions
|
|
149
|
+
if item.role_execution_ref == source_role_execution_ref
|
|
150
|
+
),
|
|
151
|
+
None,
|
|
152
|
+
)
|
|
153
|
+
if source is None:
|
|
154
|
+
raise ExecutionManifestError(
|
|
155
|
+
f"source role execution is unknown: {source_role_execution_ref}"
|
|
156
|
+
)
|
|
157
|
+
derived, created = _derived_role_execution(manifest, source, role)
|
|
158
|
+
invocation = invocation_factory(derived)
|
|
159
|
+
if not isinstance(invocation, Invocation):
|
|
160
|
+
raise ExecutionManifestError("invocation factory must return Invocation")
|
|
161
|
+
existing_invocation = next(
|
|
162
|
+
(
|
|
163
|
+
item
|
|
164
|
+
for item in manifest.invocations
|
|
165
|
+
if item.invocation_ref == invocation.invocation_ref
|
|
166
|
+
),
|
|
167
|
+
None,
|
|
168
|
+
)
|
|
169
|
+
if existing_invocation is not None:
|
|
170
|
+
if existing_invocation != invocation:
|
|
171
|
+
raise ExecutionManifestError(
|
|
172
|
+
f"invocationRef drift: {invocation.invocation_ref}"
|
|
173
|
+
)
|
|
174
|
+
if created:
|
|
175
|
+
raise ExecutionManifestError(
|
|
176
|
+
"existing invocation cannot reference an unpersisted role execution"
|
|
177
|
+
)
|
|
178
|
+
recovered_task_payload = _task_payload_with_role_index(
|
|
179
|
+
task_payload,
|
|
180
|
+
run_ref,
|
|
181
|
+
derived,
|
|
182
|
+
)
|
|
183
|
+
if recovered_task_payload != task_payload_before_recovery:
|
|
184
|
+
_write_json_atomic(task_manifest_path, recovered_task_payload)
|
|
185
|
+
return derived, existing_invocation
|
|
186
|
+
|
|
187
|
+
roles = (
|
|
188
|
+
(*manifest.role_executions, derived)
|
|
189
|
+
if created
|
|
190
|
+
else manifest.role_executions
|
|
191
|
+
)
|
|
192
|
+
with_role = _replace_manifest(manifest, role_executions=roles)
|
|
193
|
+
if created:
|
|
194
|
+
_validate_next_role_execution(manifest, derived)
|
|
195
|
+
_validate_next_invocation(with_role, invocation)
|
|
196
|
+
updated = _replace_manifest(
|
|
197
|
+
with_role,
|
|
198
|
+
invocations=(*with_role.invocations, invocation),
|
|
199
|
+
)
|
|
200
|
+
validate_execution_manifest_payload(updated.to_payload())
|
|
201
|
+
updated_task_payload = _task_payload_with_role_index(
|
|
202
|
+
task_payload,
|
|
203
|
+
run_ref,
|
|
204
|
+
derived,
|
|
205
|
+
)
|
|
206
|
+
_write_manifest_and_task_index(
|
|
207
|
+
path,
|
|
208
|
+
updated,
|
|
209
|
+
task_manifest_path,
|
|
210
|
+
updated_task_payload,
|
|
211
|
+
)
|
|
212
|
+
return derived, invocation
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def append_invocation(path: Path, row: Invocation, *, task_key: str) -> None:
|
|
216
|
+
with task_mutex(task_key):
|
|
217
|
+
manifest = read_execution_manifest(path)
|
|
218
|
+
if manifest.legacy:
|
|
219
|
+
raise ExecutionManifestError("cannot append invocation to v1 data")
|
|
220
|
+
_validate_next_invocation(manifest, row)
|
|
221
|
+
updated = _replace_manifest(
|
|
222
|
+
manifest,
|
|
223
|
+
invocations=(*manifest.invocations, row),
|
|
224
|
+
)
|
|
225
|
+
_write_manifest_over_existing(path, updated)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def ensure_invocation(path: Path, row: Invocation, *, task_key: str) -> Invocation:
|
|
229
|
+
"""Create one logical invocation or return its byte-equivalent value."""
|
|
230
|
+
with task_mutex(task_key):
|
|
231
|
+
manifest = read_execution_manifest(path)
|
|
232
|
+
if manifest.legacy:
|
|
233
|
+
raise ExecutionManifestError("cannot append invocation to v1 data")
|
|
234
|
+
existing = next(
|
|
235
|
+
(
|
|
236
|
+
item
|
|
237
|
+
for item in manifest.invocations
|
|
238
|
+
if item.invocation_ref == row.invocation_ref
|
|
239
|
+
),
|
|
240
|
+
None,
|
|
241
|
+
)
|
|
242
|
+
if existing is not None:
|
|
243
|
+
if existing != row:
|
|
244
|
+
raise ExecutionManifestError(
|
|
245
|
+
f"invocationRef drift: {row.invocation_ref}"
|
|
246
|
+
)
|
|
247
|
+
return existing
|
|
248
|
+
_validate_next_invocation(manifest, row)
|
|
249
|
+
updated = _replace_manifest(
|
|
250
|
+
manifest,
|
|
251
|
+
invocations=(*manifest.invocations, row),
|
|
252
|
+
)
|
|
253
|
+
_write_manifest_over_existing(path, updated)
|
|
254
|
+
return row
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def append_attempt(path: Path, row: Attempt, *, task_key: str) -> None:
|
|
258
|
+
with task_mutex(task_key):
|
|
259
|
+
manifest = read_execution_manifest(path)
|
|
260
|
+
if manifest.legacy:
|
|
261
|
+
raise ExecutionManifestError("cannot append attempt to v1 data")
|
|
262
|
+
_validate_next_attempt(manifest, row)
|
|
263
|
+
updated = _replace_manifest(
|
|
264
|
+
manifest,
|
|
265
|
+
attempts=(*manifest.attempts, row),
|
|
266
|
+
)
|
|
267
|
+
_write_manifest_over_existing(path, updated)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def record_invocation_attempt(
|
|
271
|
+
path: Path,
|
|
272
|
+
invocation: Invocation,
|
|
273
|
+
attempt: Attempt,
|
|
274
|
+
*,
|
|
275
|
+
task_key: str,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Ensure one logical invocation and append its next attempt atomically."""
|
|
278
|
+
with task_mutex(task_key):
|
|
279
|
+
manifest = read_execution_manifest(path)
|
|
280
|
+
if manifest.legacy:
|
|
281
|
+
raise ExecutionManifestError("cannot append execution identity to v1 data")
|
|
282
|
+
existing = next(
|
|
283
|
+
(
|
|
284
|
+
item
|
|
285
|
+
for item in manifest.invocations
|
|
286
|
+
if item.invocation_ref == invocation.invocation_ref
|
|
287
|
+
),
|
|
288
|
+
None,
|
|
289
|
+
)
|
|
290
|
+
if existing is not None and existing != invocation:
|
|
291
|
+
raise ExecutionManifestError(
|
|
292
|
+
f"invocationRef drift: {invocation.invocation_ref}"
|
|
293
|
+
)
|
|
294
|
+
invocations = manifest.invocations
|
|
295
|
+
if existing is None:
|
|
296
|
+
_validate_next_invocation(manifest, invocation)
|
|
297
|
+
invocations = (*invocations, invocation)
|
|
298
|
+
with_invocation = _replace_manifest(manifest, invocations=invocations)
|
|
299
|
+
_validate_next_attempt(with_invocation, attempt)
|
|
300
|
+
updated = _replace_manifest(
|
|
301
|
+
with_invocation,
|
|
302
|
+
attempts=(*with_invocation.attempts, attempt),
|
|
303
|
+
)
|
|
304
|
+
_write_manifest_over_existing(path, updated)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def finish_attempt_mutation(
|
|
308
|
+
path: Path,
|
|
309
|
+
*,
|
|
310
|
+
invocation_ref: str,
|
|
311
|
+
attempt: int,
|
|
312
|
+
finished_at: str,
|
|
313
|
+
status: str,
|
|
314
|
+
result_path: str | None,
|
|
315
|
+
error_path: str | None,
|
|
316
|
+
change_summary: Mapping[str, Any],
|
|
317
|
+
task_key: str,
|
|
318
|
+
) -> Attempt:
|
|
319
|
+
"""Close one started attempt with Task 11 mutation evidence only."""
|
|
320
|
+
with task_mutex(task_key):
|
|
321
|
+
manifest = read_execution_manifest(path)
|
|
322
|
+
index = next(
|
|
323
|
+
(
|
|
324
|
+
offset for offset, row in enumerate(manifest.attempts)
|
|
325
|
+
if row.invocation_ref == invocation_ref and row.attempt == attempt
|
|
326
|
+
),
|
|
327
|
+
None,
|
|
328
|
+
)
|
|
329
|
+
if index is None:
|
|
330
|
+
raise ExecutionManifestError("attempt to finish is unknown")
|
|
331
|
+
existing = manifest.attempts[index]
|
|
332
|
+
closed = Attempt(
|
|
333
|
+
invocation_ref=invocation_ref,
|
|
334
|
+
attempt=attempt,
|
|
335
|
+
started_at=existing.started_at,
|
|
336
|
+
finished_at=finished_at,
|
|
337
|
+
status=status,
|
|
338
|
+
result_path=result_path,
|
|
339
|
+
error_path=error_path,
|
|
340
|
+
change_summary=dict(change_summary),
|
|
341
|
+
evidence_seal_ref=None,
|
|
342
|
+
)
|
|
343
|
+
if existing.finished_at is not None:
|
|
344
|
+
if existing != closed:
|
|
345
|
+
raise ExecutionManifestError("attempt terminal mutation result drift")
|
|
346
|
+
return existing
|
|
347
|
+
attempts = list(manifest.attempts)
|
|
348
|
+
attempts[index] = closed
|
|
349
|
+
updated = _replace_manifest(manifest, attempts=tuple(attempts))
|
|
350
|
+
_write_manifest_over_existing(path, updated)
|
|
351
|
+
return closed
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def persist_static_execution_identity(
|
|
355
|
+
*,
|
|
356
|
+
run_manifest_path: Path,
|
|
357
|
+
task_manifest_path: Path,
|
|
358
|
+
input_snapshot_path: Path,
|
|
359
|
+
manifest: ExecutionManifest,
|
|
360
|
+
task_key: str,
|
|
361
|
+
run_ref: str,
|
|
362
|
+
dynamic_roles: Sequence[str],
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Publish v2 snapshots and their task index under the existing task mutex."""
|
|
365
|
+
payload = manifest.to_payload()
|
|
366
|
+
validate_execution_manifest_payload(payload)
|
|
367
|
+
with task_mutex(task_key):
|
|
368
|
+
run_payload = _read_json_object(run_manifest_path)
|
|
369
|
+
input_payload = _read_json_object(input_snapshot_path)
|
|
370
|
+
input_identity = _input_identity_payload(manifest, dynamic_roles)
|
|
371
|
+
_assert_input_identity_immutable(input_payload, input_identity)
|
|
372
|
+
task_payload = _read_json_object(task_manifest_path)
|
|
373
|
+
identity_surface_from_payload(task_payload, surface="task-manifest")
|
|
374
|
+
_validated_existing_task_index(task_manifest_path, task_payload)
|
|
375
|
+
|
|
376
|
+
run_payload.update(payload)
|
|
377
|
+
input_payload.update(input_identity)
|
|
378
|
+
|
|
379
|
+
_write_json_atomic(run_manifest_path, run_payload)
|
|
380
|
+
_write_json_atomic(input_snapshot_path, input_payload)
|
|
381
|
+
_rebuild_task_identity_index_unlocked(task_manifest_path, task_payload)
|
|
382
|
+
_write_json_atomic(task_manifest_path, task_payload)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def rebuild_task_role_execution_index(
|
|
386
|
+
task_manifest_path: Path,
|
|
387
|
+
*,
|
|
388
|
+
task_key: str,
|
|
389
|
+
) -> list[dict[str, str]]:
|
|
390
|
+
with task_mutex(task_key):
|
|
391
|
+
payload = _read_json_object(task_manifest_path)
|
|
392
|
+
rows = _rebuild_task_identity_index_unlocked(task_manifest_path, payload)
|
|
393
|
+
_write_json_atomic(task_manifest_path, payload)
|
|
394
|
+
return rows
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def validate_execution_manifest_payload(payload: Mapping[str, Any]) -> None:
|
|
398
|
+
_require_v2_manifest_keys(payload)
|
|
399
|
+
_validate_execution_manifest_schema(payload)
|
|
400
|
+
participants = _indexed_rows(payload["participantAssignments"], "participantRef")
|
|
401
|
+
roles = _indexed_rows(payload["roleExecutions"], "roleExecutionRef")
|
|
402
|
+
invocations = _indexed_rows(payload["invocations"], "invocationRef")
|
|
403
|
+
_validate_roles(payload, participants)
|
|
404
|
+
_validate_invocations(participants, roles, invocations)
|
|
405
|
+
_validate_attempts(payload["attempts"], invocations)
|
|
406
|
+
_validate_seals_and_recoveries(payload)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _validate_execution_manifest_schema(payload: Mapping[str, Any]) -> None:
|
|
410
|
+
try:
|
|
411
|
+
errors = validate_schema(payload, _execution_manifest_schema())
|
|
412
|
+
except SchemaError as exc:
|
|
413
|
+
raise ExecutionManifestError(f"invalid execution manifest schema: {exc}") from exc
|
|
414
|
+
if errors:
|
|
415
|
+
raise ExecutionManifestError(errors[0])
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@lru_cache(maxsize=1)
|
|
419
|
+
def _execution_manifest_schema() -> dict[str, Any]:
|
|
420
|
+
here = Path(__file__).resolve()
|
|
421
|
+
for parent in [here.parent, *here.parents]:
|
|
422
|
+
candidate = parent / "schemas/execution-manifest-v2.schema.json"
|
|
423
|
+
if candidate.is_file():
|
|
424
|
+
try:
|
|
425
|
+
return load_schema(candidate)
|
|
426
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
427
|
+
raise ExecutionManifestError(
|
|
428
|
+
f"cannot load execution manifest schema: {candidate}"
|
|
429
|
+
) from exc
|
|
430
|
+
raise ExecutionManifestError("cannot locate schemas/execution-manifest-v2.schema.json")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _require_v2_manifest_keys(payload: Mapping[str, Any]) -> None:
|
|
434
|
+
if payload.get("schemaVersion") != V2_SCHEMA_VERSION:
|
|
435
|
+
raise ExecutionManifestError("execution manifest schemaVersion must be 2.0")
|
|
436
|
+
if payload.get("executionIdentityVersion") != EXECUTION_IDENTITY_VERSION:
|
|
437
|
+
raise ExecutionManifestError("executionIdentityVersion must be 2")
|
|
438
|
+
required = _IDENTITY_KEYS[2:]
|
|
439
|
+
missing = [key for key in required if key not in payload]
|
|
440
|
+
if missing:
|
|
441
|
+
raise ExecutionManifestError(
|
|
442
|
+
"execution manifest is missing required fields: " + ", ".join(missing)
|
|
443
|
+
)
|
|
444
|
+
for key in required[1:]:
|
|
445
|
+
if not isinstance(payload[key], list):
|
|
446
|
+
raise ExecutionManifestError(f"execution manifest {key} must be an array")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _validate_roles(
|
|
450
|
+
payload: Mapping[str, Any],
|
|
451
|
+
participants: Mapping[str, Mapping[str, Any]],
|
|
452
|
+
) -> None:
|
|
453
|
+
ordinals: dict[str, list[int]] = {}
|
|
454
|
+
earlier_roles: dict[str, Mapping[str, Any]] = {}
|
|
455
|
+
participant_roles: set[tuple[str, str]] = set()
|
|
456
|
+
for row in payload["roleExecutions"]:
|
|
457
|
+
role_ref = _required_text(row, "roleExecutionRef")
|
|
458
|
+
participant_ref = _required_text(row, "participantRef")
|
|
459
|
+
if participant_ref not in participants:
|
|
460
|
+
raise ExecutionManifestError(
|
|
461
|
+
f"unknown participantRef in role execution: {participant_ref}"
|
|
462
|
+
)
|
|
463
|
+
_validate_role_identity(payload, row, participants[participant_ref])
|
|
464
|
+
role = _required_text(row, "role")
|
|
465
|
+
participant_role = (participant_ref, role)
|
|
466
|
+
if participant_role in participant_roles:
|
|
467
|
+
raise ExecutionManifestError(
|
|
468
|
+
"role execution participantRef and role must be unique"
|
|
469
|
+
)
|
|
470
|
+
participant_roles.add(participant_role)
|
|
471
|
+
ordinal = row.get("ordinal")
|
|
472
|
+
if not isinstance(ordinal, int) or ordinal < 1:
|
|
473
|
+
raise ExecutionManifestError("role execution ordinal must be positive")
|
|
474
|
+
ordinals.setdefault(role, []).append(ordinal)
|
|
475
|
+
source = row.get("sourceRoleExecutionRef")
|
|
476
|
+
if source is not None:
|
|
477
|
+
source_role = earlier_roles.get(source)
|
|
478
|
+
if source_role is None:
|
|
479
|
+
raise ExecutionManifestError(
|
|
480
|
+
"sourceRoleExecutionRef must reference an earlier different "
|
|
481
|
+
"role execution"
|
|
482
|
+
)
|
|
483
|
+
if source_role.get("participantRef") != participant_ref:
|
|
484
|
+
raise ExecutionManifestError(
|
|
485
|
+
"sourceRoleExecutionRef participant must match derived role "
|
|
486
|
+
"participant"
|
|
487
|
+
)
|
|
488
|
+
earlier_roles[role_ref] = row
|
|
489
|
+
for role, values in ordinals.items():
|
|
490
|
+
if sorted(values) != list(range(1, len(values) + 1)):
|
|
491
|
+
raise ExecutionManifestError(f"role ordinals are not continuous: {role}")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _validate_role_identity(
|
|
495
|
+
payload: Mapping[str, Any],
|
|
496
|
+
row: Mapping[str, Any],
|
|
497
|
+
participant: Mapping[str, Any],
|
|
498
|
+
) -> None:
|
|
499
|
+
role = _required_text(row, "role")
|
|
500
|
+
provider = _required_text(row, "provider")
|
|
501
|
+
model_id = _required_text(row, "modelId")
|
|
502
|
+
unknown = row.get("modelRef") is None or row.get("modelSpecDigest") is None
|
|
503
|
+
if unknown and not _is_current_session_unknown_leader(payload, row, participant):
|
|
504
|
+
raise ExecutionManifestError("unknown model is allowed only for current-session leader")
|
|
505
|
+
if not unknown and row.get("binding") is None:
|
|
506
|
+
raise ExecutionManifestError("binding is required for a known model")
|
|
507
|
+
expected_label = execution_label(role, provider, model_id, row.get("ordinal"))
|
|
508
|
+
if row.get("executionLabel") != expected_label:
|
|
509
|
+
raise ExecutionManifestError("executionLabel does not match structured identity")
|
|
510
|
+
_validate_role_participant_match(payload, row, participant)
|
|
511
|
+
source = row.get("sourceRoleExecutionRef")
|
|
512
|
+
if source is not None and not isinstance(source, str):
|
|
513
|
+
raise ExecutionManifestError("sourceRoleExecutionRef must be a string or null")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _validate_role_participant_match(
|
|
517
|
+
payload: Mapping[str, Any],
|
|
518
|
+
row: Mapping[str, Any],
|
|
519
|
+
participant: Mapping[str, Any],
|
|
520
|
+
) -> None:
|
|
521
|
+
for key in ("provider", "modelRef", "modelId"):
|
|
522
|
+
if row.get(key) != participant.get(key):
|
|
523
|
+
raise ExecutionManifestError(
|
|
524
|
+
f"role execution {key} does not match participant assignment"
|
|
525
|
+
)
|
|
526
|
+
binding = row.get("binding")
|
|
527
|
+
if binding is None:
|
|
528
|
+
if participant.get("hostModelValue") is not None:
|
|
529
|
+
raise ExecutionManifestError(
|
|
530
|
+
"role execution hostModelValue does not match participant"
|
|
531
|
+
)
|
|
532
|
+
_validate_participant_host_runtime(payload, participant)
|
|
533
|
+
return
|
|
534
|
+
if not isinstance(binding, Mapping):
|
|
535
|
+
raise ExecutionManifestError("role execution binding must be an object or null")
|
|
536
|
+
if binding.get("runner") != participant.get("runner"):
|
|
537
|
+
raise ExecutionManifestError("role execution runner does not match participant")
|
|
538
|
+
if binding.get("workerWriteCapability") != participant.get("workerWriteCapability"):
|
|
539
|
+
raise ExecutionManifestError(
|
|
540
|
+
"role execution workerWriteCapability does not match participant"
|
|
541
|
+
)
|
|
542
|
+
if binding.get("hostModelValue") != participant.get("hostModelValue"):
|
|
543
|
+
raise ExecutionManifestError(
|
|
544
|
+
"role execution hostModelValue does not match participant"
|
|
545
|
+
)
|
|
546
|
+
_validate_participant_host_runtime(payload, participant)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _validate_participant_host_runtime(
|
|
550
|
+
payload: Mapping[str, Any],
|
|
551
|
+
participant: Mapping[str, Any],
|
|
552
|
+
) -> None:
|
|
553
|
+
run_host = payload.get("leadRuntime")
|
|
554
|
+
if run_host is not None and participant.get("hostRuntime") != run_host:
|
|
555
|
+
raise ExecutionManifestError(
|
|
556
|
+
"participant hostRuntime does not match run host"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _is_current_session_unknown_leader(
|
|
561
|
+
payload: Mapping[str, Any],
|
|
562
|
+
row: Mapping[str, Any],
|
|
563
|
+
participant: Mapping[str, Any],
|
|
564
|
+
) -> bool:
|
|
565
|
+
attestation = row.get("servedModelAttestation")
|
|
566
|
+
return (
|
|
567
|
+
payload.get("entryMode") == "current-session"
|
|
568
|
+
and row.get("role") == "leader"
|
|
569
|
+
and row.get("modelRef") is None
|
|
570
|
+
and row.get("modelId") == "unknown"
|
|
571
|
+
and row.get("modelSpecDigest") is None
|
|
572
|
+
and row.get("binding") is None
|
|
573
|
+
and isinstance(attestation, Mapping)
|
|
574
|
+
and attestation.get("level") == "unknown"
|
|
575
|
+
and participant.get("entryMode") == "current-session"
|
|
576
|
+
and participant.get("runner") == "current-session"
|
|
577
|
+
and participant.get("workerWriteCapability") is None
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _validate_invocations(
|
|
582
|
+
participants: Mapping[str, Mapping[str, Any]],
|
|
583
|
+
roles: Mapping[str, Mapping[str, Any]],
|
|
584
|
+
invocations: Mapping[str, Mapping[str, Any]],
|
|
585
|
+
) -> None:
|
|
586
|
+
for row in invocations.values():
|
|
587
|
+
participant_ref = _required_text(row, "participantRef")
|
|
588
|
+
role_ref = _required_text(row, "roleExecutionRef")
|
|
589
|
+
if participant_ref not in participants or role_ref not in roles:
|
|
590
|
+
raise ExecutionManifestError("invocation references unknown execution identity")
|
|
591
|
+
if roles[role_ref].get("participantRef") != participant_ref:
|
|
592
|
+
raise ExecutionManifestError("invocation participant does not own role execution")
|
|
593
|
+
source = row.get("sourceInvocationRef")
|
|
594
|
+
if source is not None and source not in invocations:
|
|
595
|
+
raise ExecutionManifestError("invocation sourceInvocationRef is unknown")
|
|
596
|
+
_validate_invocation_write_contract(row, participants[participant_ref], roles[role_ref])
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _validate_invocation_write_contract(
|
|
600
|
+
row: Mapping[str, Any],
|
|
601
|
+
participant: Mapping[str, Any],
|
|
602
|
+
role: Mapping[str, Any],
|
|
603
|
+
) -> None:
|
|
604
|
+
policy = row.get("writePolicy")
|
|
605
|
+
enforcement = row.get("writeEnforcement")
|
|
606
|
+
if not isinstance(policy, Mapping) or not isinstance(enforcement, Mapping):
|
|
607
|
+
raise ExecutionManifestError("invocation write contract must be structured")
|
|
608
|
+
try:
|
|
609
|
+
validate_write_policy_payload(policy)
|
|
610
|
+
validate_write_enforcement_payload(enforcement)
|
|
611
|
+
expected_digest = write_policy_digest(policy)
|
|
612
|
+
except WritePolicyError as exc:
|
|
613
|
+
raise ExecutionManifestError(f"invocation write contract: {exc}") from exc
|
|
614
|
+
if row.get("writePolicyDigest") != expected_digest:
|
|
615
|
+
raise ExecutionManifestError("invocation writePolicyDigest does not match")
|
|
616
|
+
source = policy.get("sourcePolicy")
|
|
617
|
+
expected_mode = (
|
|
618
|
+
"project-mutation" if role.get("role") == "implementer" else "source-readonly"
|
|
619
|
+
)
|
|
620
|
+
if not isinstance(source, Mapping) or source.get("mode") != expected_mode:
|
|
621
|
+
raise ExecutionManifestError("invocation sourcePolicy does not match role")
|
|
622
|
+
git_policy = policy.get("gitPolicy")
|
|
623
|
+
expected_git_mode = (
|
|
624
|
+
"fast-forward-descendant-chain"
|
|
625
|
+
if expected_mode == "project-mutation"
|
|
626
|
+
else "disabled"
|
|
627
|
+
)
|
|
628
|
+
if (
|
|
629
|
+
not isinstance(git_policy, Mapping)
|
|
630
|
+
or git_policy.get("mode") != expected_git_mode
|
|
631
|
+
):
|
|
632
|
+
raise ExecutionManifestError("invocation gitPolicy does not match role")
|
|
633
|
+
if expected_mode == "source-readonly" and source.get("plannedPaths"):
|
|
634
|
+
raise ExecutionManifestError(
|
|
635
|
+
"source-readonly invocation cannot declare planned paths"
|
|
636
|
+
)
|
|
637
|
+
_validate_enforcement_capability(enforcement, participant, role)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _validate_enforcement_capability(
|
|
641
|
+
enforcement: Mapping[str, Any],
|
|
642
|
+
participant: Mapping[str, Any],
|
|
643
|
+
role: Mapping[str, Any],
|
|
644
|
+
) -> None:
|
|
645
|
+
capability = participant.get("workerWriteCapability")
|
|
646
|
+
if role.get("role") == "leader" and capability is None:
|
|
647
|
+
if enforcement.get("boundaryPrecision") != "none":
|
|
648
|
+
raise ExecutionManifestError(
|
|
649
|
+
"leader writeEnforcement must use boundaryPrecision none"
|
|
650
|
+
)
|
|
651
|
+
if enforcement.get("mutationAudit") != "none":
|
|
652
|
+
raise ExecutionManifestError(
|
|
653
|
+
"leader writeEnforcement must use mutationAudit none"
|
|
654
|
+
)
|
|
655
|
+
return
|
|
656
|
+
if not isinstance(capability, Mapping):
|
|
657
|
+
raise ExecutionManifestError("worker invocation has no write capability")
|
|
658
|
+
rank = {"none": 0, "directory-boundary": 1, "exact-path": 2}
|
|
659
|
+
maximum = capability.get("maxBoundaryPrecision")
|
|
660
|
+
actual = enforcement.get("boundaryPrecision")
|
|
661
|
+
if maximum not in rank or actual not in rank or rank[actual] > rank[maximum]:
|
|
662
|
+
raise ExecutionManifestError("writeEnforcement exceeds worker capability")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _validate_attempts(
|
|
666
|
+
rows: object,
|
|
667
|
+
invocations: Mapping[str, Mapping[str, Any]],
|
|
668
|
+
) -> None:
|
|
669
|
+
if not isinstance(rows, list):
|
|
670
|
+
raise ExecutionManifestError("execution manifest attempts must be an array")
|
|
671
|
+
attempts_by_invocation: dict[str, list[int]] = {}
|
|
672
|
+
for row in rows:
|
|
673
|
+
if not isinstance(row, Mapping):
|
|
674
|
+
raise ExecutionManifestError("attempt row must be an object")
|
|
675
|
+
invocation_ref = _required_text(row, "invocationRef")
|
|
676
|
+
if invocation_ref not in invocations:
|
|
677
|
+
raise ExecutionManifestError("attempt references unknown invocation")
|
|
678
|
+
attempt = row.get("attempt")
|
|
679
|
+
if not isinstance(attempt, int) or attempt < 1:
|
|
680
|
+
raise ExecutionManifestError("attempt number must be positive")
|
|
681
|
+
attempts_by_invocation.setdefault(invocation_ref, []).append(attempt)
|
|
682
|
+
for invocation_ref, values in attempts_by_invocation.items():
|
|
683
|
+
if sorted(values) != list(range(1, len(values) + 1)):
|
|
684
|
+
raise ExecutionManifestError(
|
|
685
|
+
f"attempt numbers are not continuous: {invocation_ref}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _validate_seals_and_recoveries(payload: Mapping[str, Any]) -> None:
|
|
690
|
+
attempts = payload.get("attempts")
|
|
691
|
+
if not isinstance(attempts, list):
|
|
692
|
+
return
|
|
693
|
+
from .dispatch_state import build_dispatch_id
|
|
694
|
+
|
|
695
|
+
attempt_refs = {
|
|
696
|
+
build_dispatch_id(str(row.get("invocationRef")), int(row.get("attempt") or 0))
|
|
697
|
+
for row in attempts
|
|
698
|
+
if isinstance(row, Mapping)
|
|
699
|
+
}
|
|
700
|
+
seals = payload.get("attemptEvidenceSeals") or []
|
|
701
|
+
if not isinstance(seals, list):
|
|
702
|
+
raise ExecutionManifestError("attemptEvidenceSeals must be an array")
|
|
703
|
+
seen_attempts: set[str] = set()
|
|
704
|
+
seen_refs: set[str] = set()
|
|
705
|
+
for row in seals:
|
|
706
|
+
if not isinstance(row, Mapping):
|
|
707
|
+
raise ExecutionManifestError("evidence seal must be an object")
|
|
708
|
+
seal_ref = str(row.get("sealRef") or "")
|
|
709
|
+
attempt_ref = str(row.get("attemptRef") or "")
|
|
710
|
+
if not seal_ref or seal_ref in seen_refs:
|
|
711
|
+
raise ExecutionManifestError("evidence sealRef is missing or duplicated")
|
|
712
|
+
if attempt_ref not in attempt_refs or attempt_ref in seen_attempts:
|
|
713
|
+
raise ExecutionManifestError("evidence seal does not uniquely reference an attempt")
|
|
714
|
+
seen_refs.add(seal_ref)
|
|
715
|
+
seen_attempts.add(attempt_ref)
|
|
716
|
+
seal_by_ref = {str(row.get("sealRef")): row for row in seals if isinstance(row, Mapping)}
|
|
717
|
+
for row in attempts:
|
|
718
|
+
if not isinstance(row, Mapping):
|
|
719
|
+
continue
|
|
720
|
+
evidence = row.get("evidenceSealRef")
|
|
721
|
+
if evidence in (None, ""):
|
|
722
|
+
continue
|
|
723
|
+
if evidence not in seal_by_ref:
|
|
724
|
+
raise ExecutionManifestError("attempt evidenceSealRef is unknown")
|
|
725
|
+
recoveries = payload.get("mutationRecoveryDecisions") or []
|
|
726
|
+
if not isinstance(recoveries, list):
|
|
727
|
+
raise ExecutionManifestError("mutationRecoveryDecisions must be an array")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _validate_next_role_execution(
|
|
731
|
+
manifest: ExecutionManifest,
|
|
732
|
+
row: RoleExecution,
|
|
733
|
+
) -> None:
|
|
734
|
+
if row.participant_ref not in {
|
|
735
|
+
item.participant_ref for item in manifest.participant_assignments
|
|
736
|
+
}:
|
|
737
|
+
raise ExecutionManifestError("role execution references unknown participant")
|
|
738
|
+
existing = [item.ordinal for item in manifest.role_executions if item.role == row.role]
|
|
739
|
+
expected = max(existing, default=0) + 1
|
|
740
|
+
if row.ordinal != expected:
|
|
741
|
+
raise ExecutionManifestError(f"next ordinal must be {expected}")
|
|
742
|
+
if row.role_execution_ref in {
|
|
743
|
+
item.role_execution_ref for item in manifest.role_executions
|
|
744
|
+
}:
|
|
745
|
+
raise ExecutionManifestError("roleExecutionRef is duplicated")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _derived_role_execution(
|
|
749
|
+
manifest: ExecutionManifest,
|
|
750
|
+
source: RoleExecution,
|
|
751
|
+
role: str,
|
|
752
|
+
) -> tuple[RoleExecution, bool]:
|
|
753
|
+
if source.role == role:
|
|
754
|
+
return source, False
|
|
755
|
+
existing = [
|
|
756
|
+
item
|
|
757
|
+
for item in manifest.role_executions
|
|
758
|
+
if item.participant_ref == source.participant_ref and item.role == role
|
|
759
|
+
]
|
|
760
|
+
if len(existing) > 1:
|
|
761
|
+
raise ExecutionManifestError(
|
|
762
|
+
f"participant has multiple {role} role executions: {source.participant_ref}"
|
|
763
|
+
)
|
|
764
|
+
if existing:
|
|
765
|
+
return existing[0], False
|
|
766
|
+
ordinal = max(
|
|
767
|
+
(item.ordinal for item in manifest.role_executions if item.role == role),
|
|
768
|
+
default=0,
|
|
769
|
+
) + 1
|
|
770
|
+
role_ref = f"role-exec-{role}-{ordinal:03d}"
|
|
771
|
+
if role_ref in {
|
|
772
|
+
item.role_execution_ref for item in manifest.role_executions
|
|
773
|
+
}:
|
|
774
|
+
raise ExecutionManifestError(
|
|
775
|
+
f"derived roleExecutionRef is already reserved: {role_ref}"
|
|
776
|
+
)
|
|
777
|
+
return RoleExecution(
|
|
778
|
+
role_execution_ref=role_ref,
|
|
779
|
+
participant_ref=source.participant_ref,
|
|
780
|
+
source_role_execution_ref=source.role_execution_ref,
|
|
781
|
+
role=role,
|
|
782
|
+
provider=source.provider,
|
|
783
|
+
model_ref=source.model_ref,
|
|
784
|
+
model_id=source.model_id,
|
|
785
|
+
ordinal=ordinal,
|
|
786
|
+
execution_label=execution_label(
|
|
787
|
+
role,
|
|
788
|
+
source.provider,
|
|
789
|
+
source.model_id,
|
|
790
|
+
ordinal,
|
|
791
|
+
),
|
|
792
|
+
model_spec_digest=source.model_spec_digest,
|
|
793
|
+
binding=source.binding,
|
|
794
|
+
served_model_attestation=ServedModelAttestation.unknown(),
|
|
795
|
+
status="prepared",
|
|
796
|
+
), True
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _validate_next_invocation(manifest: ExecutionManifest, row: Invocation) -> None:
|
|
800
|
+
role = next(
|
|
801
|
+
(
|
|
802
|
+
item
|
|
803
|
+
for item in manifest.role_executions
|
|
804
|
+
if item.role_execution_ref == row.role_execution_ref
|
|
805
|
+
),
|
|
806
|
+
None,
|
|
807
|
+
)
|
|
808
|
+
if role is None or role.participant_ref != row.participant_ref:
|
|
809
|
+
raise ExecutionManifestError("invocation references unknown role execution")
|
|
810
|
+
if row.invocation_ref in {item.invocation_ref for item in manifest.invocations}:
|
|
811
|
+
raise ExecutionManifestError("invocationRef is duplicated")
|
|
812
|
+
if row.source_invocation_ref is not None and row.source_invocation_ref not in {
|
|
813
|
+
item.invocation_ref for item in manifest.invocations
|
|
814
|
+
}:
|
|
815
|
+
raise ExecutionManifestError("sourceInvocationRef is unknown")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _validate_next_attempt(manifest: ExecutionManifest, row: Attempt) -> None:
|
|
819
|
+
if row.invocation_ref not in {
|
|
820
|
+
item.invocation_ref for item in manifest.invocations
|
|
821
|
+
}:
|
|
822
|
+
raise ExecutionManifestError("attempt references unknown invocation")
|
|
823
|
+
prior = [
|
|
824
|
+
item
|
|
825
|
+
for item in manifest.attempts
|
|
826
|
+
if item.invocation_ref == row.invocation_ref
|
|
827
|
+
]
|
|
828
|
+
expected = max((item.attempt for item in prior), default=0) + 1
|
|
829
|
+
if row.attempt != expected:
|
|
830
|
+
raise ExecutionManifestError(f"next attempt must be {expected}")
|
|
831
|
+
if not prior:
|
|
832
|
+
return
|
|
833
|
+
last = max(prior, key=lambda item: item.attempt)
|
|
834
|
+
if last.finished_at is None:
|
|
835
|
+
raise ExecutionManifestError("previous attempt is not terminal")
|
|
836
|
+
if last.status != "failed-no-mutation":
|
|
837
|
+
raise ExecutionManifestError(
|
|
838
|
+
"invocation cannot append another attempt after a terminal mutation"
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _execution_manifest_from_payload(payload: Mapping[str, Any]) -> ExecutionManifest:
|
|
843
|
+
return ExecutionManifest(
|
|
844
|
+
entry_mode=str(payload["entryMode"]),
|
|
845
|
+
participant_assignments=tuple(
|
|
846
|
+
_participant_from_payload(row) for row in payload["participantAssignments"]
|
|
847
|
+
),
|
|
848
|
+
role_executions=tuple(
|
|
849
|
+
_role_from_payload(row) for row in payload["roleExecutions"]
|
|
850
|
+
),
|
|
851
|
+
invocations=tuple(_invocation_from_payload(row) for row in payload["invocations"]),
|
|
852
|
+
attempts=tuple(_attempt_from_payload(row) for row in payload["attempts"]),
|
|
853
|
+
attempt_evidence_seals=tuple(payload["attemptEvidenceSeals"]),
|
|
854
|
+
mutation_recovery_decisions=tuple(payload["mutationRecoveryDecisions"]),
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _participant_from_payload(row: Mapping[str, Any]) -> ParticipantAssignment:
|
|
859
|
+
return ParticipantAssignment(
|
|
860
|
+
participant_ref=str(row["participantRef"]),
|
|
861
|
+
provider=str(row["provider"]),
|
|
862
|
+
model_ref=_optional_text(row.get("modelRef")),
|
|
863
|
+
model_id=str(row["modelId"]),
|
|
864
|
+
runner=str(row["runner"]),
|
|
865
|
+
host_runtime=str(row["hostRuntime"]),
|
|
866
|
+
host_model_value=_optional_text(row.get("hostModelValue")),
|
|
867
|
+
session_ref=_optional_text(row.get("sessionRef")),
|
|
868
|
+
window_ref=_optional_text(row.get("windowRef")),
|
|
869
|
+
worker_write_capability=_capability_from_payload(
|
|
870
|
+
row.get("workerWriteCapability")
|
|
871
|
+
),
|
|
872
|
+
entry_mode=str(row["entryMode"]),
|
|
873
|
+
status=str(row["status"]),
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _role_from_payload(row: Mapping[str, Any]) -> RoleExecution:
|
|
878
|
+
return RoleExecution(
|
|
879
|
+
role_execution_ref=str(row["roleExecutionRef"]),
|
|
880
|
+
participant_ref=str(row["participantRef"]),
|
|
881
|
+
source_role_execution_ref=_optional_text(row.get("sourceRoleExecutionRef")),
|
|
882
|
+
role=str(row["role"]),
|
|
883
|
+
provider=str(row["provider"]),
|
|
884
|
+
model_ref=_optional_text(row.get("modelRef")),
|
|
885
|
+
model_id=str(row["modelId"]),
|
|
886
|
+
ordinal=int(row["ordinal"]),
|
|
887
|
+
execution_label=str(row["executionLabel"]),
|
|
888
|
+
model_spec_digest=_optional_text(row.get("modelSpecDigest")),
|
|
889
|
+
binding=_binding_from_payload(row.get("binding")),
|
|
890
|
+
served_model_attestation=_attestation_from_payload(
|
|
891
|
+
row["servedModelAttestation"]
|
|
892
|
+
),
|
|
893
|
+
status=str(row["status"]),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _invocation_from_payload(row: Mapping[str, Any]) -> Invocation:
|
|
898
|
+
return Invocation(
|
|
899
|
+
invocation_ref=str(row["invocationRef"]),
|
|
900
|
+
participant_ref=str(row["participantRef"]),
|
|
901
|
+
role_execution_ref=str(row["roleExecutionRef"]),
|
|
902
|
+
source_invocation_ref=_optional_text(row.get("sourceInvocationRef")),
|
|
903
|
+
recovery_ref=_optional_text(row.get("recoveryRef")),
|
|
904
|
+
duty_id=str(row["dutyId"]),
|
|
905
|
+
dispatch_kind=str(row["dispatchKind"]),
|
|
906
|
+
round=int(row["round"]),
|
|
907
|
+
input_digest=str(row["inputDigest"]),
|
|
908
|
+
write_policy=dict(row["writePolicy"]),
|
|
909
|
+
write_policy_digest=str(row["writePolicyDigest"]),
|
|
910
|
+
write_enforcement=dict(row["writeEnforcement"]),
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _attempt_from_payload(row: Mapping[str, Any]) -> Attempt:
|
|
915
|
+
return Attempt(
|
|
916
|
+
invocation_ref=str(row["invocationRef"]),
|
|
917
|
+
attempt=int(row["attempt"]),
|
|
918
|
+
started_at=str(row["startedAt"]),
|
|
919
|
+
finished_at=_optional_text(row.get("finishedAt")),
|
|
920
|
+
status=str(row["status"]),
|
|
921
|
+
result_path=_optional_text(row.get("resultPath")),
|
|
922
|
+
error_path=_optional_text(row.get("errorPath")),
|
|
923
|
+
change_summary=dict(row["changeSummary"]),
|
|
924
|
+
evidence_seal_ref=_optional_text(row.get("evidenceSealRef")),
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _binding_from_payload(value: object) -> HostModelBinding | None:
|
|
929
|
+
if value is None:
|
|
930
|
+
return None
|
|
931
|
+
if not isinstance(value, Mapping):
|
|
932
|
+
raise ExecutionManifestError("binding must be an object or null")
|
|
933
|
+
return HostModelBinding(
|
|
934
|
+
runner=str(value["runner"]),
|
|
935
|
+
catalog_execution_value=str(value["catalogExecutionValue"]),
|
|
936
|
+
resolved_execution_value=str(value["resolvedExecutionValue"]),
|
|
937
|
+
host_model_value=_optional_text(value.get("hostModelValue")),
|
|
938
|
+
binding_fidelity=str(value["bindingFidelity"]),
|
|
939
|
+
worker_write_capability=_capability_from_payload(
|
|
940
|
+
value.get("workerWriteCapability")
|
|
941
|
+
),
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _attestation_from_payload(value: object) -> ServedModelAttestation:
|
|
946
|
+
if not isinstance(value, Mapping):
|
|
947
|
+
raise ExecutionManifestError("servedModelAttestation must be an object")
|
|
948
|
+
return ServedModelAttestation(
|
|
949
|
+
observed_model=_optional_text(value.get("observedModel")),
|
|
950
|
+
normalized_model_ref=_optional_text(value.get("normalizedModelRef")),
|
|
951
|
+
level=str(value["level"]),
|
|
952
|
+
source=str(value["source"]),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _capability_from_payload(value: object) -> WorkerWriteCapability | None:
|
|
957
|
+
if value is None:
|
|
958
|
+
return None
|
|
959
|
+
if not isinstance(value, Mapping):
|
|
960
|
+
raise ExecutionManifestError("workerWriteCapability must be an object or null")
|
|
961
|
+
return WorkerWriteCapability(str(value["maxBoundaryPrecision"]))
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _replace_manifest(manifest: ExecutionManifest, **changes: Any) -> ExecutionManifest:
|
|
965
|
+
values = {
|
|
966
|
+
"entry_mode": manifest.entry_mode,
|
|
967
|
+
"participant_assignments": manifest.participant_assignments,
|
|
968
|
+
"role_executions": manifest.role_executions,
|
|
969
|
+
"invocations": manifest.invocations,
|
|
970
|
+
"attempts": manifest.attempts,
|
|
971
|
+
"attempt_evidence_seals": manifest.attempt_evidence_seals,
|
|
972
|
+
"mutation_recovery_decisions": manifest.mutation_recovery_decisions,
|
|
973
|
+
}
|
|
974
|
+
values.update(changes)
|
|
975
|
+
return ExecutionManifest(**values)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _write_manifest_over_existing(path: Path, manifest: ExecutionManifest) -> None:
|
|
979
|
+
payload = manifest.to_payload()
|
|
980
|
+
validate_execution_manifest_payload(payload)
|
|
981
|
+
existing = _read_json_object(path)
|
|
982
|
+
existing.update(payload)
|
|
983
|
+
_write_json_atomic(path, existing)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _input_identity_payload(
|
|
987
|
+
manifest: ExecutionManifest,
|
|
988
|
+
dynamic_roles: Sequence[str],
|
|
989
|
+
) -> dict[str, Any]:
|
|
990
|
+
return {
|
|
991
|
+
"schemaVersion": V2_SCHEMA_VERSION,
|
|
992
|
+
"executionIdentityVersion": EXECUTION_IDENTITY_VERSION,
|
|
993
|
+
"staticParticipantAssignments": [
|
|
994
|
+
row.to_payload() for row in manifest.participant_assignments
|
|
995
|
+
],
|
|
996
|
+
"staticRoleExecutions": [row.to_payload() for row in manifest.role_executions],
|
|
997
|
+
"dynamicRolePolicy": {
|
|
998
|
+
"roles": list(dict.fromkeys(dynamic_roles)),
|
|
999
|
+
"appendOnly": True,
|
|
1000
|
+
},
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _assert_input_identity_immutable(
|
|
1005
|
+
existing: Mapping[str, Any],
|
|
1006
|
+
expected: Mapping[str, Any],
|
|
1007
|
+
) -> None:
|
|
1008
|
+
if existing.get("schemaVersion") != V2_SCHEMA_VERSION:
|
|
1009
|
+
return
|
|
1010
|
+
keys = (
|
|
1011
|
+
"executionIdentityVersion",
|
|
1012
|
+
"staticParticipantAssignments",
|
|
1013
|
+
"staticRoleExecutions",
|
|
1014
|
+
"dynamicRolePolicy",
|
|
1015
|
+
)
|
|
1016
|
+
if any(existing.get(key) != expected.get(key) for key in keys):
|
|
1017
|
+
raise ExecutionManifestError("input snapshot is immutable after v2 creation")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _rebuild_task_identity_index_unlocked(
|
|
1021
|
+
task_manifest_path: Path,
|
|
1022
|
+
payload: dict[str, Any],
|
|
1023
|
+
) -> list[dict[str, str]]:
|
|
1024
|
+
existing = _validated_existing_task_index(task_manifest_path, payload)
|
|
1025
|
+
task_root = task_manifest_path.parent
|
|
1026
|
+
reference_root = _index_reference_root(task_root, payload)
|
|
1027
|
+
discovered = _role_execution_index_from_runs(task_root, reference_root)
|
|
1028
|
+
rows = list(existing)
|
|
1029
|
+
known = {(row["runRef"], row["roleExecutionRef"]) for row in rows}
|
|
1030
|
+
for row in discovered:
|
|
1031
|
+
pair = (row["runRef"], row["roleExecutionRef"])
|
|
1032
|
+
if pair not in known:
|
|
1033
|
+
rows.append(row)
|
|
1034
|
+
known.add(pair)
|
|
1035
|
+
payload["schemaVersion"] = V2_SCHEMA_VERSION
|
|
1036
|
+
payload["executionIdentityVersion"] = EXECUTION_IDENTITY_VERSION
|
|
1037
|
+
payload["roleExecutionIndex"] = rows
|
|
1038
|
+
validate_task_role_execution_index(payload)
|
|
1039
|
+
return rows
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _validated_existing_task_index(
|
|
1043
|
+
task_manifest_path: Path,
|
|
1044
|
+
payload: Mapping[str, Any],
|
|
1045
|
+
) -> list[dict[str, str]]:
|
|
1046
|
+
surface = identity_surface_from_payload(payload, surface="task-manifest")
|
|
1047
|
+
if surface.legacy:
|
|
1048
|
+
return []
|
|
1049
|
+
rows = [dict(row) for row in payload["roleExecutionIndex"]]
|
|
1050
|
+
task_root = task_manifest_path.parent
|
|
1051
|
+
reference_root = _index_reference_root(task_root, payload)
|
|
1052
|
+
roles_by_run: dict[str, set[str]] = {}
|
|
1053
|
+
for row in rows:
|
|
1054
|
+
run_ref = row["runRef"]
|
|
1055
|
+
role_ref = row["roleExecutionRef"]
|
|
1056
|
+
if run_ref not in roles_by_run:
|
|
1057
|
+
roles_by_run[run_ref] = _referenced_role_refs(
|
|
1058
|
+
task_root,
|
|
1059
|
+
reference_root,
|
|
1060
|
+
run_ref,
|
|
1061
|
+
)
|
|
1062
|
+
if role_ref not in roles_by_run[run_ref]:
|
|
1063
|
+
raise ExecutionManifestError(
|
|
1064
|
+
"roleExecutionIndex disagrees with referenced run manifest: "
|
|
1065
|
+
f"{run_ref}, {role_ref}"
|
|
1066
|
+
)
|
|
1067
|
+
return rows
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _referenced_role_refs(
|
|
1071
|
+
task_root: Path,
|
|
1072
|
+
reference_root: Path,
|
|
1073
|
+
run_ref: str,
|
|
1074
|
+
) -> set[str]:
|
|
1075
|
+
path = (reference_root / run_ref).resolve()
|
|
1076
|
+
if not path.is_relative_to(task_root.resolve()) or not path.is_file():
|
|
1077
|
+
raise ExecutionManifestError(
|
|
1078
|
+
f"roleExecutionIndex referenced run manifest is missing: {run_ref}"
|
|
1079
|
+
)
|
|
1080
|
+
try:
|
|
1081
|
+
manifest = read_execution_manifest(path)
|
|
1082
|
+
except ExecutionManifestError as exc:
|
|
1083
|
+
raise ExecutionManifestError(
|
|
1084
|
+
f"roleExecutionIndex referenced run manifest is corrupt: {run_ref}"
|
|
1085
|
+
) from exc
|
|
1086
|
+
if manifest.legacy:
|
|
1087
|
+
raise ExecutionManifestError(
|
|
1088
|
+
f"roleExecutionIndex referenced run manifest is corrupt: {run_ref}"
|
|
1089
|
+
)
|
|
1090
|
+
return {row.role_execution_ref for row in manifest.role_executions}
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _validate_run_ref_for_manifest(
|
|
1094
|
+
path: Path,
|
|
1095
|
+
task_manifest_path: Path,
|
|
1096
|
+
task_payload: Mapping[str, Any],
|
|
1097
|
+
run_ref: str,
|
|
1098
|
+
) -> None:
|
|
1099
|
+
task_root = task_manifest_path.parent.resolve()
|
|
1100
|
+
manifest_path = path.resolve()
|
|
1101
|
+
reference_root = _index_reference_root(task_root, task_payload).resolve()
|
|
1102
|
+
try:
|
|
1103
|
+
expected_ref = manifest_path.relative_to(reference_root).as_posix()
|
|
1104
|
+
except ValueError:
|
|
1105
|
+
expected_ref = ""
|
|
1106
|
+
if (
|
|
1107
|
+
not manifest_path.is_relative_to(task_root)
|
|
1108
|
+
or not expected_ref
|
|
1109
|
+
or run_ref != expected_ref
|
|
1110
|
+
):
|
|
1111
|
+
raise ExecutionManifestError(
|
|
1112
|
+
"runRef does not identify execution manifest path"
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _validate_new_task_index_row(
|
|
1117
|
+
manifest: ExecutionManifest,
|
|
1118
|
+
row: RoleExecution,
|
|
1119
|
+
) -> None:
|
|
1120
|
+
payload = manifest.to_payload()
|
|
1121
|
+
validate_execution_manifest_payload(payload)
|
|
1122
|
+
role_refs = {
|
|
1123
|
+
item["roleExecutionRef"]
|
|
1124
|
+
for item in payload["roleExecutions"]
|
|
1125
|
+
}
|
|
1126
|
+
if row.role_execution_ref not in role_refs:
|
|
1127
|
+
raise ExecutionManifestError(
|
|
1128
|
+
"new roleExecutionIndex row is absent from updated run manifest"
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _append_task_index_unlocked(
|
|
1133
|
+
path: Path,
|
|
1134
|
+
run_ref: str,
|
|
1135
|
+
row: RoleExecution,
|
|
1136
|
+
payload: dict[str, Any],
|
|
1137
|
+
) -> None:
|
|
1138
|
+
identity_surface_from_payload(payload, surface="task-manifest")
|
|
1139
|
+
updated = _task_payload_with_role_index(payload, run_ref, row)
|
|
1140
|
+
_write_json_atomic(path, updated)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _task_payload_with_role_index(
|
|
1144
|
+
payload: Mapping[str, Any],
|
|
1145
|
+
run_ref: str,
|
|
1146
|
+
row: RoleExecution,
|
|
1147
|
+
) -> dict[str, Any]:
|
|
1148
|
+
updated = dict(payload)
|
|
1149
|
+
index = [dict(item) for item in payload["roleExecutionIndex"]]
|
|
1150
|
+
item = {"runRef": run_ref, "roleExecutionRef": row.role_execution_ref}
|
|
1151
|
+
if item not in index:
|
|
1152
|
+
index.append(item)
|
|
1153
|
+
updated["schemaVersion"] = V2_SCHEMA_VERSION
|
|
1154
|
+
updated["executionIdentityVersion"] = EXECUTION_IDENTITY_VERSION
|
|
1155
|
+
updated["roleExecutionIndex"] = index
|
|
1156
|
+
validate_task_role_execution_index(updated)
|
|
1157
|
+
return updated
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _write_manifest_and_task_index(
|
|
1161
|
+
manifest_path: Path,
|
|
1162
|
+
manifest: ExecutionManifest,
|
|
1163
|
+
task_manifest_path: Path,
|
|
1164
|
+
task_payload: Mapping[str, Any],
|
|
1165
|
+
) -> None:
|
|
1166
|
+
manifest_payload = _read_json_object(manifest_path)
|
|
1167
|
+
manifest_payload.update(manifest.to_payload())
|
|
1168
|
+
_write_json_atomic(manifest_path, manifest_payload)
|
|
1169
|
+
_write_json_atomic(task_manifest_path, task_payload)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _index_reference_root(
|
|
1173
|
+
task_root: Path,
|
|
1174
|
+
task_manifest: Mapping[str, Any],
|
|
1175
|
+
) -> Path:
|
|
1176
|
+
value = task_manifest.get("projectRoot")
|
|
1177
|
+
if not isinstance(value, str) or not value.strip():
|
|
1178
|
+
return task_root.resolve()
|
|
1179
|
+
project_root = Path(value).resolve()
|
|
1180
|
+
if not task_root.resolve().is_relative_to(project_root):
|
|
1181
|
+
raise ExecutionManifestError(
|
|
1182
|
+
"task root resolves outside project root"
|
|
1183
|
+
)
|
|
1184
|
+
return project_root
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _role_execution_index_from_runs(
|
|
1188
|
+
task_root: Path,
|
|
1189
|
+
reference_root: Path,
|
|
1190
|
+
) -> list[dict[str, str]]:
|
|
1191
|
+
rows: list[dict[str, str]] = []
|
|
1192
|
+
resolved_task_root = task_root.resolve()
|
|
1193
|
+
resolved_reference_root = reference_root.resolve()
|
|
1194
|
+
for path in sorted(task_root.glob("runs/**/manifests/run-manifest-*.json")):
|
|
1195
|
+
resolved_path = path.resolve()
|
|
1196
|
+
if (
|
|
1197
|
+
not resolved_path.is_relative_to(resolved_task_root)
|
|
1198
|
+
or not resolved_path.is_relative_to(resolved_reference_root)
|
|
1199
|
+
):
|
|
1200
|
+
raise ExecutionManifestError(
|
|
1201
|
+
"discovered run manifest resolves outside task or project root"
|
|
1202
|
+
)
|
|
1203
|
+
surface = read_identity_surface(resolved_path, surface="run-manifest")
|
|
1204
|
+
if surface.legacy:
|
|
1205
|
+
continue
|
|
1206
|
+
validate_execution_manifest_payload(surface.payload)
|
|
1207
|
+
run_ref = resolved_path.relative_to(resolved_reference_root).as_posix()
|
|
1208
|
+
rows.extend(
|
|
1209
|
+
{
|
|
1210
|
+
"runRef": run_ref,
|
|
1211
|
+
"roleExecutionRef": str(role["roleExecutionRef"]),
|
|
1212
|
+
}
|
|
1213
|
+
for role in surface.payload["roleExecutions"]
|
|
1214
|
+
)
|
|
1215
|
+
return rows
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _indexed_rows(rows: object, key: str) -> dict[str, Mapping[str, Any]]:
|
|
1219
|
+
if not isinstance(rows, list):
|
|
1220
|
+
raise ExecutionManifestError(f"execution manifest {key} rows must be an array")
|
|
1221
|
+
indexed: dict[str, Mapping[str, Any]] = {}
|
|
1222
|
+
for row in rows:
|
|
1223
|
+
if not isinstance(row, Mapping):
|
|
1224
|
+
raise ExecutionManifestError(f"execution manifest {key} row must be an object")
|
|
1225
|
+
value = _required_text(row, key)
|
|
1226
|
+
if value in indexed:
|
|
1227
|
+
raise ExecutionManifestError(f"{key} is duplicated: {value}")
|
|
1228
|
+
indexed[value] = row
|
|
1229
|
+
return indexed
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _required_text(row: Mapping[str, Any], key: str) -> str:
|
|
1233
|
+
value = row.get(key)
|
|
1234
|
+
if not isinstance(value, str) or not value.strip():
|
|
1235
|
+
raise ExecutionManifestError(f"{key} must be a non-empty string")
|
|
1236
|
+
return value
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
1240
|
+
try:
|
|
1241
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1242
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1243
|
+
raise ExecutionManifestError(f"cannot read JSON object: {path}") from exc
|
|
1244
|
+
if not isinstance(payload, dict):
|
|
1245
|
+
raise ExecutionManifestError(f"JSON surface must be an object: {path}")
|
|
1246
|
+
return payload
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _write_json_atomic(
|
|
1250
|
+
path: Path,
|
|
1251
|
+
payload: Mapping[str, Any],
|
|
1252
|
+
*,
|
|
1253
|
+
create_only: bool = False,
|
|
1254
|
+
) -> None:
|
|
1255
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1256
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
1257
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
1258
|
+
)
|
|
1259
|
+
temporary = Path(temporary_name)
|
|
1260
|
+
try:
|
|
1261
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
1262
|
+
json.dump(payload, stream, indent=2, ensure_ascii=False)
|
|
1263
|
+
stream.write("\n")
|
|
1264
|
+
stream.flush()
|
|
1265
|
+
os.fsync(stream.fileno())
|
|
1266
|
+
if create_only:
|
|
1267
|
+
try:
|
|
1268
|
+
os.link(temporary, path)
|
|
1269
|
+
except FileExistsError as exc:
|
|
1270
|
+
raise ExecutionManifestError(
|
|
1271
|
+
f"execution manifest already exists: {path}"
|
|
1272
|
+
) from exc
|
|
1273
|
+
else:
|
|
1274
|
+
os.replace(temporary, path)
|
|
1275
|
+
finally:
|
|
1276
|
+
temporary.unlink(missing_ok=True)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _optional_text(value: object) -> str | None:
|
|
1280
|
+
text = str(value or "").strip()
|
|
1281
|
+
return text or None
|