okstra 0.179.2 → 0.183.2
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 +1 -1
- package/dist/cli-registry.mjs +30 -2
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.d.mts +4 -2
- package/dist/commands/execute/render-bundle.mjs +46 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/run.mjs +11 -3
- package/dist/commands/execute/run.mjs.map +1 -1
- package/dist/commands/inspect/model-io.d.mts +1 -0
- package/dist/commands/inspect/model-io.mjs +25 -0
- package/dist/commands/inspect/model-io.mjs.map +1 -0
- package/dist/commands/inspect/stage-map.mjs +29 -8
- package/dist/commands/inspect/stage-map.mjs.map +1 -1
- package/dist/commands/inspect/task-list.mjs +52 -6
- package/dist/commands/inspect/task-list.mjs.map +1 -1
- package/dist/commands/inspect/user-response.mjs +14 -4
- package/dist/commands/inspect/user-response.mjs.map +1 -1
- package/dist/commands/lifecycle/check-project.d.mts +1 -0
- package/dist/commands/lifecycle/check-project.mjs +69 -50
- package/dist/commands/lifecycle/check-project.mjs.map +1 -1
- package/dist/commands/lifecycle/contract-check.d.mts +1 -0
- package/dist/commands/lifecycle/contract-check.mjs +18 -0
- package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
- package/dist/commands/lifecycle/preflight.mjs +154 -51
- package/dist/commands/lifecycle/preflight.mjs.map +1 -1
- package/dist/commands/pr/pr.d.mts +1 -0
- package/dist/commands/pr/pr.mjs +19 -1
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/dist/commands/report/agent-activity.mjs +2 -2
- package/dist/commands/report/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- package/dist/commands/report/translate.mjs +3 -0
- package/dist/commands/report/translate.mjs.map +1 -1
- package/dist/lib/host-registry-client.mjs +13 -9
- package/dist/lib/host-registry-client.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +21 -10
- package/docs/cli.md +36 -20
- package/docs/container.md +6 -4
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
- package/docs/for-ai/skills/okstra-code-review.md +4 -4
- package/docs/for-ai/skills/okstra-container-build.md +20 -17
- package/docs/for-ai/skills/okstra-inspect.md +20 -23
- package/docs/for-ai/skills/okstra-manager.md +19 -18
- package/docs/for-ai/skills/okstra-memory.md +2 -2
- package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
- package/docs/for-ai/skills/okstra-rollup.md +14 -13
- package/docs/for-ai/skills/okstra-run.md +7 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
- package/docs/for-ai/skills/okstra-setup.md +7 -7
- package/docs/for-ai/skills/okstra-usage.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +50 -32
- package/docs/project-structure-overview.md +43 -31
- package/docs/task-process/implementation-planning.md +2 -2
- package/docs/task-process/implementation.md +7 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/agents/workers/translator-worker.md +5 -13
- package/runtime/bin/okstra-error-log.py +51 -11
- package/runtime/bin/okstra-report-translate.py +210 -23
- package/runtime/prompts/host-orchestration/implementation.md +1 -1
- package/runtime/prompts/launch.template.md +9 -12
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +10 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
- package/runtime/prompts/lead/plan-body-verification.md +217 -23
- package/runtime/prompts/lead/report-writer.md +81 -421
- package/runtime/prompts/lead/team-contract.md +9 -54
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +20 -12
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +0 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
- package/runtime/python/okstra_ctl/agent_activity.py +141 -38
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
- package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
- package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
- package/runtime/python/okstra_ctl/backfill.py +13 -10
- package/runtime/python/okstra_ctl/batch.py +2 -4
- package/runtime/python/okstra_ctl/build_tools.py +6 -3
- package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
- package/runtime/python/okstra_ctl/clarification_items.py +27 -13
- package/runtime/python/okstra_ctl/cmux.py +130 -52
- package/runtime/python/okstra_ctl/code_review_target.py +34 -8
- package/runtime/python/okstra_ctl/consumers.py +5 -4
- package/runtime/python/okstra_ctl/container.py +103 -8
- package/runtime/python/okstra_ctl/context_cost.py +2 -1
- package/runtime/python/okstra_ctl/contract_graph.py +497 -0
- package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
- package/runtime/python/okstra_ctl/convergence.py +338 -17
- package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
- package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
- package/runtime/python/okstra_ctl/convergence_store.py +55 -34
- package/runtime/python/okstra_ctl/design_prep.py +7 -4
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
- package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
- package/runtime/python/okstra_ctl/doctor.py +6 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
- package/runtime/python/okstra_ctl/error_log_write.py +35 -30
- package/runtime/python/okstra_ctl/error_report.py +26 -1
- package/runtime/python/okstra_ctl/error_zip.py +27 -5
- package/runtime/python/okstra_ctl/execution_identity.py +3 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
- package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
- package/runtime/python/okstra_ctl/fixed_text.py +39 -0
- package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
- package/runtime/python/okstra_ctl/handoff.py +5 -4
- package/runtime/python/okstra_ctl/i18n.py +4 -2
- package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
- package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
- package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
- package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
- package/runtime/python/okstra_ctl/index.py +8 -11
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
- package/runtime/python/okstra_ctl/invocation.py +3 -6
- package/runtime/python/okstra_ctl/json_boundary.py +366 -0
- package/runtime/python/okstra_ctl/json_registry.py +10 -12
- package/runtime/python/okstra_ctl/jsonl.py +19 -2
- package/runtime/python/okstra_ctl/lead_events.py +33 -1
- package/runtime/python/okstra_ctl/listing.py +3 -3
- package/runtime/python/okstra_ctl/log_report.py +24 -2
- package/runtime/python/okstra_ctl/manager_cli.py +92 -7
- package/runtime/python/okstra_ctl/manager_store.py +12 -10
- package/runtime/python/okstra_ctl/material.py +5 -1
- package/runtime/python/okstra_ctl/migrate.py +29 -25
- package/runtime/python/okstra_ctl/model_cli.py +3 -15
- package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
- package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +21 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/pr_template.py +3 -2
- package/runtime/python/okstra_ctl/project_meta.py +5 -7
- package/runtime/python/okstra_ctl/recap.py +5 -4
- package/runtime/python/okstra_ctl/reconcile.py +21 -27
- package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
- package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
- package/runtime/python/okstra_ctl/render.py +74 -16
- package/runtime/python/okstra_ctl/render_final_report.py +10 -8
- package/runtime/python/okstra_ctl/report_assembly.py +509 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +67 -32
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +81 -8
- package/runtime/python/okstra_ctl/report_narrative.py +351 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
- package/runtime/python/okstra_ctl/report_views.py +3 -2
- package/runtime/python/okstra_ctl/rollup.py +65 -4
- package/runtime/python/okstra_ctl/run.py +184 -65
- package/runtime/python/okstra_ctl/run_audit.py +3 -2
- package/runtime/python/okstra_ctl/run_context.py +6 -9
- package/runtime/python/okstra_ctl/run_index_row.py +2 -8
- package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
- package/runtime/python/okstra_ctl/session_transcript.py +27 -1
- package/runtime/python/okstra_ctl/set_work_status.py +64 -38
- package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +96 -28
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +3 -2
- package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
- package/runtime/python/okstra_ctl/time_report.py +51 -4
- package/runtime/python/okstra_ctl/usage_identity.py +2 -1
- package/runtime/python/okstra_ctl/usage_report.py +58 -4
- package/runtime/python/okstra_ctl/user_response.py +1431 -66
- package/runtime/python/okstra_ctl/wizard.py +11 -44
- package/runtime/python/okstra_ctl/work_categories.py +3 -2
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/python/okstra_ctl/worker_runner.py +14 -12
- package/runtime/python/okstra_ctl/workflow.py +2 -1
- package/runtime/python/okstra_ctl/worktree.py +3 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
- package/runtime/python/okstra_ctl/write_policy.py +4 -2
- package/runtime/python/okstra_token_usage/antigravity.py +39 -12
- package/runtime/python/okstra_token_usage/collect.py +90 -38
- package/runtime/python/okstra_token_usage/grok.py +127 -0
- package/runtime/schemas/final-report-v2.0.schema.json +9 -0
- package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
- package/runtime/skills/okstra-code-review/SKILL.md +21 -11
- package/runtime/skills/okstra-container-build/SKILL.md +18 -18
- package/runtime/skills/okstra-inspect/SKILL.md +12 -11
- package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
- package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
- package/runtime/skills/okstra-inspect/facets/history.md +9 -14
- package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
- package/runtime/skills/okstra-inspect/facets/report.md +6 -10
- package/runtime/skills/okstra-inspect/facets/status.md +9 -8
- package/runtime/skills/okstra-inspect/facets/time.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +16 -14
- package/runtime/skills/okstra-memory/SKILL.md +3 -3
- package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
- package/runtime/skills/okstra-rollup/SKILL.md +6 -16
- package/runtime/skills/okstra-run/SKILL.md +7 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
- package/runtime/skills/okstra-setup/SKILL.md +21 -13
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/skills/okstra-usage/SKILL.md +10 -10
- package/runtime/skills/okstra-user-response/SKILL.md +78 -107
- package/runtime/templates/report-writer-prompt-preamble.md +29 -19
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +636 -72
- package/runtime/validators/validate_session_conformance.py +43 -7
|
@@ -24,6 +24,13 @@ from .agent_invocation import (
|
|
|
24
24
|
)
|
|
25
25
|
from .path_hints import hydrate_active_run_context
|
|
26
26
|
from .report_language import resolve_report_language
|
|
27
|
+
from .report_inputs import report_narrative_path, uses_report_contract_v3
|
|
28
|
+
from .report_synthesis_packet import (
|
|
29
|
+
ReportSynthesisPacketError,
|
|
30
|
+
materialize_report_synthesis_packet,
|
|
31
|
+
report_synthesis_packet_paths,
|
|
32
|
+
)
|
|
33
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
27
34
|
from .worker_prompt_body import (
|
|
28
35
|
REPORT_WRITER_WORKER_ID,
|
|
29
36
|
analysis_prompt_body,
|
|
@@ -444,7 +451,17 @@ def _render_prompt_parts(
|
|
|
444
451
|
f"**Prompt Delivery Mode:** {context.request.delivery_mode.value}"
|
|
445
452
|
)
|
|
446
453
|
anchors.extend(_implementation_anchor_lines(context, item.plan))
|
|
447
|
-
|
|
454
|
+
synthesis_packet_path = _materialize_report_writer_packet(
|
|
455
|
+
context,
|
|
456
|
+
item.plan,
|
|
457
|
+
)
|
|
458
|
+
body = _prompt_body(
|
|
459
|
+
context,
|
|
460
|
+
state,
|
|
461
|
+
worker,
|
|
462
|
+
item.plan,
|
|
463
|
+
synthesis_packet_path,
|
|
464
|
+
)
|
|
448
465
|
body.extend(_resource_lines(context, item))
|
|
449
466
|
except InitialPromptMaterializationError:
|
|
450
467
|
raise
|
|
@@ -503,7 +520,7 @@ def _agent_invocation_request(
|
|
|
503
520
|
f"requested model does not match {assignment_ref}",
|
|
504
521
|
)
|
|
505
522
|
anchors, body = _render_prompt_parts(context, item)
|
|
506
|
-
source_paths =
|
|
523
|
+
source_paths = [
|
|
507
524
|
AgentInstructionSource(
|
|
508
525
|
"project",
|
|
509
526
|
_project_relative(context.project_root, context.request.run_manifest_path),
|
|
@@ -514,7 +531,10 @@ def _agent_invocation_request(
|
|
|
514
531
|
AgentInstructionSource(
|
|
515
532
|
"project", _required_string(context.manifest, "teamStatePath")
|
|
516
533
|
),
|
|
517
|
-
|
|
534
|
+
]
|
|
535
|
+
packet_source = _report_writer_packet_source(context, item.plan)
|
|
536
|
+
if packet_source is not None:
|
|
537
|
+
source_paths.append(packet_source)
|
|
518
538
|
invocation_id = _worker_invocation_id(context.manifest, item.worker.worker_id)
|
|
519
539
|
return AgentInvocationRequest(
|
|
520
540
|
invocation_id=invocation_id,
|
|
@@ -526,7 +546,7 @@ def _agent_invocation_request(
|
|
|
526
546
|
instruction=AgentInstruction(
|
|
527
547
|
anchor_lines=tuple(anchors),
|
|
528
548
|
body="\n".join(body).rstrip() + "\n",
|
|
529
|
-
source_paths=source_paths,
|
|
549
|
+
source_paths=tuple(source_paths),
|
|
530
550
|
),
|
|
531
551
|
project_root=context.project_root,
|
|
532
552
|
run_manifest_path=context.request.run_manifest_path,
|
|
@@ -549,6 +569,7 @@ def _prompt_body(
|
|
|
549
569
|
state: Mapping[str, Any],
|
|
550
570
|
worker: InitialPromptWorkerRequest,
|
|
551
571
|
plan: PromptPlan,
|
|
572
|
+
synthesis_packet_path: str = "",
|
|
552
573
|
) -> list[str]:
|
|
553
574
|
if plan.audience == "report-writer":
|
|
554
575
|
return report_writer_prompt_body(
|
|
@@ -561,6 +582,7 @@ def _prompt_body(
|
|
|
561
582
|
context.manifest,
|
|
562
583
|
context.active_context,
|
|
563
584
|
),
|
|
585
|
+
synthesis_packet_path,
|
|
564
586
|
)
|
|
565
587
|
return analysis_prompt_body(
|
|
566
588
|
context.manifest,
|
|
@@ -571,6 +593,56 @@ def _prompt_body(
|
|
|
571
593
|
)
|
|
572
594
|
|
|
573
595
|
|
|
596
|
+
def _report_writer_narrative_path(context: _MaterializationContext) -> Path:
|
|
597
|
+
return report_narrative_path(context.project_root, context.manifest)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _report_writer_packet_source(
|
|
601
|
+
context: _MaterializationContext,
|
|
602
|
+
plan: PromptPlan,
|
|
603
|
+
) -> AgentInstructionSource | None:
|
|
604
|
+
if (
|
|
605
|
+
plan.audience != "report-writer"
|
|
606
|
+
or not uses_report_contract_v3(context.manifest)
|
|
607
|
+
):
|
|
608
|
+
return None
|
|
609
|
+
_, markdown_path = report_synthesis_packet_paths(
|
|
610
|
+
_report_writer_narrative_path(context)
|
|
611
|
+
)
|
|
612
|
+
return AgentInstructionSource(
|
|
613
|
+
"project",
|
|
614
|
+
_project_relative(context.project_root, markdown_path),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _materialize_report_writer_packet(
|
|
619
|
+
context: _MaterializationContext,
|
|
620
|
+
plan: PromptPlan,
|
|
621
|
+
) -> str:
|
|
622
|
+
packet_source = _report_writer_packet_source(context, plan)
|
|
623
|
+
if packet_source is None:
|
|
624
|
+
return ""
|
|
625
|
+
try:
|
|
626
|
+
_, markdown_path = materialize_report_synthesis_packet(
|
|
627
|
+
project_root=context.project_root,
|
|
628
|
+
manifest=context.manifest,
|
|
629
|
+
active_context=context.active_context,
|
|
630
|
+
team_state=context.team_state,
|
|
631
|
+
narrative_path=_report_writer_narrative_path(context),
|
|
632
|
+
)
|
|
633
|
+
except ReportSynthesisPacketError as exc:
|
|
634
|
+
defects = "; ".join(
|
|
635
|
+
f"owner={issue.owner} source={issue.label} path={issue.path} "
|
|
636
|
+
f"reason={issue.reason}"
|
|
637
|
+
for issue in exc.issues
|
|
638
|
+
)
|
|
639
|
+
raise InitialPromptMaterializationError(
|
|
640
|
+
"required_input_missing",
|
|
641
|
+
f"report synthesis packet contract defects: {defects}",
|
|
642
|
+
) from exc
|
|
643
|
+
return _project_relative(context.project_root, markdown_path)
|
|
644
|
+
|
|
645
|
+
|
|
574
646
|
def _resource_lines(
|
|
575
647
|
context: _MaterializationContext,
|
|
576
648
|
item: _PromptItem,
|
|
@@ -1325,12 +1397,12 @@ def _load_referenced_json(
|
|
|
1325
1397
|
|
|
1326
1398
|
def _load_json_object(path: Path, label: str) -> Mapping[str, Any]:
|
|
1327
1399
|
try:
|
|
1328
|
-
payload =
|
|
1329
|
-
except
|
|
1400
|
+
payload = load_owned_object(path, artifact=label)
|
|
1401
|
+
except JsonBoundaryError as exc:
|
|
1330
1402
|
raise InitialPromptMaterializationError(
|
|
1331
1403
|
"required_input_missing",
|
|
1332
1404
|
f"cannot load {label}: {path}: {exc}",
|
|
1333
|
-
) from exc
|
|
1405
|
+
) from (exc.__cause__ or exc)
|
|
1334
1406
|
if not isinstance(payload, dict):
|
|
1335
1407
|
raise InitialPromptMaterializationError(
|
|
1336
1408
|
"required_input_missing",
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
9
|
from .ids import _safe_fs_segment
|
|
10
|
+
from .json_boundary import load_owned_object, write_owned_object_atomic
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def invocation_path(home: Path, project_id: str,
|
|
@@ -32,11 +33,7 @@ def save_invocation(home: Path, project_id: str,
|
|
|
32
33
|
run_seq: int, payload: dict) -> None:
|
|
33
34
|
"""invocation 을 원자적으로 저장(임시파일 + os.replace)."""
|
|
34
35
|
target = invocation_path(home, project_id, task_group, task_id, task_type, run_seq)
|
|
35
|
-
target
|
|
36
|
-
tmp = target.with_suffix(".json.tmp")
|
|
37
|
-
tmp.write_text(json.dumps(payload, indent=2) + "\n")
|
|
38
|
-
os.chmod(tmp, 0o600)
|
|
39
|
-
os.replace(tmp, target)
|
|
36
|
+
write_owned_object_atomic(target, payload, artifact="invocation metadata")
|
|
40
37
|
|
|
41
38
|
|
|
42
39
|
def load_invocation(home: Path, project_id: str,
|
|
@@ -46,4 +43,4 @@ def load_invocation(home: Path, project_id: str,
|
|
|
46
43
|
target = invocation_path(home, project_id, task_group, task_id, task_type, run_seq)
|
|
47
44
|
if not target.is_file():
|
|
48
45
|
return None
|
|
49
|
-
return
|
|
46
|
+
return load_owned_object(target, artifact="invocation metadata")
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Strict JSON boundaries for okstra-owned artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .final_report_schema import SchemaError, validate as validate_schema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonBoundaryError(ValueError):
|
|
16
|
+
"""An owned JSON artifact could not satisfy its persistence contract."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path, artifact: str, reason: str) -> None:
|
|
19
|
+
self.path = path
|
|
20
|
+
self.artifact = artifact
|
|
21
|
+
self.reason = reason
|
|
22
|
+
super().__init__(f"{artifact}: {reason}: {path}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_EXTERNAL_SOURCE_SEAL = object()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, init=False)
|
|
29
|
+
class ExternalJsonSource:
|
|
30
|
+
"""검증된 외부 생산자가 소유한 JSON 입력 경로다."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
producer: str
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: Path, producer: str, seal: object = None) -> None:
|
|
36
|
+
if seal is not _EXTERNAL_SOURCE_SEAL:
|
|
37
|
+
raise TypeError("ExternalJsonSource is sealed")
|
|
38
|
+
object.__setattr__(self, "path", path)
|
|
39
|
+
object.__setattr__(self, "producer", producer)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class OwnedJsonSnapshot:
|
|
44
|
+
"""한 번 읽은 원시 바이트와 그 바이트에서 검증한 객체다."""
|
|
45
|
+
|
|
46
|
+
value: dict[str, Any]
|
|
47
|
+
raw_bytes: bytes
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _issue_external_source(path: Path, producer: str) -> ExternalJsonSource:
|
|
51
|
+
if not isinstance(path, Path):
|
|
52
|
+
raise TypeError("external JSON source requires Path")
|
|
53
|
+
return ExternalJsonSource(path, producer, _EXTERNAL_SOURCE_SEAL)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_okstra_owned_path(path: Path) -> bool:
|
|
57
|
+
lexical = Path(os.path.abspath(path))
|
|
58
|
+
resolved = path.resolve(strict=False)
|
|
59
|
+
return ".okstra" in lexical.parts or ".okstra" in resolved.parts
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def external_user_json_source(path: Path) -> ExternalJsonSource:
|
|
63
|
+
"""사용자 제공 JSON이 Okstra 소유 트리 밖에 있을 때만 승인한다."""
|
|
64
|
+
if _is_okstra_owned_path(path):
|
|
65
|
+
raise JsonBoundaryError(path, "external JSON", "Okstra-owned path")
|
|
66
|
+
return _issue_external_source(path, "user")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def external_worker_json_source(
|
|
70
|
+
path: Path, *, trusted_root: Path, lane_root: Path
|
|
71
|
+
) -> ExternalJsonSource:
|
|
72
|
+
"""신뢰 루트 아래의 정확한 worker 결과 lane만 승인한다."""
|
|
73
|
+
lexical_root = Path(os.path.abspath(trusted_root))
|
|
74
|
+
lexical_lane = Path(os.path.abspath(lane_root))
|
|
75
|
+
lexical_path = Path(os.path.abspath(path))
|
|
76
|
+
try:
|
|
77
|
+
lane_parts = lexical_lane.relative_to(lexical_root).parts
|
|
78
|
+
except ValueError:
|
|
79
|
+
lane_parts = ()
|
|
80
|
+
is_worker_lane = (
|
|
81
|
+
len(lane_parts) in {7, 8}
|
|
82
|
+
and lane_parts[:2] == (".okstra", "tasks")
|
|
83
|
+
and lane_parts[4] == "runs"
|
|
84
|
+
and lane_parts[-1] == "worker-results"
|
|
85
|
+
and (len(lane_parts) == 7 or lane_parts[6].startswith("stage-"))
|
|
86
|
+
)
|
|
87
|
+
if not is_worker_lane or lexical_path.parent != lexical_lane:
|
|
88
|
+
raise JsonBoundaryError(
|
|
89
|
+
path, "worker JSON", "outside worker result lanes"
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
lexical_lane.relative_to(lexical_root)
|
|
93
|
+
lexical_path.relative_to(lexical_lane)
|
|
94
|
+
resolved_lane = lane_root.resolve(strict=False)
|
|
95
|
+
resolved_lane.relative_to(trusted_root.resolve(strict=False))
|
|
96
|
+
path.resolve(strict=False).relative_to(resolved_lane)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise JsonBoundaryError(
|
|
99
|
+
path, "worker JSON", "outside worker result lanes"
|
|
100
|
+
) from exc
|
|
101
|
+
if _has_symlink_component(lexical_lane, lexical_root) or _has_symlink_component(
|
|
102
|
+
lexical_path, lexical_lane
|
|
103
|
+
):
|
|
104
|
+
raise JsonBoundaryError(path, "worker JSON", "outside worker result lanes")
|
|
105
|
+
return _issue_external_source(path, "worker")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def external_invocation_json_source(
|
|
109
|
+
path: Path, *, project_root: Path, purpose: str
|
|
110
|
+
) -> ExternalJsonSource:
|
|
111
|
+
"""정확한 standalone purpose lane의 직접 결과만 승인한다."""
|
|
112
|
+
if not purpose or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789-" for char in purpose):
|
|
113
|
+
raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
|
|
114
|
+
lane = project_root / ".okstra" / "agent-invocations" / purpose
|
|
115
|
+
lexical_path = Path(os.path.abspath(path))
|
|
116
|
+
lexical_lane = Path(os.path.abspath(lane))
|
|
117
|
+
lexical_root = Path(os.path.abspath(project_root))
|
|
118
|
+
if lexical_path.parent != lexical_lane or path.is_symlink():
|
|
119
|
+
raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
|
|
120
|
+
try:
|
|
121
|
+
lane.resolve(strict=False).relative_to(project_root.resolve(strict=False))
|
|
122
|
+
path.resolve(strict=False).relative_to(lane.resolve(strict=False))
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise JsonBoundaryError(
|
|
125
|
+
path, "invocation JSON", "outside invocation result lane"
|
|
126
|
+
) from exc
|
|
127
|
+
if _has_symlink_component(lexical_lane, lexical_root):
|
|
128
|
+
raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
|
|
129
|
+
return _issue_external_source(path, "standalone worker")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _has_symlink_component(path: Path, root: Path) -> bool:
|
|
133
|
+
current = path
|
|
134
|
+
while current != root:
|
|
135
|
+
if current.is_symlink():
|
|
136
|
+
return True
|
|
137
|
+
if current.parent == current:
|
|
138
|
+
return True
|
|
139
|
+
current = current.parent
|
|
140
|
+
return root.is_symlink()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def external_claude_global_json_source(
|
|
144
|
+
path: Path, home_dir: Path
|
|
145
|
+
) -> ExternalJsonSource:
|
|
146
|
+
"""호스트 어댑터가 지정한 home의 전역 Claude 설정만 승인한다."""
|
|
147
|
+
expected = home_dir / ".claude.json"
|
|
148
|
+
if Path(os.path.abspath(path)) != Path(os.path.abspath(expected)) or path.is_symlink():
|
|
149
|
+
raise JsonBoundaryError(path, "Claude JSON", "outside fixed adapter path")
|
|
150
|
+
return _issue_external_source(path, "Claude Code")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def external_claude_team_json_source(path: Path) -> ExternalJsonSource:
|
|
154
|
+
"""현재 사용자의 Claude team roster 설정만 승인한다."""
|
|
155
|
+
root = Path.home() / ".claude" / "teams"
|
|
156
|
+
try:
|
|
157
|
+
Path(os.path.abspath(path)).relative_to(Path(os.path.abspath(root)))
|
|
158
|
+
path.resolve(strict=False).relative_to(root.resolve(strict=False))
|
|
159
|
+
except ValueError as exc:
|
|
160
|
+
raise JsonBoundaryError(path, "Claude JSON", "outside Claude team root") from exc
|
|
161
|
+
if path.name != "config.json" or path.is_symlink():
|
|
162
|
+
raise JsonBoundaryError(path, "Claude JSON", "outside Claude team root")
|
|
163
|
+
return _issue_external_source(path, "Claude Code")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def external_tool_json_source(path: Path, workspace_root: Path) -> ExternalJsonSource:
|
|
167
|
+
"""외부 도구가 workspace의 비-Okstra 경로에 쓴 보고서만 승인한다."""
|
|
168
|
+
try:
|
|
169
|
+
path.resolve(strict=False).relative_to(workspace_root.resolve(strict=False))
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
raise JsonBoundaryError(path, "tool JSON", "outside tool workspace") from exc
|
|
172
|
+
if _is_okstra_owned_path(path):
|
|
173
|
+
raise JsonBoundaryError(path, "tool JSON", "Okstra-owned path")
|
|
174
|
+
return _issue_external_source(path, "external tool")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def external_error_sidecar_source(path: Path) -> ExternalJsonSource:
|
|
178
|
+
"""worker-results lane 또는 Okstra 밖 레거시 sidecar만 승인한다."""
|
|
179
|
+
if _is_okstra_owned_path(path):
|
|
180
|
+
raise JsonBoundaryError(
|
|
181
|
+
path, "worker error sidecar", "trusted worker lane is required"
|
|
182
|
+
)
|
|
183
|
+
return _issue_external_source(path, "legacy automation")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _NonstandardJsonConstant(ValueError):
|
|
187
|
+
"""Raised by ``json.loads`` when a non-standard numeric token appears."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_owned_object(
|
|
191
|
+
path: Path,
|
|
192
|
+
*,
|
|
193
|
+
artifact: str,
|
|
194
|
+
schema: Mapping[str, Any] | None = None,
|
|
195
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
"""Load and validate one okstra-owned JSON object."""
|
|
198
|
+
return load_owned_object_snapshot(
|
|
199
|
+
path,
|
|
200
|
+
artifact=artifact,
|
|
201
|
+
schema=schema,
|
|
202
|
+
validate_cross_fields=validate_cross_fields,
|
|
203
|
+
).value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_owned_object_snapshot(
|
|
207
|
+
path: Path,
|
|
208
|
+
*,
|
|
209
|
+
artifact: str,
|
|
210
|
+
schema: Mapping[str, Any] | None = None,
|
|
211
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
212
|
+
) -> OwnedJsonSnapshot:
|
|
213
|
+
"""한 번 읽은 바이트에서 객체를 파싱·검증해 함께 반환한다."""
|
|
214
|
+
try:
|
|
215
|
+
raw_bytes = path.read_bytes()
|
|
216
|
+
value = json.loads(
|
|
217
|
+
raw_bytes.decode("utf-8"),
|
|
218
|
+
parse_constant=_reject_nonstandard_json_constant,
|
|
219
|
+
)
|
|
220
|
+
except FileNotFoundError as exc:
|
|
221
|
+
raise JsonBoundaryError(path, artifact, "missing") from exc
|
|
222
|
+
except UnicodeDecodeError as exc:
|
|
223
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON encoding") from exc
|
|
224
|
+
except _NonstandardJsonConstant as exc:
|
|
225
|
+
raise JsonBoundaryError(path, artifact, str(exc)) from exc
|
|
226
|
+
except json.JSONDecodeError as exc:
|
|
227
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON") from exc
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while reading ({exc})") from exc
|
|
230
|
+
return OwnedJsonSnapshot(
|
|
231
|
+
value=_validate_owned(
|
|
232
|
+
value,
|
|
233
|
+
path=path,
|
|
234
|
+
artifact=artifact,
|
|
235
|
+
schema=schema,
|
|
236
|
+
validate_cross_fields=validate_cross_fields,
|
|
237
|
+
),
|
|
238
|
+
raw_bytes=raw_bytes,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load_external_json(
|
|
243
|
+
source: ExternalJsonSource,
|
|
244
|
+
*,
|
|
245
|
+
artifact: str,
|
|
246
|
+
object_pairs_hook: Callable[[list[tuple[str, Any]]], Any] | None = None,
|
|
247
|
+
) -> Any:
|
|
248
|
+
"""Load external JSON without applying an owned-artifact contract."""
|
|
249
|
+
if not isinstance(source, ExternalJsonSource):
|
|
250
|
+
raise TypeError("load_external_json requires ExternalJsonSource")
|
|
251
|
+
path = source.path
|
|
252
|
+
try:
|
|
253
|
+
return json.loads(
|
|
254
|
+
path.read_text(encoding="utf-8"),
|
|
255
|
+
object_pairs_hook=object_pairs_hook,
|
|
256
|
+
)
|
|
257
|
+
except FileNotFoundError as exc:
|
|
258
|
+
raise JsonBoundaryError(path, artifact, "missing") from exc
|
|
259
|
+
except UnicodeDecodeError as exc:
|
|
260
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON encoding") from exc
|
|
261
|
+
except json.JSONDecodeError as exc:
|
|
262
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON") from exc
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while reading ({exc})") from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def write_owned_object_atomic(
|
|
268
|
+
path: Path,
|
|
269
|
+
payload: Mapping[str, Any],
|
|
270
|
+
*,
|
|
271
|
+
artifact: str,
|
|
272
|
+
schema: Mapping[str, Any] | None = None,
|
|
273
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Validate a payload before atomically replacing an owned artifact."""
|
|
276
|
+
serialized = serialize_owned_object(
|
|
277
|
+
path,
|
|
278
|
+
payload,
|
|
279
|
+
artifact=artifact,
|
|
280
|
+
schema=schema,
|
|
281
|
+
validate_cross_fields=validate_cross_fields,
|
|
282
|
+
)
|
|
283
|
+
try:
|
|
284
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
descriptor, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
286
|
+
except OSError as exc:
|
|
287
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while preparing write ({exc})") from exc
|
|
288
|
+
temporary = Path(raw_temp)
|
|
289
|
+
try:
|
|
290
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
291
|
+
handle.write(serialized)
|
|
292
|
+
handle.flush()
|
|
293
|
+
os.fsync(handle.fileno())
|
|
294
|
+
os.replace(temporary, path)
|
|
295
|
+
except (TypeError, ValueError) as exc:
|
|
296
|
+
raise JsonBoundaryError(path, artifact, f"JSON serialization failed ({exc})") from exc
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while writing ({exc})") from exc
|
|
299
|
+
finally:
|
|
300
|
+
temporary.unlink(missing_ok=True)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def serialize_owned_object(
|
|
304
|
+
path: Path,
|
|
305
|
+
payload: Mapping[str, Any],
|
|
306
|
+
*,
|
|
307
|
+
artifact: str,
|
|
308
|
+
schema: Mapping[str, Any] | None = None,
|
|
309
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
310
|
+
) -> str:
|
|
311
|
+
"""전용 게시 프로토콜이 사용할 검증된 canonical 객체 문자열이다."""
|
|
312
|
+
owned = _validate_owned(
|
|
313
|
+
payload, path=path, artifact=artifact, schema=schema,
|
|
314
|
+
validate_cross_fields=validate_cross_fields,
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
return json.dumps(
|
|
318
|
+
owned, ensure_ascii=False, indent=2, allow_nan=False
|
|
319
|
+
) + "\n"
|
|
320
|
+
except (TypeError, ValueError) as exc:
|
|
321
|
+
raise JsonBoundaryError(
|
|
322
|
+
path, artifact, f"JSON serialization failed ({exc})"
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def mutate_owned_object_atomic(
|
|
327
|
+
path: Path,
|
|
328
|
+
mutation: Callable[[dict[str, Any]], dict[str, Any]],
|
|
329
|
+
**contract: Any,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
"""Load, mutate, validate, and atomically replace one owned object."""
|
|
332
|
+
payload = load_owned_object(path, **contract)
|
|
333
|
+
updated = mutation(dict(payload))
|
|
334
|
+
write_owned_object_atomic(path, updated, **contract)
|
|
335
|
+
return updated
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _reject_nonstandard_json_constant(constant: str) -> None:
|
|
339
|
+
raise _NonstandardJsonConstant(f"non-standard JSON constant: {constant}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _validate_owned(
|
|
343
|
+
value: object,
|
|
344
|
+
*,
|
|
345
|
+
path: Path,
|
|
346
|
+
artifact: str,
|
|
347
|
+
schema: Mapping[str, Any] | None,
|
|
348
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
if not isinstance(value, Mapping):
|
|
351
|
+
raise JsonBoundaryError(path, artifact, "top-level JSON value must be an object")
|
|
352
|
+
owned = dict(value)
|
|
353
|
+
if schema is not None:
|
|
354
|
+
try:
|
|
355
|
+
schema_errors = validate_schema(owned, dict(schema))
|
|
356
|
+
except SchemaError as exc:
|
|
357
|
+
raise JsonBoundaryError(path, artifact, f"invalid schema ({exc})") from exc
|
|
358
|
+
if schema_errors:
|
|
359
|
+
raise JsonBoundaryError(path, artifact, f"schema validation failed ({schema_errors[0]})")
|
|
360
|
+
if validate_cross_fields is not None:
|
|
361
|
+
errors = validate_cross_fields(owned)
|
|
362
|
+
if isinstance(errors, str):
|
|
363
|
+
errors = [errors]
|
|
364
|
+
if errors:
|
|
365
|
+
raise JsonBoundaryError(path, artifact, f"cross-field validation failed ({next(iter(errors))})")
|
|
366
|
+
return owned
|
|
@@ -3,11 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import fcntl
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
6
|
from collections.abc import Callable, Iterator
|
|
9
7
|
from pathlib import Path
|
|
10
8
|
|
|
9
|
+
from .json_boundary import (
|
|
10
|
+
JsonBoundaryError,
|
|
11
|
+
load_owned_object,
|
|
12
|
+
write_owned_object_atomic,
|
|
13
|
+
)
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
@contextlib.contextmanager
|
|
13
17
|
def registry_lock(lock_path: Path) -> Iterator[None]:
|
|
@@ -26,18 +30,12 @@ def registry_lock(lock_path: Path) -> Iterator[None]:
|
|
|
26
30
|
|
|
27
31
|
def load_registry_json(path: Path, default_factory: Callable[[], dict]) -> dict:
|
|
28
32
|
"""Load a registry dict, or a fresh default when absent/corrupt."""
|
|
29
|
-
if not path.exists():
|
|
30
|
-
return default_factory()
|
|
31
33
|
try:
|
|
32
|
-
|
|
33
|
-
except
|
|
34
|
+
return load_owned_object(path, artifact="registry")
|
|
35
|
+
except JsonBoundaryError:
|
|
34
36
|
return default_factory()
|
|
35
|
-
return data if isinstance(data, dict) else default_factory()
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def save_registry_json(path: Path, data: dict) -> None:
|
|
39
|
-
"""Persist registry JSON
|
|
40
|
-
path
|
|
41
|
-
tmp = path.with_suffix(".json.tmp")
|
|
42
|
-
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
43
|
-
os.replace(tmp, path)
|
|
40
|
+
"""Persist registry JSON through the owned-artifact boundary."""
|
|
41
|
+
write_owned_object_atomic(path, data, artifact="registry")
|
|
@@ -8,14 +8,31 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Callable, List, Optional
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def append_jsonl(
|
|
11
|
+
def append_jsonl(
|
|
12
|
+
path: Path, row: dict, *, ensure_ascii: bool = True, compact: bool = True
|
|
13
|
+
) -> None:
|
|
12
14
|
"""jsonl 파일 끝에 한 줄 append. 파일이 없으면 생성."""
|
|
13
15
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
-
line = json.dumps(
|
|
16
|
+
line = json.dumps(
|
|
17
|
+
row, separators=(",", ":") if compact else None,
|
|
18
|
+
ensure_ascii=ensure_ascii,
|
|
19
|
+
) + "\n"
|
|
15
20
|
with path.open("a") as f:
|
|
16
21
|
f.write(line)
|
|
17
22
|
|
|
18
23
|
|
|
24
|
+
def rewrite_jsonl(path: Path, rows: List[dict], *, ensure_ascii: bool = True) -> None:
|
|
25
|
+
"""JSONL 행 전체를 임시 파일에 쓴 뒤 원자 교체한다."""
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
28
|
+
with temporary.open("w", encoding="utf-8") as handle:
|
|
29
|
+
for row in rows:
|
|
30
|
+
handle.write(json.dumps(
|
|
31
|
+
row, separators=(",", ":"), ensure_ascii=ensure_ascii
|
|
32
|
+
) + "\n")
|
|
33
|
+
os.replace(temporary, path)
|
|
34
|
+
|
|
35
|
+
|
|
19
36
|
def read_jsonl(path: Path) -> List[dict]:
|
|
20
37
|
"""jsonl 파일을 모두 읽어 dict 목록으로 반환. 파싱 실패 라인은 스킵."""
|
|
21
38
|
if not path.is_file():
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import hashlib
|
|
5
6
|
import re
|
|
7
|
+
import uuid
|
|
6
8
|
from contextlib import contextmanager
|
|
7
9
|
from dataclasses import dataclass, field, replace
|
|
8
10
|
from pathlib import Path
|
|
@@ -107,14 +109,44 @@ def _next_activity_id(events: list[LeadEvent]) -> str:
|
|
|
107
109
|
return f"A-{max(numbers, default=0) + 1:03d}"
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
def _activity_request_digest(details: Mapping[str, Any]) -> str:
|
|
113
|
+
payload = {
|
|
114
|
+
key: value for key, value in details.items()
|
|
115
|
+
if key not in {"activityId", "activityRequestDigest", "activityRequestRef", "activityVersion"}
|
|
116
|
+
}
|
|
117
|
+
encoded = json.dumps(_stable_json_value(payload), ensure_ascii=False, separators=(",", ":"))
|
|
118
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
110
121
|
def append_activity_event(path: Path, event: LeadEvent) -> LeadEvent:
|
|
111
122
|
"""Assign the next activity ID and append the event under one lock."""
|
|
112
123
|
if event.event_type != "activity":
|
|
113
124
|
raise ValueError("activity append requires eventType=activity")
|
|
114
125
|
with _event_log_lock(path):
|
|
115
126
|
details = dict(event.details)
|
|
127
|
+
request_ref = details.get("activityRequestRef")
|
|
128
|
+
events = read_lead_events(path)
|
|
129
|
+
digest = _activity_request_digest(details)
|
|
130
|
+
if request_ref is not None:
|
|
131
|
+
if not isinstance(request_ref, str) or not request_ref:
|
|
132
|
+
raise ValueError("activityRequestRef must be a non-empty string")
|
|
133
|
+
for previous in events:
|
|
134
|
+
if (
|
|
135
|
+
previous.event_type == event.event_type
|
|
136
|
+
and previous.lead_runtime == event.lead_runtime
|
|
137
|
+
and previous.task_key == event.task_key
|
|
138
|
+
and previous.task_type == event.task_type
|
|
139
|
+
and previous.run_seq == event.run_seq
|
|
140
|
+
and previous.details.get("activityRequestRef") == request_ref
|
|
141
|
+
):
|
|
142
|
+
if previous.details.get("activityRequestDigest") != digest:
|
|
143
|
+
raise ValueError("activity request reference has different payload")
|
|
144
|
+
return previous
|
|
145
|
+
raise ValueError("unknown activity request reference")
|
|
146
|
+
details["activityRequestRef"] = f"activity-{uuid.uuid4().hex}"
|
|
147
|
+
details["activityRequestDigest"] = digest
|
|
116
148
|
details["activityVersion"] = 1
|
|
117
|
-
details["activityId"] = _next_activity_id(
|
|
149
|
+
details["activityId"] = _next_activity_id(events)
|
|
118
150
|
stored = replace(event, details=details)
|
|
119
151
|
_append_unlocked(path, stored)
|
|
120
152
|
return stored
|