diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e7e256..2825a41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -13,6 +16,7 @@ jobs: matrix: ruby: - "3.4" + - "4.0" - head steps: - uses: actions/checkout@v4 @@ -20,4 +24,8 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true + # COVERAGE=1 turns on the SimpleCov gate in spec/test_helper.rb so the + # rake run enforces coverage, not just tests + standardrb. - run: bundle exec rake + env: + COVERAGE: "1" diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml deleted file mode 100644 index 59e501e..0000000 --- a/.github/workflows/ruby.yml +++ /dev/null @@ -1,36 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake -# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby - -name: Ruby - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - test: - - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - ruby-version: ['3.4', '4.0'] - - steps: - - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby-version }} - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - - name: Run tests - run: bundle exec rake diff --git a/.gitignore b/.gitignore index 0d742a3..d351645 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /.rubocop_cache/ /doc/ /.yardoc/ +/.sentrux/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cfe1c1..24e7f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,64 @@ ## Unreleased +Project-review hardening pass: design fixes at the storage-port seams, +DRY consolidation, performance work, and CI/cop enforcement. + +### Added + +- `DAG::Ports::EffectLedger`, split out of `Ports::Storage` (which now + includes it): the Dispatcher depends only on the ledger surface plus + `append_event`. `complete_effect_succeeded` / `complete_effect_failed` + are the canonical completion path with composed (non crash-atomic) + port defaults; `thread_safe_for_dispatch?` is a documented port method + defaulting to `false`. +- `Dispatcher#tick(only_workflow_id:)` forwards the V1.4 per-workflow + claim filter. +- `DAG::Effects::DispatchAbortedError`: an aborted tick no longer + discards the outcomes sibling workers already durably applied — the + error carries the partial `DispatchReport` (`#report`) and the + original exception (`#cause`). +- Durable `:workflow_retrying` event appended atomically by + `Runner#retry_workflow` via `prepare_workflow_retry(event:)`; new + TraceRecord status `:retrying`. +- Full-fidelity `to_h` / `from_h` round-trip for `Success`, `Failure`, + `Waiting`, `Effects::Intent`, `ProposedMutation`, `ReplacementGraph`, + and `Graph`, with `DAG::Result.from_h` as the deserialization entry + point (Symbol or String keys). +- `DAG::AttemptOrder` (single definition of the canonical + committed-attempt ordering), `DAG::EventPublishing.publish_quietly`, + `DAG::Snapshot` indifferent fetch, `Validation.optional_node_id!`. +- `DAG::DuplicateWorkflowError` and `DAG::UnknownAttemptError` replace + bare `ArgumentError` for those storage states. +- Executable §9.1 grep gate: `spec/r0/kernel_ai_terms_test.rb`. + +### Changed + +- `Ports::Storage.method_overridden?` removed; extension behavior lives + in overridable port defaults instead of `Method#owner` reflection + (which broke under decorators/proxies). +- `list_committed_results_for_predecessors` port default raises + `StaleStateError` for a committed predecessor with no committed + attempt instead of silently dropping its `context_patch` (the old + Runner fallback bug). +- Rescued-exception payloads use one vocabulary: `error_class:` + everywhere (`:handler_raised`, `:effect_idempotency_conflict`); + `:handler_bad_return` uses `returned_class:`. +- `ExecutionContext#merge` validates and copies only the patch + (canonical key collisions still rejected) instead of re-walking the + whole context per predecessor per attempt (~300x faster on large + contexts); `Memory::StorageState` gains per-node attempt and active + effect indexes, removing the per-execution full-ledger scans. +- Retriable `Failure` documented as immediate-retry by design; delayed + retries belong to `Waiting` + effects. Single-runner invariant on the + crash-resume path documented on the port and in `CONTRACT.md`. +- CI consolidated to one workflow (Ruby 3.4 / 4.0 / head matrix) + running with `COVERAGE=1`, so the SimpleCov gate (100% line / 90% + branch) is enforced; cops tightened (`Fiber` banned everywhere, + `Kernel.system`/`spawn`/`fork` flagged, stdlib require allowlist + trimmed to the frozen §3 list, `mutation_service.rb` added to the + in-place-mutation cop scope). + ## 1.5.0 — 2026-05-31 V1.5 is a mutation-testing release. It adds a focused Mutant gate for the diff --git a/CLAUDE.md b/CLAUDE.md index 9fdee7d..9b901c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,28 @@ value objects and adapters. ## Port extensions The original roadmap port (Appendix C) listed 15 methods. The current -effect-aware Appendix C/I shape includes the documented extensions below. -Implementing R1's +effect-aware shape is split in two modules: `Ports::Storage` (workflow +rows, revisions, node states, attempts, event log) and +`Ports::EffectLedger` (effect listing, claim, mark, renew, release, +complete), with `Ports::Storage` including the ledger so existing +adapters are unchanged. The Dispatcher depends only on the ledger +subset plus `append_event`. Two methods ship port-level defaults: +`complete_effect_succeeded`/`complete_effect_failed` (composed +mark+release; not crash-atomic — durable adapters override atomically) +and `list_committed_results_for_predecessors` (composed +`load_node_states`+`list_attempts` via `DAG::AttemptOrder`; raises +`StaleStateError` on a committed predecessor with no committed attempt, +because the default cannot see carry-forward projections). There is no +reflection-based capability detection: extension behavior lives in +overridable defaults, and `thread_safe_for_dispatch?` is a documented +port method defaulting to `false`. + +Documented extensions beyond the canonical roadmap list: +`append_revision_if_workflow_state` (workflow-state guard + revision +CAS in one boundary, used by `MutationService`), +`claim_ready_effects(only_workflow_id:)` (V1.4 per-workflow dispatch, +forwarded by `Dispatcher#tick(only_workflow_id:)`), and the ones +detailed below. Implementing R1's `Runner#retry_workflow` per its DoD requires one operation that cannot be expressed via the original primitives: @@ -191,14 +211,21 @@ out-of-scope for V1.2 and require their own design pass. Effect PR4 adds the abstract dispatcher boundary on top of those storage methods: -- **`DAG::Effects::Dispatcher#tick(limit:)`** claims ready records, calls the - handler registered for each effect type, marks the effect succeeded or - failed, releases waiting nodes when the effect is terminal, and returns an +- **`DAG::Effects::Dispatcher#tick(limit:, only_workflow_id: nil)`** claims + ready records, calls the handler registered for each effect type, completes + each effect through the canonical `complete_effect_*` storage methods + (which release waiting nodes when the effect is terminal), and returns an immutable `DAG::Effects::DispatchReport`. - Handlers implement **`#call(DAG::Effects::Record) -> DAG::Effects::HandlerResult`**. Handler exceptions and invalid return values become retriable failures; unknown effect types default to terminal failure unless `unknown_handler_policy: :raise` is selected. +- When a dispatch worker raises an unexpected exception (storage failure, + or unknown type under `:raise`), `tick` raises + `DAG::Effects::DispatchAbortedError` after all workers have joined; the + error carries the partial `DispatchReport` via `#report` and the original + exception via `#cause`. Non-`StandardError` exceptions propagate + unwrapped. - When the dispatcher catches `DAG::Effects::StaleLeaseError` after a handler returns (the handler ran longer than `lease_ms`, so the mark cannot be applied), it appends a durable @@ -274,7 +301,11 @@ lib/dag/edge.rb # Edge = Data.define(:from, :to, :meta lib/dag/graph.rb # Graph class lib/dag/graph/builder.rb # Builder.build { |b| ... } -> frozen Graph lib/dag/graph/validator.rb # Structural validation -> Validator::Report -lib/dag/result.rb # DAG::Result marker + Result.try / assert_result! +lib/dag/result.rb # DAG::Result marker + Result.try / exception_failure / from_h +lib/dag/validation.rb # shared primitive/instance/enum validation helpers +lib/dag/attempt_order.rb # canonical committed-attempt ordering (better?/key) +lib/dag/event_publishing.rb # best-effort EventBus publish (swallow-all) +lib/dag/snapshot.rb # indifferent-key fetch for from_h deserialization lib/dag/plan_version.rb # PlanVersion[workflow_id:, revision:] lib/dag/success.rb # Success(value:, context_patch:, proposed_mutations:, proposed_effects:, metadata:) lib/dag/failure.rb # Failure(error:, retriable:, metadata:) @@ -293,9 +324,11 @@ lib/dag/effects/record.rb # durable effect snapshot lib/dag/effects/handler_result.rb # abstract dispatcher handler result lib/dag/effects/dispatch_report.rb # immutable dispatcher tick report lib/dag/effects/dispatcher.rb # abstract effect dispatcher +lib/dag/effects/dispatch_aborted_error.rb # tick abort error carrying the partial report lib/dag/effects/await.rb # monadic Waiting/Failure/Success composition helper lib/dag/immutability.rb # deep_freeze, deep_dup, json_safe! -lib/dag/ports/storage.rb # Ports::Storage interface +lib/dag/ports/effect_ledger.rb # Ports::EffectLedger (effect surface + complete_* defaults) +lib/dag/ports/storage.rb # Ports::Storage interface (includes EffectLedger) lib/dag/ports/event_bus.rb # Ports::EventBus interface lib/dag/ports/fingerprint.rb # Ports::Fingerprint interface lib/dag/ports/clock.rb # Ports::Clock interface @@ -308,7 +341,18 @@ lib/dag/step_type_registry.rb # DAG::StepTypeRegistry lib/dag/builtin_steps/noop.rb # :noop -> Success(nil, {}) lib/dag/builtin_steps/passthrough.rb # :passthrough -> Success(context, context) lib/dag/workflow/definition.rb # DAG::Workflow::Definition (immutable, chainable) +lib/dag/workflow/definition/builder.rb # bulk builder (mutable until build; use for large graphs) lib/dag/runner.rb # DAG::Runner kernel + private RunContext carrier +lib/dag/mutation_service.rb # durable structural mutations (R3) +lib/dag/definition_editor.rb # pure mutation planning -> PlanResult +lib/dag/plan_result.rb # mutation plan value +lib/dag/apply_result.rb # mutation apply receipt +lib/dag/diagnostics.rb # storage-derived diagnostics facade +lib/dag/node_diagnostic.rb # immutable per-node diagnostic value +lib/dag/trace_record.rb # normalized event trace value +lib/dag/runtime_snapshot.rb # public step-side runtime boundary +lib/dag/toolkit.rb # in-memory kit factory for examples/tests +lib/dag/testing/storage_contract.rb # shareable storage adapter contract suite (+ storage_contract/) lib/dag/adapters/stdlib/clock.rb # wall + monotonic ms lib/dag/adapters/stdlib/id_generator.rb # SecureRandom.uuid lib/dag/adapters/stdlib/fingerprint.rb # JSON-canonical SHA256 @@ -317,13 +361,16 @@ lib/dag/adapters/null/event_bus.rb # drops everything; optional logger lib/dag/adapters/memory/event_bus.rb # bounded ring buffer + subscribers lib/dag/adapters/memory/storage.rb # facade; deep-dup-freezes returns lib/dag/adapters/memory/storage_state.rb # mutable bookkeeping (only spot in lib/dag/** allowed to mutate) +lib/dag/adapters/memory/crashable_storage.rb # crash-injection test adapter ``` -Tests live in `spec/r0/` (R0 invariants), `spec/r1/` (R1 DoD), and `spec/r2/` -(R2 DoD). Roadmap paths written as `test/r*/...` map to this repo's -`spec/r*/...` layout. Shared helpers under -`spec/support/{runner_factory,workflow_builders,step_helpers}.rb` are -auto-included into `Minitest::Test` by `spec/test_helper.rb`. +Tests live in `spec/r0/` (R0 invariants), `spec/r1/` (R1 DoD), `spec/r2/` +(R2 DoD), and `spec/r3/` (R3 mutations + hardening). Roadmap paths written +as `test/r*/...` map to this repo's `spec/r*/...` layout. Shared helpers +under `spec/support/` are auto-included into `Minitest::Test` by +`spec/test_helper.rb`. CI runs `bundle exec rake` with `COVERAGE=1`, which +enforces the SimpleCov gate (100% line / 90% branch) from +`spec/test_helper.rb`. ## Error hierarchy @@ -337,11 +384,14 @@ All errors inherit from `DAG::Error < StandardError`: - `StaleStateError`, `StaleRevisionError`, `ConcurrentMutationError` - `FingerprintMismatchError` - `UnknownStepTypeError`, `UnknownWorkflowError` +- `DuplicateWorkflowError`, `UnknownAttemptError` - `WorkflowRetryExhaustedError` - `DAG::Effects::IdempotencyConflictError` - `DAG::Effects::StaleLeaseError` - `DAG::Effects::UnknownEffectError` - `DAG::Effects::UnknownHandlerError` +- `DAG::Effects::DispatchAbortedError` (carries `#report`, the partial + `DispatchReport`; original exception on `#cause`) Adding a duplicate edge is **not** an error — `add_edge` is idempotent. @@ -382,8 +432,10 @@ public surface) without paying ceremony for private scaffolding. caught at the Runner boundary as `Failure[error: {code: :step_bad_return, ...}, retriable: false]`. - `StandardError` raised in `#call` is converted to - `Failure[error: {code: :step_raised, class:, message:}, retriable: false]`. - `NoMemoryError`, `SystemExit`, and `Interrupt` propagate. + `Failure[error: {code: :step_raised, message:, error_class:}, + retriable: false]` via `DAG::Result.exception_failure`. Rescued + exceptions always travel under the `error_class:` key across the + kernel. `NoMemoryError`, `SystemExit`, and `Interrupt` propagate. - `count_attempts` excludes `:aborted` attempts. `Storage#prepare_workflow_retry` marks failed attempts as `:aborted` so the per-node attempt budget resets across workflow retries, and owns the @@ -410,9 +462,10 @@ public surface) without paying ceremony for private scaffolding. - Event types are the closed set in `DAG::Event::TYPES`. The Runner emits `:workflow_started` once per workflow (idempotent via event log inspection), `:node_started` / `:node_committed` / `:node_waiting` / - `:node_failed` per attempt, and one terminal + `:node_failed` per attempt, one terminal `:workflow_completed` / `:workflow_waiting` / `:workflow_paused` / - `:workflow_failed`. + `:workflow_failed`, and `:workflow_retrying` durably inside + `prepare_workflow_retry` on every `Runner#retry_workflow`. - `Memory::Storage` events get a monotonic `seq`; `read_events(after_seq:, limit:)` filters by it. `EventBus#publish` sees the same stamped event the storage appended. diff --git a/CONTRACT.md b/CONTRACT.md index cf319fb..e7cbbc0 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -72,6 +72,20 @@ intents with execution coordinates and payload fingerprints, then storage reserves them atomically with the attempt commit. Dispatch and concrete handlers remain outside the runner. +Every step outcome serializes with full fidelity: `Success#to_h`, +`Failure#to_h`, and `Waiting#to_h` project every field (including +`context_patch`, which replay requires, and `retriable`, which retry +semantics require) as JSON-safe values, and `DAG::Result.from_h` is the +single deserialization entry point, dispatching on `:status` and accepting +Symbol or String keys. Durable adapters must persist results through this +round-trip rather than inventing their own shapes. + +Retriable `Failure` retries are immediate: the node returns to `:pending` +and the Runner re-executes it within the same `#call`, consuming the +per-node attempt budget back-to-back. A step that needs to retry later +returns `Waiting` (optionally with `not_before_ms` and a blocking effect) +so the delay is owned by the scheduler/dispatcher boundary. + ## Effects Value Layer An effect is described as an abstract, adapter-agnostic intent: @@ -289,10 +303,15 @@ DAG::Effects::Dispatcher.new( clock:, owner_id:, lease_ms:, - unknown_handler_policy: :terminal_failure + unknown_handler_policy: :terminal_failure, + parallelism: 1 ) ``` +The dispatcher's storage dependency is the `DAG::Ports::EffectLedger` +surface (claim + atomic completion) plus `append_event`; it never touches +workflow rows, revisions, or attempts. + `handlers` is a Hash keyed by effect type (`String` or `Symbol`). Each handler must implement: @@ -300,14 +319,16 @@ must implement: #call(DAG::Effects::Record) -> DAG::Effects::HandlerResult ``` -`tick(limit:)` claims up to `limit` ready effects with -`storage.claim_ready_effects`, dispatches each claimed record, and completes -the effect through storage. When the adapter implements -`complete_effect_succeeded` / `complete_effect_failed`, the terminal mark and -waiting-node release happen in one storage boundary. Older adapters may still -fall back to `mark_effect_succeeded` / `mark_effect_failed` followed by -`release_nodes_satisfied_by_effect`. The return value is an immutable -`DAG::Effects::DispatchReport`: +`tick(limit:, only_workflow_id: nil)` claims up to `limit` ready effects with +`storage.claim_ready_effects` (forwarding `only_workflow_id` for per-workflow +dispatch), dispatches each claimed record, and completes the effect through +`storage.complete_effect_succeeded` / `storage.complete_effect_failed` — the +canonical completion path. Adapters that override those methods bind the +terminal mark and the waiting-node release in one atomic storage boundary; +adapters that only implement the `mark_effect_*` / +`release_nodes_satisfied_by_effect` primitives inherit the composed (non +crash-atomic) default from `DAG::Ports::EffectLedger`. The return value is an +immutable `DAG::Effects::DispatchReport`: ```text claimed @@ -328,7 +349,18 @@ remaining claimed records. Handler exceptions and invalid handler return values become retriable effect failures with JSON-safe error payloads. Unknown effect types default to terminal failure with `code: :unknown_handler`; alternatively, -`unknown_handler_policy: :raise` raises `DAG::Effects::UnknownHandlerError`. +`unknown_handler_policy: :raise` aborts the tick. + +When a dispatch worker raises an unexpected exception (a dispatcher-side +storage failure, or an unknown effect type under +`unknown_handler_policy: :raise`), `tick` raises +`DAG::Effects::DispatchAbortedError` only after every worker has joined. +The error carries `#report` — the `DispatchReport` of every outcome that +durably completed before the abort — and the original exception as +`#cause` (for `:raise` policy that is the `DAG::Effects::UnknownHandlerError`). +Claimed records that were never marked stay `:dispatching` until their +lease expires and a future tick re-claims them. Non-`StandardError` +exceptions propagate unwrapped, still after all workers have joined. Every entry in `DispatchReport#errors` has the shared JSON-safe keys: @@ -339,8 +371,10 @@ ref type ``` -Code-specific entries may add fields. `:handler_raised` adds `class` and -`message`; `:handler_bad_return` adds `class`; `:stale_lease` adds `message`. +Code-specific entries may add fields. `:handler_raised` adds `error_class` +and `message`; `:handler_bad_return` adds `returned_class`; `:stale_lease` +adds `message`. Rescued-exception payloads across the kernel share one +vocabulary: the exception class always travels under `error_class`. ### Dispatcher Concurrency Contract @@ -446,13 +480,34 @@ shape documented in `DAG::Ports::Storage`: `{id:, state:, reset:, workflow_retry_count:, event:}`. Storage adapters use the shared public error vocabulary for control flow: -`DAG::UnknownWorkflowError`, `DAG::StaleStateError`, +`DAG::UnknownWorkflowError`, `DAG::DuplicateWorkflowError`, +`DAG::UnknownAttemptError`, `DAG::StaleStateError`, `DAG::StaleRevisionError`, `DAG::ConcurrentMutationError`, `DAG::WorkflowRetryExhaustedError`, `DAG::Effects::UnknownEffectError`, `DAG::Effects::IdempotencyConflictError`, and -`DAG::Effects::StaleLeaseError`. Exception messages are diagnostics only; -Runner and consumers must branch on classes and structured receipts rather than -parsing adapter-specific text. +`DAG::Effects::StaleLeaseError`. Every storage state error lives under +`DAG::Error` so consumers can rescue the hierarchy uniformly. Exception +messages are diagnostics only; Runner and consumers must branch on classes +and structured receipts rather than parsing adapter-specific text. + +The storage port is split in two modules: `DAG::Ports::Storage` owns +workflow rows, revisions, node states, attempts, and the event log; +`DAG::Ports::EffectLedger` (included by `Ports::Storage`) owns the effect +ledger surface. The Runner consumes the storage side; +`DAG::Effects::Dispatcher` consumes only the ledger side plus +`append_event`. Two methods ship safe port-level defaults: + +- `complete_effect_succeeded` / `complete_effect_failed` default to the + composed `mark_effect_*` + `release_nodes_satisfied_by_effect` sequence. + The composition is not crash-atomic; durable adapters must override them + with one transaction. +- `list_committed_results_for_predecessors` defaults to a composition of + `load_node_states` + `list_attempts` using the canonical + `DAG::AttemptOrder` rule. The default cannot see carry-forward + committed-result projections, so it raises `DAG::StaleStateError` when a + predecessor is `:committed` with no committed attempt instead of + silently dropping its `context_patch`; adapters that materialize + projections (such as `Memory::Storage`) override it. ## Effect Storage Contract @@ -838,6 +893,15 @@ Storage must persist the supplied `attempt_number`; it must not recalculate it. `commit_attempt` is one-shot: adapters must reject a second commit for the same attempt after it has left `:running`. +Single-runner invariant: resuming a workflow whose row is already +`:running` (crash recovery) performs no storage transition, so storage +provides no mutual exclusion on that path — two hosts resuming the same +workflow would both abort in-flight attempts and both execute nodes. +Deployments must guarantee at most one runner drives a given workflow at a +time. A workflow-level owner/lease claim (mirroring the effect-lease +model) is a planned extension that must be designed before multi-host +consumers resume concurrently. + ## Proposed Mutations Consumers may propose mutations with: @@ -926,11 +990,19 @@ EVENT_TYPES = %i[ workflow_waiting workflow_completed workflow_failed + workflow_retrying mutation_applied effect_dispatch_stale_lease ].freeze ``` +`:workflow_retrying` is appended durably by +`storage.prepare_workflow_retry(event:)` in the same atomic step as the +retry transition, so the event log explains the +`workflow_failed -> node_started` sequence that `Runner#retry_workflow` +produces. `workflow_started` remains once-per-lifetime and is not +re-emitted by a retry. + Events are durably appended by storage operations, then published live through `EventBus#publish` after commit. diff --git a/docs/reviews/claude-review-v1.0.1.md b/docs/reviews/claude-review-v1.0.1.md new file mode 100644 index 0000000..a3297f0 --- /dev/null +++ b/docs/reviews/claude-review-v1.0.1.md @@ -0,0 +1,623 @@ +# ruby-dag v1.0.1 — Code Review (stile Antirez) + +> Revisione richiesta: brutalmente onesta, niente carezze. Vision dell'utente +> validata punto per punto. Citazioni `file:line` per ogni claim. + +--- + +## Context + +Revisione completa di `ruby-dag` v1.0.1 (kernel deterministico per +DAG/workflow, ~6.4k LOC `lib/`, ~6.7k LOC `spec/`) e validazione punto per +punto della vision tecnica dichiarata: + +1. Zero dipendenze esterne +2. Monadi +3. Tipi dati immutabili +4. Copy-on-write per garantire concorrenza futura +5. Bilanciamento perfetto OOP / programmazione funzionale +6. Ruby idiomatico +7. DRY + +Stile Antirez: fatti, non lodi; pro e contro; proposte alternative che +rispettino la vision dopo averla validata. + +**File ignorati**: `REVIEW.md` nel working tree è di un altro progetto (parla +di `Steps::Exec`, `Strategy.run_task`, `:threads`/`:processes`, `drain_pipes`, +`KILL_GRACE_SECONDS` — niente di tutto ciò esiste qui). Sembra spillage da un +altro repo. Da rimuovere o spostare; non vale come review di ruby-dag. + +--- + +## 1. Validazione della vision + +### 1.1 ✅ Zero dipendenze esterne — vero + +`ruby-dag.gemspec` non dichiara runtime deps; `lib/dag.rb` carica solo file +relativi. Stdlib (JSON, SecureRandom, Set) — fine. Questo claim regge. + +### 1.2 ⚠️ Monadi — parzialmente vero, parzialmente marketing + +**Vero**: `DAG::Result` è una mini-monade onesta su `Success | Failure`: + +- `result.rb:25` — modulo marker incluso da Success **e** Failure +- `result.rb:54-57` — `assert_result!` enforce alla bound del block, beccando + il bug più comune ("forgot to wrap"). Questa è **buona ingegneria**. +- `success.rb:61-63`, `Failure#and_then` (`failure.rb:45`), `recover` + (`failure.rb:58-60`) — left identity, associatività e short-circuit di + `and_then`/`recover` sono rispettate. +- `result.rb:21-24` — la lista esplicita di metodi NON inclusi (`tap`, + `tap_error`, `map_error`, `value_or`) è disciplina vera, non religione. + +**Marketing**: `Effects::Await` (`effects/await.rb:5`) si proclama +"Monad-like". Falso. + +- È un dispatcher su 4 stati di un effect snapshot (succeeded / + failed_terminal / failed_retriable / nil-or-pending) con `yield` al + continuation. Non c'è composizione `Await ∘ Await`, niente `pure`, niente + `bind`. È una if-let con custodia di tipi. Va benissimo come helper — + pessimo come "monade". +- Non si compone con se stesso: due Await consecutivi richiedono di rimbalzare + attraverso `Result#and_then`, mescolando due astrazioni. + +**Sum type fake**: `Success | Waiting | Failure` non è un ADT vero. Sono tre +`Data.define` separate; il dispatch è `case/when` su classi +(`runner.rb:228-264`, `step_protocol.rb:16`). Waiting **deliberatamente non +include** `Result` (`result.rb:4-5`) — design consapevole: "Waiting è +control-flow, non un valore monadico". Questa scelta è onesta e va +documentata come tale, ma cancella metà del claim "monadi". + +**Verdetto vision**: il claim "monadi" regge solo per `Result` su +Success/Failure. Per Effects/Await è retorica. Cambia il vocabolario, non il +design. + +### 1.3 ✅ Tipi dati immutabili — vero, eseguito con disciplina + +- `Data.define` ovunque: Edge, Event, RunResult, RuntimeProfile, StepInput, + Success, Failure, Waiting, Intent, PreparedIntent, Record, ProposedMutation, + ReplacementGraph, RunContext, DispatchReport, HandlerResult — 16+ + occorrenze. +- `immutability.rb:16-19` — `frozen_copy` evita il pattern + `deep_freeze(deep_dup(...))` ed è usato 36+ volte ai confini dei costruttori + (success.rb:41-45, failure.rb:28-30, waiting.rb:62-66, edge.rb, intent.rb, + prepared_intent.rb:119-130, record.rb:194-215). Disciplinato. +- `runner.rb:66` — `freeze` finale del Runner. `RunContext` (linee 142-161) e + `predecessors_by_node` (linea 150) frozen. +- `graph.rb` — frozen graph eagerly cacha layers, sort, roots, leaves, edges + (commento + cache invalidation pattern). + +Buco onesto, **dichiarato dal codice stesso**: +`adapters/memory/storage_state.rb` — 760 LOC di mutation in-place. Il commento +in cima dice esplicitamente "the ONLY spot in `lib/dag/**` allowed to mutate" +(richiamato anche in `CLAUDE.md`). È un'ammissione, non un nascondiglio. + +### 1.4 ⚠️ Copy-on-write per concorrenza futura — overstatement + +**Vero per i value objects**: ogni mutazione di Definition / Graph / +ExecutionContext ritorna una nuova istanza congelata. Una `Definition` o +`Graph` frozen è safe da condividere tra thread. + +**Falso per lo stato durabile**: +`adapters/memory/storage_state.rb` ha read-modify-write loops senza alcun +meccanismo di concorrenza: + +```ruby +# storage_state.rb (pattern ricorrente) +row = fetch_workflow!(state, id) +unless row[:state] == from + raise StaleStateError, ... +end +row[:state] = to +``` + +Due thread che chiamano `transition_workflow_state` sullo stesso workflow in +parallelo → race condition con lost update. Il commento in cima del file +dichiara "single-process" — è onesto. Ma "copy-on-write per concorrenza +futura" implica un design di concorrenza che **non è stato disegnato**. + +CoW dei value objects è una **precondizione necessaria** per la concorrenza, +non sufficiente. Per arrivare a concorrenza vera serve uno di: + +1. Spostare TUTTO lo stato dietro un Storage transazionale (SQLite/Postgres, + roadmap S0). Allora il Memory adapter è solo per testing e il Runner non + ha bisogno di nulla. +2. Disegnare un protocollo CAS/MVCC al livello del port `Ports::Storage`. Es. + `transition_workflow_state(id:, from:, to:, expected_version:)`. + +Il primo è il path implicito della roadmap. Il secondo non è iniziato. + +**Verdetto vision**: "CoW per concorrenza futura" è vero solo per i value +objects passati attraverso il kernel (Definition, ExecutionContext, eventi). +Lo stato condiviso (storage) **non** è preparato. Chiarisci il claim nel +README come "frozen value objects safe to share; concurrent storage requires +SQLite/durable adapter (S0)". + +### 1.5 ❌ "Bilanciamento perfetto OOP/FP" — retorica + +Il codice è **layered**, non bilanciato: + +- Value objects → FP puro (Data.define + frozen_copy). +- Result/Success/Failure → mini-monade FP. +- Step → classe OOP con `#call` (`step/base.rb:24`). +- Runner → orchestrator OOP con stato implicito viaggiante (`RunContext` + attraverso ~15 metodi privati, `runner.rb:102-127, 200-225, 227-265`). +- Storage adapter → OOP classico (mutation tracker dentro StorageState). + +Non c'è composizione orizzontale tra i livelli. Non puoi comporre due Runner +in pipeline. Non puoi comporre due Await. Il "bilanciamento" è in realtà +"ogni layer sceglie il paradigma comodo". + +Niente di sbagliato in questo — è pragmatismo. Ma chiamarlo "bilanciamento +perfetto" è retorica. Più onesto: "FP per i valori, OOP per la coordinazione, +ports per le dipendenze esterne". + +### 1.6 ✅ Ruby idiomatico — buono, con un'eccezione + +**Bene**: + +- Niente `attr_accessor` (`runner.rb:31-44` solo `attr_reader`, e il cop + `Dag/NoMutableAccessors` lo enforce). +- Niente `method_missing`, niente `define_method` magico. +- `Data.define` per ogni value object. +- Lazy `enum_for` su `each_predecessor`/`each_successor` + (`graph.rb:394, 400`) — pattern Ruby corretto. +- `private_constant` (`runner.rb:187, 309`) per le costanti interne. + +**Eccezione documentabile, non sbagliata**: il pattern "custom factory []" + +```ruby +class << self + remove_method :[] + def [](kw:); new(...); end +end + +def initialize(kw:); validate!; super(...); end +``` + +è ripetuto in ~9 file (success.rb:13-31, failure.rb:11-21, waiting.rb:10-28, +run_result.rb:9-25, event.rb, step_input.rb, intent.rb:9-20, +prepared_intent.rb:22-86, record.rb:47-144). È un trick conosciuto per +sostituire il costruttore positional di `Data.define` con uno keyword-only, +ma è ~10-15 righe di boilerplate per file. Vedere §3.1. + +### 1.7 ⚠️ DRY — vero per validation, falso per il pattern Data.define + +**Vero**: `validation.rb` (182 LOC, 14 helper) è centralizzato, ben usato e +recentemente refactored (commit `45b5257`, `480d187`, `95786de`). Ogni +costruttore di value object lo invoca ai confini. Esempi: +prepared_intent.rb:107-115, record.rb:172-185, waiting.rb:49-58. + +**Falso**: ~9 file ripetono il pattern `class << self; remove_method :[]; +def []; end; end; def initialize; validate; super; end`. Stima conservativa: +~150-200 righe di duplicazione "soft" attraverso il sotto-sistema di value +objects. La duplicazione è regolare, non casuale, ma non è eliminata. + +`CLAUDE.md` dice "Avoid `method_missing`, broad metaprogramming, or generic +'validate schema' layers". È una scelta consapevole di **vivere con la +duplicazione** invece di scivolare in metaprogramming. Antirez approverebbe +la motivazione. Ma allora cancella il claim "DRY" come universale. Diventa: +"DRY per le check di validazione; duplicazione regolare e accettata per la +forma dei value object". + +### Riassunto vision + +| Claim | Stato | +| ---------------------------------- | ----------- | +| Zero dipendenze | ✅ vero | +| Monadi | ⚠️ parziale | +| Tipi dati immutabili | ✅ vero | +| CoW per concorrenza futura | ⚠️ parziale | +| Bilanciamento OOP/FP | ❌ retorica | +| Ruby idiomatico | ✅ vero | +| DRY | ⚠️ parziale | + +Tre veri, tre parziali con onestà del codice ad ammetterlo, uno retorico. + +--- + +## 2. Cosa brilla davvero + +Antirez-style: chi ha scritto questo ha letto Wadler **e** ha shippato Ruby +in produzione. Cose specifiche che meritano d'essere viste: + +- **`Result.assert_result!`** (`result.rb:54-57`) — beccare il "forgot to + wrap" è il bug #1 di chi inizia con monadi. Enforced al limite del block, + con messaggio puntuale. +- **`Result.exception_failure`** (`result.rb:44-51`) — un solo posto fa il + contratto `{code:, message:, error_class:, **extras}`. Riusato in + `runner.rb:428` e nei test. Niente drift. +- **Niente Enumerable mixin su Graph** — scelta deliberata, esposta da + `each_node`/`each_edge`. Decision-by-refusal. +- **`graph.rb:394-401`** — `each_predecessor` **e** `each_successor` esistono + entrambi. Sono usati in `runner.rb:147` (`each_predecessor`) e disponibili + per il chiamante. +- **`runner.rb:142-161` `build_run_context`** — precomputa + `predecessors_by_node` una volta per `#call`, evita `set.dup` per nodo nel + loop principale. Hot-path optimization motivata. +- **`runner.rb:382-386` `storage_overrides?`** — verifica con + `method.owner != Ports::Storage` se l'adapter implementa il fast-path + ottimale o se il Runner deve fare il fallback. Trick semplice, robusto. +- **`runner.rb:177-184` `append_workflow_started_once`** — l'idempotenza è + fatta scansionando il primo evento, non con un flag in storage. Survives + retry, survives crash. +- **`runner.rb:440-453` `finalize`** — il commento esplicita il design: NO + fallback a `:waiting` se non c'è almeno un nodo waiting; surface `:failed` + con `diagnostic: :no_eligible_but_incomplete`. Onestà sopra eleganza. +- **`runner.rb:54-57`** — i 7 keyword sono richiesti, niente default + injection di port nascosti. Il cop `Dag/NoThreadOrRactor` enforce + l'assenza di `Thread`/`Mutex`/`Queue`/`Ractor` nell'intero `lib/dag/**`. +- **Test fuzz su Graph** — `spec/graph_fuzz_test.rb` (708 LOC) e + `spec/graph_test.rb` (1235 LOC). 100% line + 90% branch floor in + `spec/test_helper.rb`. Per una libreria foundation è il livello giusto. +- **Validation centralizzato e usato uniformemente** (`validation.rb`). +- **Errori gerarchici e specifici** (`errors.rb` + `effects/*_error.rb`). + +--- + +## 3. Critiche reali (in ordine di priorità) + +### 3.1 Priorità ALTA — DRY mancato sui value objects + +**Problema**: 9 file (`success.rb`, `failure.rb`, `waiting.rb`, +`run_result.rb`, `event.rb`, `step_input.rb`, `intent.rb`, +`prepared_intent.rb`, `record.rb`) ripetono la stessa struttura: + +```ruby +Foo = Data.define(:a, :b, :c) do + class << self + remove_method :[] + def [](a:, b: default, c: {}) + new(a: a, b: b, c: c) + end + end + + def initialize(a:, b: default, c: {}) + Validation.foo!(a, "a") + Validation.bar!(b, "b") if b + DAG.json_safe!(c, "$root.c") + super(a: a, b: DAG.frozen_copy(b), c: DAG.frozen_copy(c)) + end +end +``` + +Ripetuto, in alcuni casi, con 12+ campi (`record.rb` ha 21 campi e ~150 +righe di sole boilerplate dichiarative). + +**Impatto**: la vision "DRY" è la più colpita. Tutto il resto del kernel +**è** DRY; questo strato è chiaramente non. + +**Proposte alternative (rispettose della vision)**: + +Opzione A — **un helper dedicato, non metaprogramming generico**. + +```ruby +module DAG + def self.frozen_value(*field_names, &validate) + Data.define(*field_names) do + define_method(:initialize) do |**kwargs| + instance_exec(kwargs, &validate) if validate + super(**kwargs.transform_values { |v| DAG.frozen_copy(v) }) + end + end + end +end +``` + +Ma `CLAUDE.md` esplicitamente bandisce "broad metaprogramming". Quindi: + +Opzione B (**raccomandata**) — **vivi con la duplicazione, ma estrai i +pezzi ripetuti che non richiedono metaprog**: + +- `frozen_copy` è già fatto. +- Aggiungi `Validation.json_safe_at!(value, label)` come alias di + `DAG.json_safe!` per uniformare il chiamante. +- **Documenta** la regolarità del pattern in `CLAUDE.md` come "intentional + duplication: every value object follows the same skeleton; do not + refactor without removing 200 LOC and gaining substantial type safety". + +Opzione C — **drop "remove_method :[]"**. È un trick per nascondere il +costruttore positional di `Data.define`. Se accetti `Foo.new(a: ..., b: ...)` +ovunque, perdi 8 righe per file. Ma perdi anche default kwargs e la firma +esplicita del costruttore pubblico. Trade-off non vincente. + +**La mia raccomandazione**: Opzione B. Lasciare il pattern, dichiararlo +intenzionale in `CLAUDE.md`, e considerare il claim "DRY" come "DRY dove non +costa metaprogramming". + +### 3.2 Priorità ALTA — `Effects::Await` chiamato "monade" senza esserlo + +**Problema**: `effects/await.rb:5` "Monad-like step helper". Falso. Vedi +§1.2. + +**Fix proposto** (10 minuti): + +```ruby +# Helper to translate an effect snapshot into a legal step result. On +# `:succeeded` it yields the effect result to a continuation; on +# `:failed_terminal` it returns Failure; on `:failed_retriable` and +# `:reserved`/`:dispatching`/missing it returns Waiting. +# +# This is NOT a monad: there is no `bind`/`pure`/`flat_map` and two +# `Await.call` cannot be composed without going through `Result#and_then`. +# The block return value is type-checked at the boundary (line 26) so +# the contract stays explicit. +``` + +E rinomina mentalmente: non è `Await` come un Future, è +`Effects.translate_snapshot` con continuation. + +### 3.3 Priorità MEDIA — `RunResult` non valida `state` + +**Problema**: `run_result.rb:27-37` accetta qualsiasi Symbol come `state`. +Tutti gli altri value object validano i campi enumerati con +`Validation.member!`. RunResult no. + +**Costruttore unico**: solo il Runner costruisce `RunResult` +(`runner.rb:471-477`), e passa solo i 4 valori validi +(`:completed | :paused | :waiting | :failed`). Ma se la libreria espone +`RunResult.new` come API pubblica (lo è — `@api public` a `run_result.rb:7`), +un caller esterno può costruirne uno con stato spazzatura. + +**Fix** (3 righe): + +```ruby +# Aggiungi nelle costanti pubbliche o in run_result.rb +RUN_RESULT_STATES = %i[completed paused waiting failed].freeze + +# In initialize +DAG::Validation.member!(state, RUN_RESULT_STATES, "state") +``` + +### 3.4 Priorità MEDIA — `canonical_committed_attempt` ottimizzazione locale fragile + +**Problema**: `runner.rb:393-412`. 19 righe di mutation loop con tre +variabili (`best`, `best_id`, `candidate_id`) per evitare l'allocazione di un +Array intermedio. Il commento (`runner.rb:388-392`) **giustifica** invece di +**spiegare**. + +**Quanto vale l'ottimizzazione?** Per un workflow con 100 nodi e ~3 attempts +per nodo, sono ~300 array di 2 elementi e ~300 stringhe per `Runner#call`. +Su qualunque profilo realistico, è polvere. + +**Quanto costa?** Ogni reviewer deve fermarsi 30 secondi a verificare che lo +stato `best_id ||= best.fetch(:attempt_id).to_s` non leak across iterazioni +quando `best` viene riassegnato (linea 401: `best_id = nil` lo resetta — +corretto, ma serve il check mentale). + +**Proposta alternativa**: + +```ruby +def canonical_committed_attempt(attempts) + attempts + .select { |a| a[:state] == :committed } + .max_by { |a| [a.fetch(:attempt_number), a.fetch(:attempt_id).to_s] } +end +``` + +3 righe. Equivalente. Più lente di 1-2 microsecondi. **Migra l'ottimizzazione +allo storage adapter** (`Memory::Storage` la fa con un dispatch al +`storage_state`; SQLite la farà con `ORDER BY attempt_number DESC, +attempt_id DESC LIMIT 1`). Il Runner non dovrebbe sapere. + +### 3.5 Priorità MEDIA — `effective_context` con fallback O(predecessori) per nodo + +**Problema**: `runner.rb:353-379`. Se lo storage non override +`list_committed_results_for_predecessors` (default port → fallback), il +Runner fa una query `list_attempts` per **ogni predecessore**, per **ogni +nodo eseguito**. Per un layer con N nodi e M predecessori medi, sono +O(N×M) round-trips a storage. + +Il fast-path (`storage_overrides?` linea 367) salva la situazione su Memory +adapter; ma lascia il default port debole. + +**Proposta**: + +- Spostare `list_committed_results_for_predecessors` da extension opzionale + a metodo richiesto del port `Ports::Storage`. +- L'implementazione default può vivere come module helper riusabile dagli + adapter, non come fallback runtime nel Runner. + +Costo: refactoring del port (`ports/storage.rb`, ~5 righe), eliminazione di +`storage_overrides?` (3 righe in `runner.rb:382-386`), semplificazione di +`committed_results_for_predecessors` a singolo path. + +### 3.6 Priorità MEDIA — `handle_outcome` mescola decisione e side-effect + +**Problema**: `runner.rb:227-265`. Il `case result` decide simultaneamente: + +1. Lo stato del nodo (`:committed | :pending | :waiting | :failed`). +2. Il tipo di evento (`:node_committed | :node_failed | :node_waiting`). +3. Il payload dell'evento (3 forme diverse). +4. Se il workflow deve transire a `:paused`/`:failed`. +5. Cosa restituire al loop (`:continue | :paused | :failed_terminal`). + +E IO immediato: `commit_and_emit` chiama storage + event_bus. + +Test isolati della logica di retry sono difficili: per testare "fallimento +retriable con budget esaurito → terminale" devi simulare storage che +risponde a count_attempts, begin_attempt, commit_attempt, +transition_workflow_state — è quasi un test integrazione. + +**Proposta**: separare in due metodi: + +```ruby +# pure: input result + budget → output decision +def decide_outcome(result, attempt_number, max_attempts) + case result + when DAG::Success + Decision.committed_success(result) + when DAG::Waiting + Decision.waiting(result) + when DAG::Failure + if result.retriable && attempt_number < max_attempts + Decision.retriable_failure(result) + else + Decision.terminal_failure(result) + end + end +end + +# impure: applies the decision +def apply_outcome(run, node_id, attempt_id, attempt_number, decision) + ... +end +``` + +Costo: +30-40 LOC, ma test della decisione diventano property-style. + +Trade-off: il codice attuale è denso ma in **un solo posto**. Se preferisci +"un posto solo per la verità" sopra "testabilità isolata" — non ti sbagli. +Antirez approverebbe entrambi gli approcci, dipende dalla traiettoria +prevista del codice (più stati di outcome → più valore della separazione). + +### 3.7 Priorità BASSA — `storage_state.rb` monolitico (760 LOC) + +Confessato dal codice stesso (commento iniziale + `CLAUDE.md`). È il punto +dove l'immutabilità si rompe per design. Quando arriverà SQLite (S0 in +`ROADMAP.md`), questo file sarà la blueprint. + +**Proposta**: split per dominio prima di S0. Workflow CRUD, Attempt CRUD, +Effect ledger, Event log → 4 file da ~150-200 LOC. Niente cambio di +comportamento, solo chiarezza architettura. La porta `Ports::Storage` +(`ports/storage.rb`, 338 LOC) è già il contratto unico — facile splittare +l'adapter dietro. + +Costo: ~2h di refactoring meccanico, zero rischio. + +### 3.8 Priorità BASSA — Snapshot `Effects::Record#to_snapshot` leak + +**Problema**: `record.rb:223-225` espone `payload_fingerprint`, +`not_before_ms`, `external_ref` agli step via `metadata[:effects]`. Sono +campi infrastrutturali (lease, idempotenza). Lo step **può** ignorarli, ma +li vede. + +**Proposta**: filtra `RECORD_SNAPSHOT_FIELDS` (`record.rb:5-19`) ai soli +campi semantici (`id`, `ref`, `type`, `key`, `payload`, `blocking`, `status`, +`result`, `error`, `metadata`). Esclude `payload_fingerprint` (idempotenza +storagica), `external_ref` (lease integration). 4 righe. + +Trade-off: rompe API se qualcuno già legge `payload_fingerprint` dallo +snapshot. Vista la fase alpha (memoria utente), accettabile. + +### 3.9 Priorità BASSA — Ridondanza `Intent` → `PreparedIntent` → `Record` + +`type, key, payload, metadata` sono campi in tutti e tre. La +`prepared_intent.rb:69-85 from_intent` e `record.rb:101-143 from_prepared` +mitigano il problema (factory che lifta). Ma: + +- `validate_ref_part!` è chiamato in `intent.rb:23-24`, + `prepared_intent.rb:105-106`, `record.rb:173-174`. +- Validazione di `payload` come json_safe è in tutti e tre. +- Validazione di `type, key` come stringhe è in tutti e tre. + +**È evitabile?** Solo con composition (`Record { intent:; durability:; ... }`) +che richiederebbe accessor delegation per ergonomia (`record.type` al posto +di `record.intent.type`). In Ruby, `Forwardable.def_delegators` fa il lavoro, +ma aggiunge una dipendenza concettuale. + +**Verdetto**: trade-off accettabile. La duplicazione è regolare, validata, +e ogni livello aggiunge campi reali. Cambierei solo se cambiassi il modello +(es. astrarre `EffectIdentity = Data.define(:type, :key, :payload, :metadata)` +e tenerlo come campo in PreparedIntent/Record). Non è un cambio piccolo — +non lo farei senza un trigger forte. + +--- + +## 4. Proposte concrete che rispettano la vision + +Ordinate per costo/beneficio: + +| # | Cambiamento | Costo | Vision-aligned | +| --- | -------------------------------------------------------------------------------------------- | ------ | ----------------------------------------- | +| 1 | Rinomina commento `Effects::Await` "Monad-like" → "Effect snapshot dispatcher" (§3.2) | 10min | sì — onestà semantica | +| 2 | `Validation.member!(state, RUN_RESULT_STATES)` in `RunResult#initialize` (§3.3) | 15min | sì — DRY/uniformità | +| 3 | Filtra campi infrastrutturali da `Record#to_snapshot` (§3.8) | 30min | sì — separazione semantica/infra | +| 4 | Sostituisci `canonical_committed_attempt` con `select.max_by` (§3.4) | 20min | sì — leggibilità sopra micro-perf | +| 5 | Promuovi `list_committed_results_for_predecessors` a port-required (§3.5) | 1h | sì — semplifica fast/slow path | +| 6 | Documenta CoW limitato a value objects + storage-CoW pendente di SQLite (§1.4) | 30min | sì — onestà del claim | +| 7 | Documenta in `CLAUDE.md` che il boilerplate Data.define è intenzionale (§3.1) | 30min | sì — chiude il falso claim DRY | +| 8 | Split `storage_state.rb` per dominio prima di S0 (§3.7) | 2h | sì — preparazione SQLite | +| 9 | Separa `decide_outcome` da `apply_outcome` in Runner (§3.6) | 2h | dipende — testabilità sopra densità | +| 10 | Rimuovi `REVIEW.md` o spostalo in `docs/legacy/` | 5min | sì — pulizia working tree | + +Totale "low-hanging" (1-7): ~3h. Sposterebbero la honest-claim count da 3/7 +a 6/7 e migliorerebbero leggibilità senza toccare il design. + +--- + +## 5. Verdetto stile Antirez + +Codice **serio**, scritto da chi conosce sia Wadler sia il fatto che in +produzione i bug arrivano alle 3 di notte. Tre marcatori: + +1. **`Result.assert_result!`** beccare il bug più comune al boundary del + block — questo è esperienza, non teoria. +2. **Cop custom** (`Dag/NoThreadOrRactor`, `Dag/NoMutableAccessors`, + `Dag/NoInPlaceMutation`, `Dag/NoExternalRequires`) — la disciplina è + enforced, non confidata. +3. **`finalize` con commento di design** (`runner.rb:435-453`) — surface + `:failed` con diagnostic invece di forgive a `:waiting`. Onestà sopra + eleganza. + +Ma **non chiamiamolo "monadi e bilanciamento OOP/FP"**. Sono parole. Quello +che hai è: + +- Un kernel deterministico immutabile, con value objects ben validati e + congelati ai confini. +- Una mini-monade legittima su `Success | Failure`. +- Un Runner orchestratore OOP, denso ma coerente. +- Un'astrazione effect ledger con due strati di lift (Intent → + PreparedIntent → Record) ridondanti ma funzionali. +- Uno storage in-memory mutable single-process, dichiarato come tale. + +**Niente bug critici. Tre debt manageable**: + +1. Boilerplate Data.define ripetuta (~200 righe). Vivila o documentala. +2. Una manciata di overstatement nei commenti / vision (Await "monad-like", + CoW per concorrenza). Riformula. +3. Un'ottimizzazione locale (`canonical_committed_attempt`) e un fallback + O(N) (`effective_context`) che andrebbero spostati al port. + +**Vision validata con due correzioni**: + +1. **Sii preciso sui monadi**: hai uno solo (`Result`). `Effects::Await` è + un dispatcher su snapshot di effetti. Non è meno utile, è solo un'altra + cosa. Cambia il vocabolario, non il design. +2. **Decidi cosa significa "concorrenza futura"**: se è multi-thread Ruby + in MRI, il GIL ti salva e CoW dei value objects è sufficiente per la + parte "dati". Lo storage rimane single-writer. Se è multi-process o + multi-host (S0+), il design del port `Storage` deve esprimere CAS o + versioning — e non lo fa ancora. Definisci il target. + +Tutto il resto — DRY, immutabilità, idiomaticità, zero deps, ports, tests — +**regge**. + +Useresti questa libreria? Sì, se il dominio è "workflow deterministico +single-process con futuro durable adapter". No, se ti aspetti +multi-process out-of-the-box. + +Stato della codebase: **production-ready alpha** per il workload dichiarato. +Niente da riscrivere. Una lista di nit di tre ore di lavoro per chiudere il +gap tra il codice e la sua narrazione. + +--- + +## 6. Verifiche fatte (per onestà del processo) + +Prima di scrivere questa review ho controllato di persona, perché due dei +tre agent di esplorazione hanno fornito informazioni inesatte: + +| Verifica | Risultato | +| -------------------------------------------------------------- | -------------------------------------------------------- | +| `Graph#each_successor` esiste? | ✅ sì, `graph.rb:400` (un agent sosteneva il contrario) | +| `Graph#each_predecessor` esiste? | ✅ sì, `graph.rb:394` | +| `REVIEW.md` è sul progetto attuale? | ❌ no, è di un altro progetto (`Steps::Exec`, etc.) | +| `Result` è una vera mini-monade? | ✅ sì, su Success+Failure (Waiting escluso by design) | +| `Effects::Await` è una monade? | ❌ no, è un dispatcher con continuation | +| `RunResult` valida `state`? | ❌ no, `run_result.rb:27-37` non lo controlla | +| `frozen_copy` è usato disciplinatamente? | ✅ sì, 36+ occorrenze in `lib/dag/**` | +| `Validation` è centralizzato? | ✅ sì, `validation.rb` 14 helper, usato uniformemente | +| `storage_state.rb` è davvero l'unica zona mutabile? | ✅ sì, dichiarato + cop `Dag/NoInPlaceMutation` | + +Quindi: la review è basata su lettura diretta dei file, non solo su sintesi +degli agent. Le claim citate hanno tutte un `file:line` controllato. diff --git a/docs/reviews/codex-review-v1.0.1.md b/docs/reviews/codex-review-v1.0.1.md new file mode 100644 index 0000000..15f2a8a --- /dev/null +++ b/docs/reviews/codex-review-v1.0.1.md @@ -0,0 +1,454 @@ +# ruby-dag v1.0.1 - Codex Review + +Reviewed on: 2026-05-02 +Scope: repository state on disk, not a clean release tag. The working tree had +local edits and untracked review artifacts at review time. + +This is an honest engineering review of `ruby-dag` as a deterministic Ruby DAG +execution kernel. It is not a marketing review. + +## Verdict + +`ruby-dag` is a solid kernel for the scope it declares: deterministic workflow +execution over immutable definitions, explicit ports, durable event semantics, +retry/resume behavior, structural mutation, and an abstract effect ledger. It +is not a general-purpose workflow platform, and it should not try to become one +inside this gem. + +The project is stronger than average because its hard constraints are enforced +in code and tests, not just described in prose: + +- zero runtime dependencies in the gemspec; +- Ruby `>= 3.4`; +- explicit seven-port `Runner`; +- no `Thread`, `Ractor`, `Mutex`, `Queue`, `Process.spawn`, `system`, or + backticks in `lib/dag/**`; +- immutable public value objects via `Data.define` and defensive frozen copies; +- shared storage contract specs for adapter behavior. + +The main risk is not sloppy code. The main risk is scope gravity. The storage +port and memory storage state have become the real center of the system. If the +next phases keep adding behavior to that boundary, the library will drift from +"small deterministic kernel" to "workflow engine database contract", with a +large adapter tax for every consumer. + +## Verification + +Commands run during review: + +```bash +bundle exec rake +bundle exec ruby scripts/production_readiness.rb --fast --duration 5 --progress-interval 2 +``` + +Observed results: + +- `bundle exec rake`: 515 tests, 39885 assertions, 0 failures, 0 errors. +- Standard/RuboCop custom cops: clean. +- YARD: 99.08% documented. +- Production readiness fast probe: pass after 5 seconds. + +I also checked runtime anti-patterns with repository search. No forbidden +thread/ractor/process primitives or `attr_accessor` were found under +`lib/dag/**`. Runtime `require` usage is stdlib-only: `securerandom`, `digest`, +and `json`. + +## Vision + +Your vision is mostly correct, but not all parts have the same truth value. + +### Zero external dependencies + +Correct for the kernel. This is one of the best decisions in the project. It +keeps the public gem easy to audit, easy to embed, and hard to accidentally +turn into a framework. + +Do not extend this dogma to durable infrastructure. SQLite, PostgreSQL, Redis, +HTTP clients, queue systems, and observability integrations belong outside this +gem. The current decision to keep SQLite outside the zero-dependency kernel is +right. + +### Monads + +Correct if kept small. `Success` / `Failure` as a `Result` pair is useful: +`and_then`, `map`, `recover`, `unwrap!` are enough. + +It is also correct that `Waiting` is not part of `DAG::Result`. Waiting is a +state-machine outcome, not just a value-level failure or success. Making it a +third monadic branch would make the public API look elegant while hiding an +important runtime distinction. + +Do not add a large monadic vocabulary unless the project itself uses it. More +methods would make the library less Ruby-like without clearly improving the +kernel. + +### Immutable data types + +Correct at the public and pure-kernel boundaries. `Data.define` plus +`DAG.frozen_copy` is a good fit for Ruby 3.4. + +The current exception is also correct: `DAG::Adapters::Memory::StorageState` is +mutable. Pretending storage internals are immutable would add ceremony and +probably bugs. The important rule is that mutation stays behind the port and +callers never receive live mutable references. + +### Copy-on-write for future concurrency + +Partly correct. Copy-on-write and frozen values reduce aliasing, make execution +more deterministic, and make future concurrency less dangerous. + +They do not give you concurrency by themselves. Future concurrency will depend +on storage atomicity, compare-and-set boundaries, leases, and durable adapter +behavior. The project already recognizes this in methods like +`prepare_workflow_retry`, `transition_workflow_state(event:)`, +`append_revision_if_workflow_state`, and effect lease operations. + +### Balance between OOP and functional programming + +This is one of the better parts of the design. The split is natural: + +- OOP for ports, adapters, runner, dispatcher, mutation service; +- functional/value style for definitions, graph transformations, results, and + effect intents; +- explicit state transitions instead of hidden object mutation. + +That is idiomatic Ruby. It is not "functional Ruby cosplay". + +### Ruby idiomatic + +Mostly yes. Keyword arguments, small objects, `Data.define`, block builders, +explicit error classes, and straightforward service objects are idiomatic. + +The main place where idiomatic Ruby is at risk is not the current code, but the +future direction: if monads, validation helpers, or storage extensions become +too generic, the project will start to feel like a private framework. + +### DRY + +The DRY direction is good: `DAG::Validation` and `DAG.frozen_copy` remove +boring repeated boundary code. + +But DRY should not hide contract logic. In this project, explicitness matters +more than clever reuse. The storage contract, runner transitions, and effect +lease semantics should stay readable even if that means some local repetition. + +## What Works Well + +### The kernel has real boundaries + +`DAG::Runner` requires all seven dependencies explicitly: + +- `storage` +- `event_bus` +- `registry` +- `clock` +- `id_generator` +- `fingerprint` +- `serializer` + +There are no hidden singleton defaults in `Runner.new`. This makes tests, +durable adapters, and future host integration much cleaner. + +Reference: `lib/dag/runner.rb:54`. + +### Crash-resume semantics are treated seriously + +The project does not hand-wave durability. The runner and storage contract +separate attempt commit, workflow terminal transition, retry preparation, +mutation revision append, and effect completion into explicit atomic +boundaries. + +Good examples: + +- `commit_attempt(..., effects: [])` commits result, node state, event, and + effect reservations together. +- `transition_workflow_state(..., event:)` closes the crash window between a + terminal workflow state and its terminal event. +- `prepare_workflow_retry` owns the retry-budget check and reset operation. +- `complete_effect_succeeded` / `complete_effect_failed` close the mark/release + crash gap for effects. + +This is the right kind of complexity. It exists because the failure modes are +real. + +### The effect design is abstract in the right way + +The kernel reserves effect intents and coordinates dispatch, but concrete side +effects stay in host handlers. That is the right split. + +The public promise in the README is also honest: + +```text +exactly-once durable effect intent reservation ++ at-most-once successful effect recording per (type, key) ++ lease-protected dispatch ++ effectively-once external side effects through host handlers +``` + +The first three are kernel concerns. The last one belongs to consumers. Good. + +### Tests are not superficial + +The suite is broad for a small gem: + +- graph behavior and fuzzing; +- zero runtime dependencies; +- public require; +- custom RuboCop cops; +- runner behavior; +- retry and resume; +- effect value objects; +- effect dispatcher; +- shared storage contract; +- mutation service and stale revision behavior; +- crashable storage tests. + +The shared storage contract under `spec/support/storage_contract/**` is +especially important. Without that, the storage port would just be prose. + +### The project is honest about memory adapters + +The memory adapters are documented as single-process. That prevents a common +Ruby mistake: pretending an in-memory adapter is a cheap substitute for durable +coordination. + +## Findings + +### 1. Medium-high: the storage port is now the real kernel + +`lib/dag/ports/storage.rb` is canonical, but it is large. It covers: + +- workflow rows; +- definition revisions; +- node states; +- attempts; +- durable events; +- crash resume; +- workflow retry; +- mutation CAS; +- effect reservation; +- effect lookup; +- effect claim leases; +- effect completion; +- waiting-node release; +- batched predecessor result lookup. + +Reference: `lib/dag/ports/storage.rb:23`. + +This is coherent, but expensive. A durable adapter must implement a lot before +it is useful. That can slow adoption and make the first non-memory adapter a +major project rather than a small integration. + +I would not split the port casually now, because the repo rules correctly say +the port shape is canonical. But I would freeze the growth of this interface +hard. New behavior should have to prove it cannot be expressed by existing +atomic boundaries. + +Concrete proposal: + +- Add an adapter capability matrix in docs: "runner core", "resume", "mutation", + "effects", "dispatcher". +- Keep the canonical port, but document which method groups are required for + which public feature. +- Before adding another storage method, require a short contract note explaining + the crash or stale-read window it closes. + +### 2. Medium: effect idempotency conflicts can leave workflows operationally stuck + +The storage contract correctly rolls back `commit_attempt` if effect +reservation detects a fingerprint conflict. The test asserts this explicitly: +after `DAG::Effects::IdempotencyConflictError`, the second attempt remains +`:running`, the node remains `:running`, no event is appended, and no effect +link is created. + +Reference: `spec/support/storage_contract/effects.rb:87`. + +That is atomic and defensible at the storage layer. But at the runner layer it +is harsh. A deterministic step that reuses the same `(type, key)` with a +different payload can repeatedly hit the same conflict. Resume can abort the +running attempt and reset the node, but the next run will likely produce the +same conflict again. + +This is not data corruption. It is an operational dead end. + +Concrete proposal: + +- In `Runner`, catch `DAG::Effects::IdempotencyConflictError` around + `commit_attempt` for step outcomes with effects. +- Convert it into a non-retriable node failure and workflow failure using the + same durable event path as normal failures. +- Keep the storage-level rollback guarantee as-is. +- Add a runner spec proving the workflow ends in `:failed` with a structured + error payload instead of leaking a `:running` node. + +This preserves the effect safety invariant while giving operators a terminal, +inspectable workflow state. + +### 3. Medium: public value-object validation is uneven + +Some public values validate aggressively. Others trust callers more than the +contract implies. + +Examples: + +- `DAG::StepInput` validates `metadata` JSON safety, but not that `context` is + a `DAG::ExecutionContext`, that `node_id` is symbol/string-like, or that + `attempt_number` is positive. + Reference: `lib/dag/step_input.rb:27`. +- `DAG::Event` validates `type` and `payload`, but not `workflow_id`, + `revision`, `node_id`, `attempt_id`, `seq`, or `at_ms`. + Reference: `lib/dag/event.rb:38`. +- `DAG::RuntimeProfile` validates durability and retry counts, but not + `event_bus_kind`. + Reference: `lib/dag/runtime_profile.rb:40`. +- `DAG::RunResult` validates JSON safety for `outcome` and `metadata`, but not + `state` or `last_event_seq`. + Reference: `lib/dag/run_result.rb:27`. + +This does not currently break the runner, because the runner constructs these +objects correctly. But these are public API objects. If the project advertises +them as contract values, they should reject malformed values consistently. + +Concrete proposal: + +- Add narrow validation helpers where missing: + - optional nonnegative integer; + - workflow id string; + - optional node id; + - workflow state enum; + - event bus kind enum or documented opaque symbol. +- Tighten `StepInput`, `Event`, `RunResult`, and `RuntimeProfile`. +- Add tests in `spec/r1/types_validation_test.rb`. + +This is the sort of fix that pays off later when external adapters and hosts +start constructing values directly. + +### 4. Medium-low: `Memory::StorageState` is doing too much + +`lib/dag/adapters/memory/storage_state.rb` is about 760 lines and owns every +mutable in-memory concern. + +Reference: `lib/dag/adapters/memory/storage_state.rb:12`. + +The design reason is valid: keep mutation in one allowed place and make the +facade return frozen copies. But the file now contains multiple subsystems: + +- workflow lifecycle; +- revision append; +- attempts; +- event log; +- effect ledger; +- lease claim/mark; +- waiting-node release; +- retry reset; +- internal validations. + +This is still manageable today. It will become brittle if more features land in +the same module. + +Concrete proposal: + +- Keep the public `DAG::Adapters::Memory::Storage` facade unchanged. +- Split internal implementation by concern under `lib/dag/adapters/memory/`, + for example lifecycle, attempts, effects, and events. +- Preserve one mutable state hash and one public facade. +- Do this as a no-behavior-change refactor only after the next patch-level + correctness work, not during feature work. + +### 5. Medium-low: large graph builder exists but is not consistently used + +`DAG::Workflow::Definition::Builder` exists specifically for bulk construction. + +Reference: `lib/dag/workflow/definition/builder.rb:7`. + +But `scripts/production_readiness.rb` still builds large scenarios through the +immutable chain API: + +Reference: `scripts/production_readiness.rb:770`. + +That makes the performance probe less representative of the intended fast path +and pays avoidable copy-on-write cost while generating test graphs. + +Concrete proposal: + +- Update production readiness large-graph scenarios to use + `DAG::Workflow::Definition::Builder`. +- Keep the chainable API in README for small examples. +- Add one focused test that the builder and chain API produce equivalent + definitions for a simple graph. + +This respects the immutable public model while using the mutable-local builder +where it was meant to be used. + +### 6. Low: stale untracked review artifacts can mislead future work + +There is an untracked `REVIEW.md` in the repository root. Its content appears +to describe an older version of the project with `Exec`, parallel strategies, +file read/write steps, and other code that is not part of the current kernel. + +Reference: `REVIEW.md:1`. + +This is not a runtime bug. It is review hygiene. If a future agent or engineer +reads that file first, they will form the wrong mental model. + +Concrete proposal: + +- Delete it if it is obsolete. +- Or move it under a historical notes directory with a clear "obsolete" header. +- Do not leave it in root with the same apparent authority as `CONTRACT.md`. + +## Non-Issues + +### `Waiting` not being a monad is correct + +It may look asymmetric that `Success` and `Failure` include `DAG::Result` while +`Waiting` does not. That is the right choice. Waiting is a workflow parking +state with storage implications; treating it like a normal result value would +make the runtime less explicit. + +### The mutable memory adapter is acceptable + +The vision says immutable data and copy-on-write. It does not require an +immutable in-memory database. `StorageState` mutating hashes internally is fine +because the port boundary protects callers from shared mutable state. + +### No internal threads is a strength, not a weakness + +The kernel should not run its own scheduler. Deterministic execution against +ports is the point. Parallel dispatch, durable workers, and concrete external +systems belong in hosts or adapters. + +## Suggested Priority + +### v1.0.2 candidate fixes + +1. Convert effect idempotency conflicts in `Runner` into durable terminal + workflow failures instead of leaving the workflow operationally stuck. +2. Tighten public value-object validation for `StepInput`, `Event`, + `RunResult`, and `RuntimeProfile`. +3. Update production readiness large-graph scenarios to use + `Definition::Builder`. +4. Remove or quarantine obsolete root-level review artifacts. + +### Later refactors + +1. Split `Memory::StorageState` internally by concern without changing the + public facade. +2. Add adapter capability documentation for the storage port. +3. Keep resisting new storage methods unless they close a concrete atomicity or + crash-recovery hole. + +## Final Assessment + +The project is good. Not "good for a personal project"; good in the stricter +sense that the core constraints are visible, tested, and mostly enforced. + +The vision is valid, with one correction: copy-on-write is not a concurrency +strategy. It is a discipline that makes concurrency less dangerous once the +real concurrency control exists in storage. + +The next danger is not that the code becomes messy. The next danger is that the +contract becomes too large to implement comfortably. Keep the kernel small, +make every new atomic boundary justify itself, and push concrete infrastructure +outside the gem. + diff --git a/docs/reviews/final-review-v1.0.1-claude-opus-4-7.md b/docs/reviews/final-review-v1.0.1-claude-opus-4-7.md new file mode 100644 index 0000000..f59af87 --- /dev/null +++ b/docs/reviews/final-review-v1.0.1-claude-opus-4-7.md @@ -0,0 +1,799 @@ +# ruby-dag v1.0.1 — Final Review (meta-review verificata, stile Antirez) + +> Sintesi delle 5 review parallele (`claude`, `codex`, `gemini`, +> `opencode-deepseek-v4-flash`, `opencode-kimi-k2.6`), con ogni claim +> verificata di prima persona contro il codice. Niente fiducia per delega: +> ogni `file:line` qui è stato controllato. Quando i reviewer divergono, decide +> il file, non la maggioranza. + +--- + +## TL;DR + +Il kernel è **serio**: zero deps, value object immutabili, custom cop per gli +anti-pattern, contract test condivisi sullo storage, fingerprint stability +verificato a 100 run. Niente bug critici. Tre debt strutturali manageable +(boilerplate `Data.define`, port storage grasso, `StorageState` monolitico) e +**due overstatement nella vision** (CoW per concorrenza, "monadi" plurale). + +Tre dei cinque reviewer sono accurati nei dettagli, due hanno claim +falsificate (DeepSeek su event types, DeepSeek su `RuntimeProfile.defaults`, +Kimi su "anti-idiomatico" `module_function`). Claude sbaglia il count di +`storage_overrides?` (2 invece di 3 occorrenze). + +**Stato**: production-ready alpha per il workload dichiarato (workflow +deterministico single-process con futuro durable adapter). Da non spedire come +"workflow engine multi-process out-of-the-box". + +--- + +## 1. Validazione vision — 7 claim + +| Claim | Verdetto | Riferimento principale | +| -------------------------- | ------------- | ---------------------------------------------------------------------------------- | +| Zero dipendenze esterne | ✅ vero | `ruby-dag.gemspec` senza runtime deps; `lib/dag.rb` solo relative; require stdlib | +| Monadi | ⚠️ parziale | `Result` è una mini-monade; `Effects::Await` è un dispatcher con continuation | +| Tipi dati immutabili | ✅ vero | `Data.define` 16+, `frozen_copy` 36+, `runner.rb:66` freeze finale | +| CoW per concorrenza futura | ⚠️ parziale | Vero per i value object; lo storage è single-writer dichiarato | +| Bilanciamento OOP/FP | ⚠️ parziale | Stratificazione, non bilanciamento (FP per i valori, OOP per l'orchestrazione) | +| Ruby idiomatico | ✅ vero | `Data.define`, kw args, `private_constant`, niente `attr_accessor`/method_missing | +| DRY | ⚠️ parziale | DRY per `Validation` e `frozen_copy`; non DRY per la forma costruttore Data.define | + +### 1.1 Zero deps — vero + +`lib/dag.rb` carica solo file relativi. I require runtime sono solo stdlib: +`securerandom`, `digest`, `json`. Il gemspec non dichiara runtime deps. Tutti +e cinque i reviewer convergono. **Reggesi al 100%.** + +### 1.2 Monadi — parziale, lessico da correggere + +`Result` è una mini-monade legittima: + +- `result.rb:25` — `module Result` marker incluso da `Success` (`success.rb:11`) + e `Failure` (`failure.rb:9`). +- `success.rb:61-63` — `and_then` con `Result.assert_result!` al boundary. +- `failure.rb:58-60` — `recover` simmetrico. +- `result.rb:21-24` — la lista esplicita dei metodi **non** inclusi (`tap`, + `map_error`, `value_or`, `tap_error`) è disciplina, non religione. +- `result.rb:54-57` — `assert_result!` raise se il block non torna un `Result`. + Questo becca il bug più comune di chi inizia con monadi (forgot to wrap). + È **buona ingegneria**. + +**`Waiting` non include `Result`** (`waiting.rb` linee 9-69, nessun +`include Result`). Codex e DeepSeek sono d'accordo: **scelta corretta**, non +bug. `Waiting` è control-flow di runtime (parking + resume token), non valore +monadico. Mescolarlo nel chain renderebbe la semantica meno esplicita. + +`Effects::Await` (`effects/await.rb:5`) è documentato come "Monad-like step +helper". **Falso**. Niente `bind`, niente `pure`, niente composizione +`Await ∘ Await`. È un dispatcher che mappa snapshot di effetti +(`succeeded | failed_terminal | failed_retriable | reserved/pending`) in +risultati legali (`Success | Waiting | Failure`), con un `yield` per la +continuation. Va benissimo come helper — pessimo come "monade". + +**Azione (10 minuti)**: cambia il commento a `effects/await.rb:5` da +"Monad-like step helper" a "Effect snapshot dispatcher with continuation". Non +cambiare il codice. Lo dice anche Claude review §3.2. + +### 1.3 Tipi immutabili — vero, eseguito con disciplina + +Verificato in modo capillare: + +- `Data.define` ovunque (Edge, Event, RunResult, RuntimeProfile, StepInput, + Success, Failure, Waiting, Intent, PreparedIntent, Record, ProposedMutation, + ReplacementGraph, RunContext, DispatchReport, HandlerResult — 16+). +- `immutability.rb:16-19` — `frozen_copy` evita `deep_freeze(deep_dup(...))` + inline, usato 36+ volte ai confini dei costruttori. +- `runner.rb:66` — `freeze` finale del Runner. +- `runner.rb:142-161` — `RunContext` frozen, `predecessors_by_node` frozen e + cache pre-computata una volta per `#call`. +- `graph.rb` — frozen graph eagerly cacha layers, sort, roots, leaves, edges. + +**Buco onesto, dichiarato dal codice**: `adapters/memory/storage_state.rb` è +mutable (760 LOC). Il commento in cima a `storage_state.rb:6-13` lo dichiara +esplicitamente come "the only spot in `lib/dag/**` allowed to mutate hashes +in place"; il cop `Dag/NoInPlaceMutation` esenta solo questa cartella. È +ammissione, non occultamento. Codex e Claude convergono: **scelta corretta**. + +**Nitpick verificato di Kimi** (review §1.3 "Tipi immutabili"): +`immutability.rb:17` — `frozen_copy` accetta `value if value.frozen? && !value.is_a?(Hash) && !value.is_a?(Array)`. +Tecnicamente, un `Set` frozen contenente elementi mutabili non sarebbe +visitato. **Vero ma irrilevante**: nel kernel non vengono mai usati Set di +oggetti mutabili — i `Set` interni a `Graph` contengono Symbol (immutabili) e +sono freezati esplicitamente in `graph.rb:freeze`. Da non fixare senza un +caso reale. + +### 1.4 CoW per concorrenza futura — overstatement + +**Vero per i value object**: ogni mutazione di `Definition` / `Graph` / +`ExecutionContext` ritorna una nuova istanza congelata. Una `Definition` o +`Graph` frozen è safe da condividere tra thread. + +**Falso per lo stato durabile**: `storage_state.rb:100-108` (e analoghi) +contengono read-modify-write loops senza alcun meccanismo di concorrenza: + +```ruby +row = fetch_workflow!(state, id) +unless row[:state] == from + raise StaleStateError, ... +end +row[:state] = to +``` + +Due thread che chiamano `transition_workflow_state` sullo stesso workflow in +parallelo → race condition con lost update. Il commento del file dichiara +"single-process" — è onesto. Ma "CoW per concorrenza futura" implica un +design di concorrenza che **non è ancora stato disegnato per lo storage +in-memory**. Sui port durable (S0+ in roadmap), il design è invece presente: +`prepare_workflow_retry`, `transition_workflow_state(event:)`, +`append_revision_if_workflow_state`, `commit_attempt(effects:)`, +`claim_ready_effects` con lease — sono tutti CAS/atomic boundary corretti. + +Codex (§Vision/Copy-on-write) è il più preciso: **CoW non è una strategia di +concorrenza, è una disciplina che la rende meno pericolosa una volta che la +vera concorrenza vive nello storage**. Sottoscrivo. + +**Gemini** ha un punto separato e legittimo: in MRI Ruby non esistono +strutture dati persistenti (Trie/HAMT). `deep_freeze` + `deep_dup` su un +contesto che cresce a ogni step è davvero copia totale, non CoW algoritmico. +Per workflow profondi e contesti grandi, il GC pressure è reale. Ma — come +osserva DeepSeek (§Copy-on-write) — il costo di vero CoW (structural sharing) +non vale il beneficio per workflow di decine/centinaia di nodi, che è il +target dichiarato. **Il claim "CoW" va riformulato in `ImmutableCopy at +boundaries` o "frozen value semantics"**. + +### 1.5 Bilanciamento OOP/FP — stratificazione, non bilanciamento + +Il codice è layered: + +- **Value objects** → FP puro (`Data.define` + `frozen_copy`). +- **`Result`/`Success`/`Failure`** → mini-monade FP. +- **`Step::Base`** → classe OOP con `#call(StepInput) -> Result`. +- **`Runner`** → orchestrator OOP con `RunContext` carrier. +- **`StorageState`** → `module_function` C-style con state injection. + +Niente di sbagliato in questo. È **pragmatismo**. Ma "bilanciamento perfetto" +è retorica. La definizione onesta è quella di Codex: "OOP per ports/adapters +e orchestrazione, FP per i valori". DeepSeek e Kimi convergono. Gemini è il +più ottimista qui ed è quello con meno fondamento sul codice. + +### 1.6 Ruby idiomatico — vero, con un pattern ripetuto + +Conformi al lessico Ruby 3.4: + +- Niente `attr_accessor` (`runner.rb:32-44` solo `attr_reader`, cop + `Dag/NoMutableAccessors` enforce). +- Niente `method_missing`, niente `define_method` magico. +- `Data.define` per ogni value object. +- `enum_for` lazy (`graph.rb:382, 389, 395, 401`). +- `private_constant` (`runner.rb:187, 309`). + +**Pattern ripetuto, non sbagliato**: `class << self; remove_method :[]; def +[]; end; end; def initialize; validate; super; end` ricorre in 9 file. È un +trick noto per sostituire il costruttore positional di `Data.define` con uno +keyword-only validato. Costa ~10-15 righe per file di boilerplate. Vedi §2.4. + +**Nota su `module_function` in `StorageState`** (`storage_state.rb:13`): Kimi +review lo definisce "anti-idiomatico Ruby 3.4. Stai scrivendo C con sintassi +Ruby". **Sbagliato**. Una classe con `@state` interno sarebbe identicamente +mutabile, solo con encapsulation. Il design qui è esplicito: tutto lo stato +viene iniettato dal facade `Memory::Storage`, e **una classe avrebbe nascosto +questo, non risolto**. Il pattern è "explicit state, single mutable site", +non "C with Ruby syntax". È ortogonale a OOP/non-OOP. Lascia stare. + +### 1.7 DRY — parziale + +**DRY dove costa poco**: + +- `validation.rb` — 14 helper, 182 LOC, recentemente refactored (commit + `45b5257`, `480d187`, `95786de`). Ogni costruttore lo invoca al confine. +- `frozen_copy` — 36+ occorrenze. +- `Result.exception_failure` (`result.rb:44-51`) — un solo posto fa + `{code:, message:, error_class:, **extras}`. + +**Non DRY dove costa metaprogramming**: + +- 9 file ripetono il pattern `Data.define` + `class << self; remove_method + :[]` + custom `initialize` (success, failure, waiting, run_result, event, + step_input, intent, prepared_intent, record). +- 3 file ripetono `storage_overrides?` (vedi §2.1). +- 2 classi nello stesso file ripetono `immutable_json_copy` (vedi §2.1). + +`CLAUDE.md` esplicitamente dice "Avoid `method_missing`, broad +metaprogramming, or generic 'validate schema' layers". Questa è scelta +consapevole di **vivere con la duplicazione regolare**. Ma il claim "DRY" +universale va ridimensionato: "DRY per le validation; duplicazione regolare +e accettata per la forma dei value object". + +--- + +## 2. Bug e debt verificati di prima persona + +### 2.1 `storage_overrides?` duplicato in 3 file (non 2) + +| Sito | Righe | Corpo | +| ---------------------------- | ------------ | --------------------------------------------------------------------------- | +| `runner.rb:382-386` | 5 | identico | +| `effects/dispatcher.rb:285-289` | 5 | identico | +| `mutation_service.rb:76-80` | 5 | identico | + +Claude review (§3) e DeepSeek review (§DRY) hanno detto "2 file". **Sbagliato**. +Kimi review (§DRY) ha detto "Runner, MutationService, Dispatcher (3 volte +identico)". **Corretto**. Verificato con `grep -rn "storage_overrides?" +lib/`. + +**Fix (15 minuti)**: spostare in `DAG::Ports::Storage` come module helper +o in `DAG` come `module_function`: + +```ruby +# In lib/dag/ports/storage.rb (o lib/dag.rb) +module DAG + module_function + def storage_overrides?(storage, method_name) + return false unless storage.respond_to?(method_name) + storage.method(method_name).owner != DAG::Ports::Storage + end +end +``` + +I 3 chiamanti diventano `DAG.storage_overrides?(@storage, :foo)`. Costo zero. + +### 2.2 `immutable_json_copy` duplicato in `dispatcher.rb` + +`effects/dispatcher.rb:40-46` (in `HandlerOutcome`) e +`effects/dispatcher.rb:106-112` (in `DispatchOutcome`) — stesso file, due +classi private, corpo identico: + +```ruby +def immutable_json_copy(value) + return nil if value.nil? + return value if value.frozen? + DAG.frozen_copy(value) +end +``` + +DeepSeek review (§DRY) lo ha beccato. **Corretto**. + +**Fix (5 minuti)**: estrarre come `module_function` privato del `Dispatcher` +o convergere su `DAG.frozen_copy(value)` con un `nil` guard inline (la +funzione non aggiunge valore semantico oltre `frozen_copy` + `nil` check). + +### 2.3 `RunResult` non valida `state` + +`run_result.rb:27-37` — accetta qualsiasi Symbol come `state`. Tutti gli +altri value object validano gli enumerati con `Validation.member!` +(`event.rb:39`, `runtime_profile.rb:41`, `record.rb:229-236`). Il +costruttore unico in `runner.rb:471-477` passa solo i 4 valori validi +(`:completed | :paused | :waiting | :failed`), ma `RunResult.new`/`[]` +sono `@api public` (`run_result.rb:7`). + +Claude (§3.3) e Codex (§Findings 3) convergono. + +**Fix (15 minuti)**: + +```ruby +# in run_result.rb +RUN_RESULT_STATES = %i[completed paused waiting failed].freeze + +# in initialize +DAG::Validation.member!(state, RUN_RESULT_STATES, "state") +``` + +### 2.4 Validazione disuguale negli altri value object + +Codex review (§Findings 3) ha la lista più completa, tutta verificata: + +- **`StepInput`** (`step_input.rb:27-36`): valida solo `metadata` json_safe. + Non valida `context` (dovrebbe essere `ExecutionContext`), `node_id` + (Symbol/String), `attempt_number` (positive Integer). +- **`Event`** (`event.rb:38-57`): valida `type` membership e `payload` + json_safe. Non valida `workflow_id` (String), `revision` + (positive Integer), `at_ms` (Integer), `node_id`/`attempt_id` (opzionali), + `seq` (opzionale Integer). +- **`RuntimeProfile`** (`runtime_profile.rb:40-59`): valida `durability`, + `max_attempts_per_node`, `max_workflow_retries`. Non valida + `event_bus_kind` (Symbol o enum). + +Non è un bug attuale (il Runner costruisce questi valori correttamente). Ma +sono `@api public`. Se un host costruisce un `Event` con `revision: "1"` +invece di `1`, il bug si manifesta lontano dal sito di costruzione. + +**Fix (1h)**: aggiungere helper `optional_nonnegative_integer!` e +`workflow_id!` in `validation.rb` e tirare i tre file. Test in +`spec/r1/types_validation_test.rb`. + +### 2.5 Effect idempotency conflict — dead-end operativo + +Codex (§Findings 2) ha l'osservazione più importante e meno ovvia. Verificata +contro `spec/support/storage_contract/effects.rb:87`: dopo un +`IdempotencyConflictError`, l'attempt resta `:running`, il nodo resta +`:running`, nessun evento appeso. Atomic e corretto al livello storage. + +Ma al livello Runner: uno step deterministico che propone +`(type, key)` con payload diverso re-incappa nel conflitto a ogni resume. +Non è data corruption, è un **dead-end operativo** non terminale. + +**Fix (1-2h)**: in `runner.rb` `commit_and_emit` (linea 285) avvolgere la +chiamata a `commit_attempt` in `rescue DAG::Effects::IdempotencyConflictError`, +trasformarlo in un `Failure` non retriable e proseguire al ramo +`:failed_terminal`. Test in `spec/r1/effects_*` mostrano il workflow +terminale invece del nodo `:running`. + +### 2.6 `canonical_committed_attempt` micro-ottimizzazione fragile + +`runner.rb:393-412` — 19 righe di mutation loop con 3 variabili (`best`, +`best_id`, `candidate_id`) per evitare l'array intermedio di `select`. Il +commento (`runner.rb:388-392`) **giustifica** invece di **spiegare**. + +Costo dell'ottimizzazione: ogni reviewer si ferma a verificare che `best_id` +non leak across iterazioni quando `best` viene riassegnato (linea 401: +`best_id = nil` resetta correttamente). + +Beneficio: per workflow con 100 nodi e ~3 attempts/nodo, ~300 array di 2 +elementi e ~300 stringhe per `Runner#call`. Polvere su qualsiasi profilo +realistico. + +Claude (§3.4) e DeepSeek (§Runner.rb) convergono: **migra al port**. Memory +adapter fa già la stessa cosa con `better_committed_attempt?` +(`storage_state.rb:710-719`); SQLite la farà con `ORDER BY attempt_number +DESC, attempt_id DESC LIMIT 1`. Il Runner non dovrebbe sapere come +discriminare attempts. La sostituzione è: + +```ruby +def canonical_committed_attempt(attempts) + attempts + .select { |a| a[:state] == :committed } + .max_by { |a| [a.fetch(:attempt_number), a.fetch(:attempt_id).to_s] } +end +``` + +3 righe. 1-2 microsecondi più lente. Più leggibili. + +### 2.7 Storage port "fat" — port gravity + +`lib/dag/ports/storage.rb` ha **26 metodi** in 338 righe (verificato con +`grep -c '^[[:space:]]*def ' lib/dag/ports/storage.rb`): + +- workflow CRUD (5): `create_workflow`, `load_workflow`, + `transition_workflow_state`, `prepare_workflow_retry`, + `abort_running_attempts` +- definition revisions (4): `append_revision`, + `append_revision_if_workflow_state`, `load_revision`, + `load_current_definition` +- node states (2): `load_node_states`, `transition_node_state` +- attempts (4): `begin_attempt`, `commit_attempt`, `list_attempts`, + `count_attempts` +- effect ledger (8): `list_effects_for_node`, `list_effects_for_attempt`, + `claim_ready_effects`, `mark_effect_succeeded`, `mark_effect_failed`, + `complete_effect_succeeded`, `complete_effect_failed`, + `release_nodes_satisfied_by_effect` +- predecessor results (1): `list_committed_results_for_predecessors` +- events (2): `append_event`, `read_events` + +Codex (§Findings 1), Gemini (§2 Storage Contract), DeepSeek (§ports/storage), +Kimi (§ports/storage) — **convergono tutti**. + +Fa male in due modi: + +1. La barriera per scrivere il primo durable adapter (SQLite/Postgres) è + alta. Ogni metodo ha semantica atomica documentata. +2. Tre dei metodi (`prepare_workflow_retry`, `commit_attempt(effects:)`, + `claim_ready_effects`) chiedono al DB transazionalità complessa. + +**Codex propone bene** (§Findings 1): non splittare il port casualmente, ma +**congelare la crescita** e documentare una capability matrix ("runner core", +"resume", "mutation", "effects", "dispatcher") per dichiarare quali metodi +servono per quale feature pubblica. Sottoscrivo. + +**Gemini propone** (§Storage Contract): ridurre il contratto storage a +primitive elementari + CAS lift-up al kernel. **Non sono d'accordo**: +spostare il CAS al kernel impone round-trip extra (read → guard → write) che +in un DB transazionale sono unica query. Gli atomic boundary attuali esistono +perché chiudono crash gap reali, e Codex lo dimostra metodicamente +("Crash-resume semantics are treated seriously"). La giusta strada è quella +di Codex: **congelare il port, non riscriverlo**. + +**Deepseek/Kimi propongono** lo split in 4 port (`WorkflowStorage`, +`NodeAttemptStorage`, `EventStorage`, `EffectStorage`). Difendibile, ma il +Runner dipenderebbe da 4 oggetti invece di 1, e gli atomic boundary +(`commit_attempt(event:, effects:)`, `transition_workflow_state(event:)`, +`prepare_workflow_retry(event:)`) tagliano deliberatamente trasversalmente +ai sub-domini. Splittare significherebbe rompere quei boundary o introdurre +un facade. Non vale la candela in alpha. + +### 2.8 `StorageState` monolitico (760 LOC) + +`storage_state.rb` è un singolo `module_function` con tutti i sottosistemi +(workflow, attempts, events, effects, retry, mutation guard) in un file. +Codex (§Findings 4), DeepSeek (§Memory::StorageState), Kimi +(§lib/dag/adapters/memory/storage_state.rb) convergono. + +**Fix (2h, zero rischio)**: split per dominio prima di S0 (SQLite). 4-5 file +sotto `lib/dag/adapters/memory/` (es. `lifecycle.rb`, `attempts.rb`, +`effects.rb`, `events.rb`, `retry.rb`). Il facade `Memory::Storage` resta +unchanged; il modulo `StorageState` può diventare un facade interno che +delega ai sub-moduli. Nessun cambio di comportamento. + +Quando arriverà SQLite, ogni sub-modulo diventa una sezione di adapter con +boundary di transazione coerente, invece di un mega-file da decomporre. Il +costo è basso, il payoff è alto se si vuole rispettare l'invariante "lo +storage è il vero kernel" (Codex §Findings 1). + +### 2.9 `Record#to_snapshot` espone campi infrastrutturali + +`effects/record.rb:5-19` — `RECORD_SNAPSHOT_FIELDS` include +`payload_fingerprint`, `external_ref`, `not_before_ms` insieme ai campi +semantici (`type`, `key`, `payload`, `result`, `error`). Lo step in +`Effects::Await` legge solo `status`, `result`, `error`, `not_before_ms`. + +`payload_fingerprint` è idempotenza storagica; `external_ref` è dispatch lease +integration. Nessuno step li dovrebbe vedere. + +Claude (§3.8) lo segnala. Verificato. + +**Fix (30 min)**: filtrare `RECORD_SNAPSHOT_FIELDS` ai soli campi semantici. +Rompe API solo se qualche caller esterno legge `payload_fingerprint` dallo +snapshot — improbabile, ma vista l'alpha, accettabile. + +### 2.10 Production readiness usa la chain API per grafi grandi + +Codex (§Findings 5). `scripts/production_readiness.rb:770-796` — +`build_large_graph(:chain | :fanout | :diamond, nodes)` itera con +`definition = definition.add_node(...).add_edge(...)`. Ogni iterazione +ricrea una `Definition` immutabile, paga il costo CoW. + +`DAG::Workflow::Definition::Builder` esiste a +`lib/dag/workflow/definition/builder.rb:7` proprio per costruzione bulk +(buffer mutabile + freeze unico al `build`). + +**Fix (30 min)**: sostituire le 3 branch con `Builder.build do |b| ... end` +nel modo `Builder.build { |b| b.add_node(:n0, type: :noop) ... +b.add_edge(:n0, :n1) ... }`. Una probe perf più rappresentativa. + +### 2.11 `REVIEW.md` è di altro progetto + +`REVIEW.md` (untracked, root). Verificato leggendo le prime 50 righe: parla +di `Steps::Exec`, `Strategy.run_task`, `KILL_GRACE_SECONDS`, `drain_pipes`, +`Threads/Processes/Ractor`, `:exec`/`:ruby`/`:ruby_script`, +`Loader.from_yaml(Dumper.to_yaml(...))`. **Niente di tutto ciò esiste in +ruby-dag**. Sembra spillage da un altro progetto. + +Claude (§Context) e Codex (§Findings 6) convergono. + +**Fix (5 min)**: rimuovi o sposta in `docs/legacy/`. Se resta in root, un +agente futuro forma il modello mentale sbagliato. + +--- + +## 3. Cosa hanno preso bene i 5 reviewer (convergenze) + +Punti su cui tutti concordano, **e il codice conferma**: + +1. **Zero deps regge** — gemspec + `lib/dag.rb` puliti. +2. **`Result` è una mini-monade legittima**; **`Effects::Await` non è una + monade**. +3. **`Waiting` escluso da `Result` è scelta deliberata e corretta**, non bug. +4. **Immutabilità ben eseguita ai confini** (`frozen_copy`, `Data.define`, + `freeze` finale del Runner). +5. **`storage_state.rb` è confessato come single mutable site** e contenuto + dietro il facade. +6. **Custom RuboCop cops** (`NoThreadOrRactor`, `NoMutableAccessors`, + `NoInPlaceMutation`, `NoExternalRequires`) — disciplina enforced al + linter, non confidata. +7. **Test seri**: graph fuzz (708 LOC), fingerprint stability a 100 run, + crash simulation (`CrashableStorage`), `spec/support/storage_contract/` + condivisibile. +8. **Atomic boundary corretti**: + - `commit_attempt(event:, effects:)` (un'unica transazione logica), + - `transition_workflow_state(event:)` (chiude la crash gap workflow→event), + - `prepare_workflow_retry(event:)` (CAS guard + reset + budget atomico), + - `claim_ready_effects(lease_ms:, owner_id:)` (lease-aware claim). +9. **`Runner.new` 7 keyword required** — niente default singleton hide + (`runner.rb:54-57`). +10. **`finalize` con commento di design** (`runner.rb:435-453`) — niente + fallback a `:waiting` se nessun nodo waiting; surface `:failed` con + `diagnostic: :no_eligible_but_incomplete`. **Onestà sopra eleganza**. + +--- + +## 4. Cosa hanno sbagliato i 5 reviewer (claim falsificate) + +Verificare ogni claim ha pagato. Errori trovati: + +### 4.1 DeepSeek — "CONTRACT.md dice 10, codice ha 13" — **falso** + +DeepSeek (§Errori minori): "Event types count: CONTRACT.md dice 10, il codice +ne ha 13 (mancano probabilmente quelli aggiunti con effects/mutations)". + +`event.rb:61-72` ha **10** event types (`workflow_started`, `node_started`, +`node_committed`, `node_waiting`, `node_failed`, `workflow_paused`, +`workflow_waiting`, `workflow_completed`, `workflow_failed`, +`mutation_applied`). + +`CONTRACT.md:514-525` ha **gli stessi 10**. Allineati. Niente da fare. + +### 4.2 DeepSeek — "`RuntimeProfile.defaults` non è usato dal Runner" — **falso** + +DeepSeek (§Errori minori): "RuntimeProfile.defaults: max_attempts_per_node:3 +e max_workflow_retries:0 non sono usati dal Runner — sono default nel value +object ma il costruttore di Runner non applica default". + +`RuntimeProfile.default` (`runtime_profile.rb:30-38`, singolare, non +"defaults") è una factory helper. L'utente la chiama, ottiene un +`RuntimeProfile` con i valori 3/0/`:ephemeral`/`:null`, e lo passa a +`create_workflow`. Il Runner accede a quei valori in +`runner.rb:248` +(`run.runtime_profile.max_attempts_per_node`) e dentro +`storage.prepare_workflow_retry` per il budget. **I default sono usati +indirettamente, non magicamente — cioè come tutti i factory default in +Ruby.** Il design è corretto: il Runner non ha default impliciti +(`Runner.new` richiede ogni keyword), e i workflow defaults vivono nel +profile creato dall'utente. + +### 4.3 Claude — "`storage_overrides?` duplicato in 2 file" — **falso (off-by-one)** + +Claude (§3 critiche, riepilogo): "DRY mancato in 2 file (Runner + +Dispatcher)". + +In realtà sono **3** file: `runner.rb:382`, `effects/dispatcher.rb:285`, +`mutation_service.rb:76`. Kimi review è il più accurato qui. + +Non cambia il fix (estrarre come module helper), ma cambia il count. + +### 4.4 Kimi — "`module_function` su `StorageState` è anti-idiomatico" — **opinione, non fatto** + +Kimi (§Ruby idiomatico, §StorageState C-style): "stai scrivendo C con +sintassi Ruby. La scusa 'è l'unico posto dove mutare è permesso' non +giustifica la forma procedurale". + +Il pattern qui è **explicit state injection**: ogni metodo prende `state` +come primo argomento, lo mutua in-place, ritorna il delta. Una classe con +`@state` interno avrebbe la stessa mutazione, solo nascosta dietro un +campo. Il vantaggio del `module_function` qui è che **l'unico posto in cui +lo state esiste è il facade `Memory::Storage`** (verificare leggendo +`memory/storage.rb`), non sparpagliato in tante istanze. Anti-idiomatico +sarebbe avere `StorageState.workflows = {}` come singleton globale. + +Reasonable people may differ on encapsulation taste. Ma "anti-idiomatico" +è un'overstatement. Lascia stare. + +### 4.5 Gemini — "Bilanciamento perfetto OOP/FP raggiunto" — **ottimista** + +Gemini (§Vision) è il più favorevole sul claim "bilanciamento perfetto +OOP/FP". Claude e DeepSeek lo demoliscono con esempi concreti: + +- `Runner` ha 19 metodi privati, `RunContext` viaggia attraverso ~15 di + loro; non puoi comporre due Runner in pipeline. +- `StorageState` è procedurale, non OOP né FP. +- `Step::Base` è OOP con contratto FP. + +La descrizione onesta è **stratificazione**: FP per i valori, OOP per +l'orchestrazione, procedurale per lo storage. Niente di sbagliato — è +**pragmatismo**, non bilanciamento. + +### 4.6 Gemini — "deep_freeze distrugge il GC" — **non verificato sul workload target** + +Gemini (§1 CoW Performance): "In un workflow con centinaia di nodi, dove il +contesto cresce ad ogni step, questo design distruggerà le performance del +Garbage Collector". + +L'osservazione del costo CoW in Ruby è corretta in astratto. Ma `production_readiness.rb` (probe perf nel repo) include scenari `:chain` / +`:fanout` / `:diamond` con grafi grandi e li passa con un budget di tempo +configurabile. Codex riporta che il fast probe passa in 5 secondi +(`bundle exec ruby scripts/production_readiness.rb --fast --duration 5 +--progress-interval 2` → pass). Non c'è evidenza pubblica nel repo che il GC +sia un problema attuale per il workload target (workflow di decine/centinaia +di nodi). Diventa rilevante solo per contesti larghi e profondi +(>>100 nodi con context grandi). **Su workload normali, non è un problema +verificato — è un'ipotesi ragionevole**. + +--- + +## 5. Cosa nessuno ha visto (o ha sotto-pesato) + +### 5.1 `predecessors_by_node` cache — è il singolo fix di perf più importante + +`runner.rb:142-161` — `build_run_context` precomputa +`predecessors_by_node` una volta per `Runner#call`, evitando di chiamare +`each_predecessor` per nodo a ogni iterazione del loop. Claude lo segnala +come positivo (§2). Nessun altro reviewer lo nota. + +In un grafo con N nodi e M predecessori medi, il loop `eligible_nodes` viene +chiamato O(L) volte (L = numero di layer). Senza la cache, ogni chiamata +itera N nodi e per ognuno chiama `each_predecessor` (O(M)). Con la cache, è +O(L·N·M) → O(L·N) hash lookup. È un vantaggio reale, non micro. + +### 5.2 `append_workflow_started_once` è idempotente per scansione, non per flag + +`runner.rb:177-184` — l'idempotenza dell'evento `workflow_started` è +implementata leggendo il primo evento dello storage, non con un flag in +storage: + +```ruby +first_event = @storage.read_events(workflow_id: run.workflow_id, limit: 1).first +return if first_event&.type == :workflow_started +``` + +Sopravvive crash, retry, resume, durable adapter switch. **Buona ingegneria**. +Claude lo nota; gli altri no. + +### 5.3 `storage_overrides?` è il pattern fast-path/slow-path documentato + +DeepSeek (§Cosa funziona) lo cita positivamente: "Permettere allo storage di +sovrascrivere metodi 'default' del runner è intelligente. Il runner ha un +fallback generico, lo storage può ottimizzare. Questo pattern dovrebbe +essere usato di più". + +**Sottoscrivo, ma con un caveat**: il fast-path su +`list_committed_results_for_predecessors` è essenziale (`runner.rb:367`); il +fallback (`runner.rb:375-379`) è O(predecessori) per nodo, **debole sul +default port**. Codex (§Findings 1, capability matrix) ha la risposta +giusta: documentare come capability obbligatoria per gli adapter "runner +core". Vedi §6.5. + +### 5.4 Effect snapshot leak è più grave di "campi infrastrutturali" + +Claude (§3.8) tratta `payload_fingerprint`/`external_ref` come "leak di +campi infrastrutturali" e propone un filter — corretto, ma sottostimato. + +Il problema reale: uno step che scrive ai metadati lo step pre-execution e +si fa fingerprint con `payload_fingerprint` di prima, **diventa +deterministico in modo errato** (ricade sull'idempotenza dell'effect, non +sulla logica dello step). Il filter va fatto. + +### 5.5 Crash-resume durability invariant è documentato e implementato + +Codex (§What Works Well, "Crash-resume semantics are treated seriously") è +il più preciso. La sequenza +`Runner#transition_and_emit_terminal` → `storage.transition_workflow_state(event:)` +chiude la finestra in cui il workflow era terminale ma il `:workflow_failed` +non era stato persisted. Senza questo, `Runner#resume` rimaneva intrappolato +(`acquire_running` rejecta terminal states). + +Sotto-pesato dagli altri reviewer. Vale come "feature distintiva" rispetto a +workflow engine concorrenti che lasciano questa gap implicita. + +--- + +## 6. Proposte concrete (priorità) + +Ordinate per costo/beneficio. Le prime 5 sono "low-hanging fruit" e +dovrebbero essere candidate v1.0.2. + +| # | Cambiamento | Costo | Beneficio | Rif. | +| --- | ------------------------------------------------------------------------- | ------ | ------------------------------- | ----- | +| 1 | `Validation.member!(state, RUN_RESULT_STATES)` in `RunResult` | 15min | uniformità + safety pubblica | §2.3 | +| 2 | Estrai `storage_overrides?` come `DAG.storage_overrides?(storage, name)` | 15min | DRY 3 → 1 | §2.1 | +| 3 | Estrai/elimina `immutable_json_copy` duplicato in `dispatcher.rb` | 10min | DRY 2 → 1 | §2.2 | +| 4 | Rimuovi `REVIEW.md` o sposta in `docs/legacy/` | 5min | igiene + no-mental-model-drift | §2.11 | +| 5 | Cambia commento "Monad-like" → "Effect snapshot dispatcher" in `await.rb` | 5min | onestà semantica | §1.2 | +| 6 | Filtra `RECORD_SNAPSHOT_FIELDS` ai soli campi semantici | 30min | separazione semantica/infra | §2.9 | +| 7 | Sostituisci `canonical_committed_attempt` con `select.max_by` | 20min | leggibilità sopra micro-perf | §2.6 | +| 8 | `production_readiness.rb` `build_large_graph` usa `Builder` | 30min | probe perf rappresentativa | §2.10 | +| 9 | Tighten validation di `StepInput`/`Event`/`RuntimeProfile` | 1h | safety API pubblica | §2.4 | +| 10 | Catch `IdempotencyConflictError` in Runner → terminal failure | 1-2h | chiude operational dead-end | §2.5 | +| 11 | Documenta capability matrix dello storage port | 1-2h | adoption barrier per S0 | §2.7 | +| 12 | Documenta in `CLAUDE.md` boilerplate `Data.define` come scelta intenzionale| 30min | chiude falsa contradiction DRY | §1.7 | +| 13 | Documenta CoW limitato a value objects + storage CoW → SQLite | 30min | onestà del claim | §1.4 | +| 14 | Split `storage_state.rb` per dominio prima di S0 | 2h | preparazione SQLite | §2.8 | +| 15 | Rinomina mentalmente "CoW" in "frozen value semantics" nel README | 15min | onestà del claim | §1.4 | + +**Bundle "1 ora di lavoro per chiudere il gap"**: voci 1-5 (45 min totali), +zero rischio, riducono il count di "claim non onesti" da 3 a 0. + +**Bundle "candidato v1.0.2"**: aggiungi voci 6-10 (~3-4h totali). Stato +honest-claim 7/7, validation public-API uniforme, idempotency conflict +recoverable, probe perf più rappresentativa. + +**Bundle "preparazione S0"**: aggiungi voci 11, 14 (~3-4h totali). Capability +matrix pronta per il primo durable adapter; `storage_state.rb` decomposed. + +--- + +## 7. Verdetto Antirez + +Codice serio. Tre marcatori che separano "personal project" da "qualcuno che +ha shippato": + +1. **`Result.assert_result!`** (`result.rb:54-57`) — beccare il "forgot to + wrap" al boundary del block è esperienza, non teoria. +2. **Custom cop enforcement** (`Dag/NoThreadOrRactor`, `Dag/NoMutableAccessors`, + `Dag/NoInPlaceMutation`, `Dag/NoExternalRequires`) — la disciplina è + **enforced**, non confidata. +3. **`finalize` con commento di design** (`runner.rb:435-453`) — surface + `:failed` con `diagnostic: :no_eligible_but_incomplete` invece di forgive + a `:waiting`. **Onestà sopra eleganza**. + +Niente bug critici verificati. Tre debt manageable: + +- **boilerplate `Data.define` ripetuta** (~150-200 LOC). Vivi con essa o + documentala come intenzionale. **Non metaprogrammarla**. +- **storage port grasso** (26 metodi). Congela la crescita; documenta la + capability matrix. +- **`storage_state.rb` monolitico** (760 LOC). Splittalo prima di SQLite. + +Due overstatement nella vision: + +- "**Monadi**" → hai una sola, su `Result`. `Effects::Await` è dispatcher. + Cambia il vocabolario, non il design. +- "**CoW per concorrenza futura**" → vale per i value object passati + attraverso il kernel. Lo stato durabile è single-writer. Definisci il target + di concorrenza (single-process MRI? multi-process via durable adapter? + multi-host?) e riformula il claim. + +Useresti questa libreria? **Sì**, se il dominio è "workflow deterministico +single-process con futuro durable adapter". **No**, se ti aspetti +multi-process out-of-the-box senza la fase S0. + +**Stato**: production-ready alpha per il workload dichiarato. Niente da +riscrivere. Lista di nit di 3-4 ore per la candidate v1.0.2 e 6-8 ore per la +preparazione S0. + +Tutto il resto — DRY (per le validation), immutabilità (al boundary), +idiomaticità Ruby, zero deps, ports, atomic boundary, test — **regge**. + +--- + +## 8. Appendice: tabella di verifica completa + +Ogni claim significativa dei 5 review verificata di prima persona contro il +codice. Verdetto: ✅ vero — ⚠️ parziale/sfumato — ❌ falso. + +| # | Claim | Da chi | Esito | Verifica | +| -- | --------------------------------------------------------------------------- | ------------------- | -------------- | -------------------------------------------------------------------------- | +| 1 | Zero deps esterne nel gemspec | tutti | ✅ | `lib/dag.rb` solo relative; `securerandom`, `digest`, `json` da stdlib | +| 2 | `Result` è una mini-monade su Success+Failure | claude, codex, ds | ✅ | `result.rb:25`, `success.rb:11`, `failure.rb:9` | +| 3 | `Waiting` non include `Result` | claude, codex, ds | ✅ | `waiting.rb:9-69`, nessun `include Result` | +| 4 | `Effects::Await` "Monad-like" non lo è | claude | ✅ | `effects/await.rb:5`; nessun `bind`/`pure` | +| 5 | `runner.rb` 497 righe | claude, ds, kimi | ✅ | `wc -l` | +| 6 | `graph.rb` 696 righe | ds, kimi | ✅ | `wc -l` | +| 7 | `storage_state.rb` 760 righe | claude, codex, ds | ✅ | `wc -l` | +| 8 | `dispatcher.rb` 339 righe | ds | ✅ | `wc -l` | +| 9 | `validation.rb` 182 righe | claude | ✅ | `wc -l` | +| 10 | `Runner.new` richiede 7 keyword | claude, codex | ✅ | `runner.rb:54-67` | +| 11 | `Runner` freeze finale | claude | ✅ | `runner.rb:66` | +| 12 | `each_predecessor`/`each_successor` esistono | claude | ✅ | `graph.rb:394`, `graph.rb:400` | +| 13 | `RunResult` non valida `state` | claude, codex | ✅ | `run_result.rb:27-37` | +| 14 | `StepInput` non valida `context`/`node_id`/`attempt_number` | codex | ✅ | `step_input.rb:27-36` | +| 15 | `Event` non valida `workflow_id/revision/at_ms/...` | codex | ✅ | `event.rb:38-57` | +| 16 | `RuntimeProfile` non valida `event_bus_kind` | codex | ✅ | `runtime_profile.rb:40-59` | +| 17 | `canonical_committed_attempt` mutation loop | claude, ds | ✅ | `runner.rb:393-412` | +| 18 | `effective_context` fallback O(predecessori) | claude | ✅ | `runner.rb:366-380` con `storage_overrides?` | +| 19 | `handle_outcome` mescola decisione e side-effect | claude, ds | ✅ | `runner.rb:227-265` | +| 20 | `finalize` non fallback a `:waiting` se nessun nodo waiting | claude | ✅ | `runner.rb:440-453` + commento di design | +| 21 | `storage_overrides?` duplicato in 2 file | claude, ds | ❌ | In realtà 3: `runner.rb:382`, `dispatcher.rb:285`, `mutation_service.rb:76` | +| 22 | `storage_overrides?` duplicato in 3 file | kimi | ✅ | Conferma ai siti elencati | +| 23 | `immutable_json_copy` duplicato in `dispatcher.rb` | ds | ✅ | `dispatcher.rb:40-46` (HandlerOutcome) + `dispatcher.rb:106-112` (DispatchOutcome) | +| 24 | `to_dot` in `Graph` viola SRP | kimi | ✅ | `graph.rb:419-436` — accettabile ma scope creep | +| 25 | `nodes` ritorna dup-or-original a seconda di `frozen?` | kimi | ⚠️ | `graph.rb:36`; nitpick (Hash usa `hash()`, non `object_id`) | +| 26 | `frozen_copy` accetta frozen `Set` con elementi mutabili | kimi | ⚠️ | `immutability.rb:17`; tecnicamente vero, no caso reale nel kernel | +| 27 | `RECORD_SNAPSHOT_FIELDS` espone `payload_fingerprint`/`external_ref` | claude | ✅ | `record.rb:5-19` | +| 28 | `CONTRACT.md` ha 10 event types, codice ne ha 13 | ds | ❌ | Entrambi 10: `CONTRACT.md:514-525` e `event.rb:61-72` | +| 29 | `RuntimeProfile.defaults` non è usato dal Runner | ds | ❌ | È `RuntimeProfile.default` (singolare); usato indirettamente dal profile creato dall'utente — `runner.rb:248` | +| 30 | `REVIEW.md` è di altro progetto | claude, codex | ✅ | Steps::Exec/Strategy/KILL_GRACE_SECONDS/Threads — niente esiste qui | +| 31 | `production_readiness.rb` `build_large_graph` usa chain non Builder | codex | ✅ | `scripts/production_readiness.rb:770-796` | +| 32 | `Definition::Builder` esiste | codex | ✅ | `lib/dag/workflow/definition/builder.rb` | +| 33 | `Ports::Storage` ha ~30 metodi | ds, kimi | ✅ | 26 metodi pubblici (338 LOC); approssimazione ragionevole | +| 34 | Cycle detection O(V+E) per `add_edge` | gemini | ✅ | `graph.rb` `add_edge` chiama `reachable?` (commento esplicito) | +| 35 | Storage port "fat"/scope gravity | codex, gemini, kimi | ✅ | 26 metodi su 338 LOC; convergenza onesta | +| 36 | `Memory::StorageState` è single mutable site dichiarato | claude, codex, ds | ✅ | `storage_state.rb:6-13` + `Dag/NoInPlaceMutation` cop scope | +| 37 | Test count: 515 (codex) o 490 (ds) | codex, ds | ⚠️ | Codex riporta `bundle exec rake` 515 tests / 39885 assertions. Da rieseguire. | +| 38 | Effect idempotency conflict deadlock operativo | codex | ✅ | `spec/support/storage_contract/effects.rb:87` conferma; runner-level reale | +| 39 | Custom RuboCop cops attivi e funzionanti | tutti | ✅ | `CLAUDE.md` + cop test | +| 40 | `module_function` per `StorageState` è "anti-idiomatico" | kimi | ❌ | Opinione, non fatto. Pattern "explicit state, single mutable site" valido | +| 41 | "Bilanciamento perfetto OOP/FP" raggiunto | gemini | ⚠️ | Stratificazione, non bilanciamento; pragmatismo legittimo | +| 42 | `deep_freeze` "distrugge il GC" su workflow grandi | gemini | ⚠️ | Plausibile in astratto; non verificato sul workload target nel repo | + +Totale: 42 claim. **34 ✅ vere — 5 ⚠️ sfumate — 3 ❌ falsificate**. + +Reviewer più accurato sui dettagli: **Claude** (con un off-by-one su +`storage_overrides?`). +Reviewer più equilibrato sull'analisi architetturale: **Codex**. +Reviewer più aggressivo ma con claim non verificate: **DeepSeek** (2 falsi), +**Kimi** (1 opinione presentata come fatto). +Reviewer più sintetico ma con alcune assunzioni non probate: **Gemini**. + +--- + +*— Antirez (channeled). La review che il codice meritava: fatti, file:line, +niente lodi. Ogni claim sopravvive al `git grep`, o muore.* diff --git a/docs/reviews/final-review-v1.0.1-codex.md b/docs/reviews/final-review-v1.0.1-codex.md new file mode 100644 index 0000000..3128169 --- /dev/null +++ b/docs/reviews/final-review-v1.0.1-codex.md @@ -0,0 +1,649 @@ +# ruby-dag v1.0.1 - Final Review (Codex, stile Antirez) + +Data: 2026-05-02 + +Scope deciso: + +- `claude-review-v1.0.1.md` +- `codex-review-v1.0.1.md` +- `gemini-review-v1.0.1.md` +- `opencode-deepseek-v4-flash-review-v1.0.1.md` +- `opencode-kimi-k2.6-review-v1.0.1.md` + +Esclusi: + +- `docs/reviews/opencode-deepseek-v4-flash-review-v1.0.1.md`: duplicato + byte-per-byte del file DeepSeek in root. +- `REVIEW.md`: fuori repo/versione. Cita `Steps::Exec`, strategie + `threads/processes`, `FileRead`, `FileWrite`, path sandboxing e bug EINTR: + nessuno di questi oggetti esiste nel kernel v1.0.1 attuale. + +Questa review non e' un compromesso tra agent. E' un verdetto dopo verifica +diretta di codice, contratto, README, test e script locali. + +## Verifiche eseguite + +Comandi eseguiti: + +```bash +bundle exec rake +bundle exec ruby scripts/production_readiness.rb --fast --duration 5 --progress-interval 2 +``` + +Risultati osservati: + +- `bundle exec rake`: 515 test, 39885 assertion, 0 failure, 0 error. +- RuboCop/Standard: 129 file, 0 offense. +- YARD: 99.08% documented. +- Production readiness fast probe: PASS dopo 5 secondi. + +Verifiche statiche: + +- `DAG::VERSION` e' `1.0.1`. +- `ruby-dag.gemspec` richiede Ruby `>= 3.4`. +- Nessuna runtime dependency nel gemspec. +- Runtime `require` in `lib/dag/**`: solo stdlib (`json`, `digest`, + `securerandom`) piu' `require_relative`. +- Nessun `Thread`, `Ractor`, `Mutex`, `Queue`, `Monitor`, + `ConditionVariable`, `Process.spawn`, `system`, `attr_accessor` in + `lib/dag/**`. +- `Event::TYPES` e `CONTRACT.md` sono allineati: 10 eventi, non 13. + +## Verdetto + +`ruby-dag` v1.0.1 e' un kernel serio: piccolo per scelta, deterministico, +zero runtime deps, con boundary atomici espliciti e test migliori della media +di una gem di questa dimensione. + +Non e' perfetto. Le review migliori hanno trovato debito reale. Le review +peggiori hanno confuso preferenze estetiche con bug, o hanno recensito file +che non appartengono a questa codebase. + +La frase corta e': + +> Buon kernel. Nessuna riscrittura. Correggere la narrazione, stringere alcune +> validazioni pubbliche, gestire un dead-end operativo sugli effetti, e tenere +> a dieta il port storage. + +## Vision: cosa regge e cosa no + +### Zero dipendenze esterne + +Confermato. + +La gem non ha runtime dependency esterne. Gli adapter stdlib usano solo +`json`, `digest` e `securerandom`. Questo e' coerente con README e gemspec. + +Verdetto: vero. Non trasformarlo in religione. SQLite/Postgres/Redis/HTTP +client devono restare fuori dal kernel, negli adapter o nei consumer. + +### Monadi + +Parzialmente vero. + +`DAG::Result` e' una mini-monade legittima su `Success | Failure`: + +- `Success` e `Failure` includono `DAG::Result`. +- `Waiting` non lo include, per scelta esplicita. +- `and_then` e `recover` fanno type-check del valore restituito con + `Result.assert_result!`. +- `tap`, `tap_error`, `map_error`, `value_or` sono esclusi deliberatamente + per superficie minima. + +Questa scelta e' buona Ruby: poca astrazione, contratto chiaro. + +Pero' `Effects::Await` non e' una monade. Il commento dice "Monad-like", ma il +codice e' un traduttore di snapshot: legge `input.metadata[:effects]`, ritorna +`Waiting`, `Failure`, oppure passa il risultato a una continuation che deve +ritornare `Success | Waiting | Failure`. Non ha `pure`, `bind` o composizione +diretta `Await -> Await`. + +Verdetto: non cambiare il design. Cambiare il vocabolario. Dire: + +> `Result` e' una mini-monade Success/Failure. `Waiting` e `Await` sono +> control-flow del runner/effect ledger. + +### Tipi immutabili + +Confermato ai confini pubblici e nel kernel puro. + +`Data.define` e `DAG.frozen_copy` sono usati in modo sistematico. I value +objects validano input JSON-safe dove serve e congelano copie difensive. +`Runner` e' frozen dopo `initialize`. + +La mutazione in-place esiste, ma e' confinata in +`DAG::Adapters::Memory::StorageState`, come documentato e come richiesto dal +design del memory adapter. + +Verdetto: vero. Non fingere che storage in-memory sia immutabile; il punto e' +che il chiamante non riceve riferimenti mutabili vivi. + +### Copy-on-write e concorrenza futura + +Parzialmente vero, e il nome e' troppo forte. + +`ExecutionContext#merge` e i value boundary non fanno structural sharing vero. +Fanno deep copy + freeze. Questo e' semanticamente immutabile, non CoW in senso +persistente/Clojure/HAMT. + +In piu', i value objects frozen aiutano la concorrenza futura, ma non la +risolvono. La concorrenza vera sta nello storage: CAS, transazioni, lease, +retry atomici, effect claim atomici. Il memory adapter e' single-process e non +sincronizzato. + +Verdetto: sostituire la narrazione "copy-on-write per concorrenza" con: + +> Value objects frozen e copie difensive riducono aliasing e drift. La +> concorrenza multi-process/multi-host richiede adapter storage transazionali. + +### Bilanciamento OOP/FP + +Vero se detto bene, falso se venduto come "perfetto". + +La regola reale e' chiara: + +- FP/value style per `Graph` frozen, `Definition`, `ExecutionContext`, + `Success`, `Failure`, `Waiting`, effetti e eventi. +- OOP per `Runner`, `Dispatcher`, ports, adapters e registry. +- Mutazione confinata dietro storage o builder locali. + +Questo non e' caos. E' una divisione pragmatica. Ma "bilanciamento perfetto" +e' marketing. + +Verdetto: formulare cosi': + +> FP per i dati e le trasformazioni pure; OOP per coordinazione, boundary e +> adapter. + +### Ruby idiomatico + +Confermato con riserva. + +Buono: + +- keyword args; +- error class esplicite; +- `Data.define` per value objects; +- no `method_missing`; +- no `Enumerable` ambiguo su `Graph`; +- `each_predecessor` e `each_successor` esistono entrambi; +- `Runner.new` richiede tutti e 7 i port. + +Riserva: il pattern `Data.define` + `remove_method :[]` + constructor +keyword-only e' ripetitivo. Non e' un bug. E' boilerplate deliberato per non +introdurre metaprogramming generico. + +Verdetto: tenerlo, ma documentare che la duplicazione e' intenzionale finche' +non appare un helper davvero piccolo e non magico. + +### DRY + +Parzialmente vero. + +Vero: + +- `DAG::Validation` centralizza molti check. +- `DAG.frozen_copy` evita duplicazione ai boundary. +- storage contract specs evitano drift tra adapter. + +Falso o incompleto: + +- `storage_overrides?` e' duplicato in `Runner`, `Dispatcher` e + `MutationService`, non solo in due file. +- `immutable_json_copy` e' duplicato due volte nello stesso + `effects/dispatcher.rb`. +- il boilerplate dei value object e' ripetuto. + +Verdetto: DRY buono dove non nasconde il contratto. Evitare refactor generici +che rendono opachi storage, runner e value validation. + +## Claim verificati: accettati + +### 1. Il port storage e' diventato il vero centro del sistema + +Confermato. + +`lib/dag/ports/storage.rb` ha 338 linee e copre workflow row, revisioni, node +states, attempts, event log, resume, retry, mutation CAS, effect ledger, +leasing e query batch dei predecessori. + +Questo e' coerente, ma costoso per ogni adapter durevole. + +Decisione: non spezzare ora il port. `AGENTS.md` dice che +`lib/dag/ports/storage.rb` e' canonico. Spezzarlo in 4 port e' una migrazione +pubblica, non un refactor innocente. + +Soluzione accettata: + +- aggiungere una capability matrix documentale: core runner, resume, mutation, + effects, dispatcher; +- bloccare la crescita del port; +- ogni nuovo metodo storage deve motivare quale finestra di crash/stale-read + chiude. + +### 2. Effect idempotency conflict puo' lasciare il workflow in dead-end + +Confermato. + +Il contratto dice che `commit_attempt(..., effects:)` deve rollbackare tutto +se la reservation degli effetti fallisce. Lo spec +`spec/support/storage_contract/effects.rb` verifica proprio questo: dopo +`IdempotencyConflictError`, l'attempt resta `:running`, il nodo resta +`:running`, non viene scritto evento, non viene creato link effetto. + +Storage-level e' corretto. Runner-level e' ruvido: se uno step deterministico +produce lo stesso `(type, key)` con payload diverso, puo' riprodurre lo stesso +conflitto a ogni resume. + +Soluzione accettata per v1.0.2: + +- catturare `DAG::Effects::IdempotencyConflictError` nel `Runner` intorno a + `commit_attempt`; +- convertirlo in failure non retriable di nodo/workflow con evento durabile; +- preservare la garanzia storage di rollback. + +Questa e' la proposta piu' importante uscita dalle review. + +### 3. Validazione pubblica non uniforme + +Confermato. + +Esempi reali: + +- `RunResult` valida JSON safety di `outcome` e `metadata`, ma non `state` o + `last_event_seq`. +- `StepInput` valida solo `metadata` JSON-safe, ma non `context`, + `node_id`, `attempt_number`. +- `Event` valida `type` e `payload`, ma non `workflow_id`, `revision`, + `seq`, `at_ms`, `node_id`, `attempt_id`. +- `RuntimeProfile` valida `durability`, retry budget e attempt budget, ma non + `event_bus_kind`. + +Il Runner crea questi oggetti correttamente, quindi non e' bug osservato. Ma +sono API pubbliche. Devono rifiutare stati impossibili. + +Soluzione accettata: + +- aggiungere helper stretti in `DAG::Validation` solo dove ricorrono; +- validare `RunResult.state` contro `%i[completed paused waiting failed]`; +- validare interi non negativi opzionali per `seq`, `last_event_seq`, + `at_ms`, `attempt_number` dove applicabile; +- validare `StepInput.context` come `DAG::ExecutionContext`; +- decidere se `event_bus_kind` e' enum chiuso o simbolo opaco documentato. + +### 4. `Effects::Await` ha un commento falso + +Confermato. + +Il commento "Monad-like" in `lib/dag/effects/await.rb` non descrive il codice. + +Soluzione accettata: + +- rinominare la descrizione a "effect snapshot helper" o "effect snapshot + translator"; +- non cambiare API. + +### 5. `storage_overrides?` e `immutable_json_copy` sono duplicati + +Confermato. + +`storage_overrides?` compare in: + +- `lib/dag/runner.rb` +- `lib/dag/effects/dispatcher.rb` +- `lib/dag/mutation_service.rb` + +`immutable_json_copy` compare due volte nello stesso dispatcher. + +Soluzione accettata: + +- estrarre un helper piccolo, esplicito, non magico; +- non usarlo come scusa per rendere generica l'interfaccia storage. + +### 6. `Memory::StorageState` e' troppo grande + +Confermato. + +`lib/dag/adapters/memory/storage_state.rb` ha 760 linee e contiene lifecycle, +revision append, attempts, events, effects, lease, retry e validazioni. + +Non e' un bug. E' il prezzo di "una sola zona mutabile". Ma sta diventando il +file dove si accumula tutto. + +Soluzione accettata, non urgente: + +- mantenere il facade `DAG::Adapters::Memory::Storage`; +- mantenere una singola struttura di stato mutabile; +- dividere internamente per dominio quando si tocca lo storage per S0 o per + una nuova feature sostanziale. + +### 7. Production readiness large graph dovrebbe usare il builder + +Confermato. + +`scripts/production_readiness.rb` costruisce scenari large graph con la API +immutabile chainable. Esiste invece +`DAG::Workflow::Definition::Builder`, dichiarato proprio per costruzioni bulk. + +Soluzione accettata: + +- aggiornare gli scenari large-graph dello script a usare il builder; +- aggiungere un test semplice di equivalenza builder vs chain API. + +### 8. `canonical_committed_attempt` e' una micro-ottimizzazione locale + +Confermato, ma non prioritario. + +Il metodo manuale nel Runner evita allocazioni, ma rende la lettura piu' +difficile. Memory storage ha gia' una query dedicata +`list_committed_results_for_predecessors`; SQLite farebbe meglio con +`ORDER BY attempt_number DESC, attempt_id DESC LIMIT 1`. + +Soluzione accettata solo se si tocca gia' quel codice: + +- semplificare il fallback con codice piu' leggibile, oppure spostare la + scelta canonica nello storage dove possibile. + +### 9. `handle_outcome` mescola decisione e side effect + +Confermato, ma da trattare con disciplina. + +`Runner#handle_outcome` decide stato nodo, tipo evento, payload, retry, +terminal workflow state e valore di controllo del loop. La logica e' +corretta, ma densa. + +Soluzione accettata solo con trigger reale: + +- estrarre prima una decisione pura piccola, poi applicarla; +- non spezzare il Runner in 5 classi per estetica. + +## Claim verificati: respinti o ridimensionati + +### 1. "Graph#each_successor non esiste" + +Falso. + +`Graph#each_successor` esiste accanto a `each_predecessor`. Questo claim viene +da `REVIEW.md`, file escluso perche' fuori repo/versione. + +### 2. "CONTRACT.md dice 10 eventi, il codice ne ha 13" + +Falso nello stato verificato. + +`Event::TYPES` contiene 10 eventi: + +- `workflow_started` +- `node_started` +- `node_committed` +- `node_waiting` +- `node_failed` +- `workflow_paused` +- `workflow_waiting` +- `workflow_completed` +- `workflow_failed` +- `mutation_applied` + +`CONTRACT.md` contiene la stessa lista. + +### 3. "Spezzare subito il port Storage in 4 port" + +Respinto per v1.0.1/v1.0.2. + +L'idea e' comprensibile: il port e' grande. Ma in questo repo la forma del +port storage e' fonte canonica. Spezzarlo ora significa cambiare contratto, +adapter contract tests, runner, mutation service, dispatcher e documentazione. + +La strada giusta e' prima documentare capability e fermare la crescita. Si +valuta lo split solo se un adapter reale prova che il contratto attuale e' +troppo costoso o concettualmente sbagliato. + +### 4. "Rendere Waiting parte di Result" + +Respinto. + +`Waiting` non e' fallimento e non e' successo. E' parcheggio del workflow con +semantica storage/eventi. Metterlo nella monade per ottenere chainability +omogenea renderebbe il codice piu' elegante e meno onesto. + +Tenere `Success | Failure` come `Result`, e `Waiting` come outcome separato. + +### 5. "Aggiungere value_or/tap/map_error per completare la monade" + +Respinto per ora. + +Il commento di `DAG::Result` dice che questi metodi sono esclusi per scelta: +non usati dalla libreria o trivially expressible. La superficie piccola e' +una decisione forte, non una mancanza. + +Si aggiungono solo quando il codice interno li usa davvero. + +### 6. "Eliminare payload_fingerprint/external_ref/not_before_ms dallo snapshot" + +Respinto senza cambio di contratto. + +`CONTRACT.md` elenca esplicitamente questi campi in +`StepInput.metadata[:effects]`. Non sono lease owner o timestamp storage; +sono parte dello snapshot pubblico stabile. Toglierli sarebbe breaking change. + +Se si vuole ridurre lo snapshot, prima si cambia contratto e si spiega perche'. + +### 7. "Graph#nodes e' pericoloso perche' cambia object_id" + +Ridimensionato. + +Il metodo documenta che ritorna uno snapshot frozen. Per grafi non frozen fa +una copia, per grafi frozen puo' restituire il set interno frozen. Il vero +rischio Hash-key e' gia' documentato in `Graph#hash`: non usare un grafo +mutabile come chiave e poi mutarlo. + +Non e' una priorita'. + +### 8. "Graph fa troppe cose: estrarre subito algoritmi e DOT" + +Respinto per ora. + +`Graph` ha 696 linee, ma il file e' coeso: struttura DAG, query DAG, +algoritmi DAG e formato DOT minimo. Non c'e' drift verso adapter o runtime. + +Estrazione accettabile solo se arrivano altri formati o algoritmi che rendono +il file davvero opaco. Oggi sarebbe refactor estetico. + +### 9. "StorageState module_function e' C-style Ruby, sostituire con classi" + +Ridimensionato. + +La critica di forma ha senso, ma la proposta non e' automaticamente migliore. +Una classe backend con `@state` renderebbe l'OO piu' idiomatico, ma non +ridurrebbe la complessita' delle transizioni. Il problema reale e' la +dimensione del dominio, non il fatto che `state` sia un argomento esplicito. + +Prima dividere per dominio; poi decidere se classi o moduli. + +### 10. "RuntimeProfile defaults non sono usati dal Runner" + +Vero ma non bug. + +Il Runner legge il `runtime_profile` del workflow creato nello storage. +`RuntimeProfile.default` e' un convenience per chi crea il workflow, e README +lo dice chiaramente: retry workflow default `0`, opt-in con profilo diverso. + +Non serve far applicare default al Runner. + +### 11. "DAG.frozen_copy e' insicuro per Set/custom frozen" + +Parzialmente vero, ma non e' una falla generalizzata. + +`frozen_copy` ritorna oggetti frozen non Hash/Array cosi' come sono. Quindi un +custom object superficialmente frozen potrebbe portare stato interno non +profondamente congelato. + +Pero' i payload pubblici JSON-safe non accettano `Set` o oggetti arbitrari. +Il rischio riguarda boundary dove si passano oggetti ricchi, non il normale +payload step/event/effect. + +Decisione: non cambiare subito. Documentare meglio il contratto di +`frozen_copy`: e' sicuro per valori JSON-safe e value objects gia' immutabili, +non un freezer universale per oggetti custom. + +## Priorita consigliata + +### v1.0.2 + +1. Gestire `DAG::Effects::IdempotencyConflictError` nel Runner come failure + terminale non retriable con evento durabile. +2. Stringere la validazione dei value object pubblici: `RunResult`, + `StepInput`, `Event`, `RuntimeProfile`. +3. Correggere la narrazione: `Await` non e' monade; CoW e' deep-copy/freeze, + non structural sharing; concorrenza futura dipende dallo storage. +4. Usare `Definition::Builder` negli scenari large graph di + `scripts/production_readiness.rb`. +5. Eliminare o quarantinare `REVIEW.md`, perche' induce agent e umani a + recensire un progetto diverso. + +### Debito piccolo + +1. Estrarre `storage_overrides?` in helper condiviso. +2. Estrarre `immutable_json_copy` dal dispatcher. +3. Validare `RunResult.state` con enum chiuso. +4. Documentare il boilerplate `Data.define` come duplicazione intenzionale. +5. Semplificare `canonical_committed_attempt` solo quando si tocca il Runner. + +### Dopo v1.0.2 / prima di S0 durable adapter + +1. Capability matrix del port storage. +2. Split interno di `Memory::StorageState` per dominio, senza cambiare facade. +3. Revisione del contratto storage solo dopo feedback da un adapter reale. +4. Eventuale separazione decisione/applicazione in `Runner#handle_outcome`. + +## Cosa non fare + +- Non riscrivere il Runner ora solo per portarlo da 497 a 200 linee. +- Non spezzare il port storage per estetica. +- Non trasformare Ruby in Haskell aggiungendo una monade a 3 stati. +- Non sostituire il boilerplate esplicito dei value object con + metaprogramming generico. +- Non ottimizzare `Graph#add_edge` finche' il target resta "tens to low + thousands of nodes". Il costo O(V+E) e' documentato e accettabile. + +## Valutazione dei cinque input + +### Claude review + +La piu' utile sul piano semantico. Corretta su: + +- `Result` vero solo per `Success | Failure`; +- `Await` non monade; +- CoW/concorrenza sovradichiarati; +- boilerplate value object; +- `RunResult.state` non validato; +- `canonical_committed_attempt` troppo ottimizzato; +- `handle_outcome` denso; +- `StorageState` grande; +- `REVIEW.md` fuori repo. + +Da respingere o modificare: + +- filtrare `Record#to_snapshot` rimuovendo campi gia' presenti nel contratto; +- promuovere immediatamente alcuni fallback storage a port-required senza + passare da capability/contract review. + +### Codex review + +La piu' bilanciata sul rischio operativo. Corretta su: + +- storage port come centro reale; +- idempotency conflict come dead-end operativo; +- validazione pubblica non uniforme; +- `StorageState` grande; +- builder non usato negli scenari large graph; +- review artifact obsoleti. + +E' la base piu' solida per v1.0.2. + +### DeepSeek review + +Buona sui componenti, ma con alcuni errori. + +Corretta su: + +- zero deps; +- monadi parziali; +- CoW come deep-copy/freeze; +- Runner e StorageState grandi; +- duplicazione `immutable_json_copy`; +- test suite forte; +- non refactorare Runner senza trigger. + +Da correggere: + +- `storage_overrides?` non e' solo in due file: e' anche in + `MutationService`; +- event types count e' falso nello stato verificato; +- alcuni giudizi numerici sono opinioni, non finding. + +### Kimi review + +Utile come stress test, non come piano. + +Corretta su: + +- zero deps ha costo prestazionale; +- Waiting non e' monade; +- CoW e' nome impreciso; +- storage port grande; +- `to_dot`/algoritmi Graph sono potenziali candidati futuri se il file cresce. + +Da respingere: + +- rendere `Waiting` un `Result`; +- aggiungere monadic vocabulary non usato; +- spezzare subito storage port; +- chiamare anti-idiomatico ogni `Data.define` validato; +- trasformare StorageState in classi come soluzione primaria. + +### Gemini review + +Alta quota, poca verifica puntuale. + +Corretta su: + +- effect intents come split buono tra dichiarazione ed esecuzione; +- CoW/deep_freeze ha costo GC potenziale; +- storage contract e' pesante per adapter durevoli; +- `Graph#add_edge` O(V+E) e' pragmatico e non va ottimizzato ora. + +Da ridimensionare: + +- "Runner funzione pura" e' troppo generoso: Runner coordina storage e event + bus, quindi e' orchestratore OOP frozen, non funzione pura; +- "OOP/FP perfetto" e' marketing; +- OCC generico al posto delle atomic boundaries attuali e' una proposta + architetturale grande, non patch-level. + +## Giudizio finale + +Questo progetto non ha bisogno di una riscrittura. Ha bisogno di onesta' +lessicale e di pochi fix mirati. + +La parte migliore non e' "monadi", non e' "FP", non e' "architettura +perfetta". La parte migliore e': + +- il kernel non crea thread; +- i port sono espliciti; +- le transizioni che devono essere atomiche sono nominate; +- gli eventi terminali sono legati allo storage; +- gli effect intent sono astratti e durevoli; +- i test non sono decorazione. + +La parte piu' fragile e' la crescita del contratto storage. Non perche' sia +sbagliato, ma perche' ogni nuovo metodo atomico diventa tassa permanente per +ogni adapter futuro. + +Stato realistico: + +> production-ready alpha per workflow deterministici single-process e per +> consumer che accettano il contratto storage attuale. Non ancora una piattaforma +> workflow general purpose. Non ancora una storia completa di concorrenza +> durable multi-process. Ma le fondamenta sono buone. + +Antirez-style: taglia le parole grosse, non il codice buono. diff --git a/docs/reviews/final-review-v1.0.1-gemini.md b/docs/reviews/final-review-v1.0.1-gemini.md new file mode 100644 index 0000000..1599348 --- /dev/null +++ b/docs/reviews/final-review-v1.0.1-gemini.md @@ -0,0 +1,81 @@ +# Antirez Final Review - ruby-dag v1.0.1 + +*Revisione tecnica brutale, basata sui fatti e sul codice. Nessun marketing, solo ingegneria. Modello: gemini* + +--- + +## 1. La Vision: Validazione Onesta + +1. **Zero dipendenze esterne:** **Vero.** + Il gemspec è pulito. L'uso esclusivo della stdlib (JSON, SecureRandom) è un'ottima scelta per un kernel che deve durare 10 anni. Promosso a pieni voti. + +2. **Monadi:** **Parzialmente falso (o per lo meno, incompleto).** + Hai una buona mini-monade `Result` (`Success`, `Failure`) con `and_then` e costruttori sicuri. Il problema è che `Waiting` non ne fa parte e lo step ritorna un tipo somma non omogeneo (`Success | Waiting | Failure`). In `Runner` devi quindi fare pattern matching esplicito (case/when). Se i ritorni non sono componibili in modo omogeneo, non chiamiamolo design basato su monadi; è solo un set di value object espliciti. `Effects::Await` inoltre non è una monade, è un dispatcher con continuazione. + +3. **Tipi immutabili e Copy-on-Write (CoW):** **Attenzione alle performance.** + I tipi sono immutabili (tramite `Data.define` e `frozen_copy`), ma il "CoW" implementato via `deep_dup` + `deep_freeze` in `ExecutionContext` è, di fatto, una copia ricorsiva totale dell'intero albero di oggetti in Ruby. Per piccoli DAG va bene, ma per payload grandi, il GC di Ruby collasserà sotto le allocazioni. Non è "Copy-on-Write", è "Copy-Every-Time". Cambia la terminologia e documenta il trade-off. + +4. **Bilanciamento OOP / FP:** **Retorica, non bilanciamento.** + In realtà, hai sovrapposto due approcci: + - Valori/Eventi: puri (FP). + - Logica di orchestrazione/costruzione: OOP classico (mutazione + freeze finale in `Graph`). + - `StorageState`: puramente procedurale. + Non c'è niente di male nel pragmatismo, ma non venderlo come "bilanciamento perfetto". È un design "a livelli separati", che va bene. + +5. **DRY:** **Da sistemare.** + Hai delle violazioni DRY imbarazzanti e visibili: + - `storage_overrides?` copiato identico 3 volte (in `Runner`, `MutationService`, `Dispatcher`). + - `immutable_json_copy` duplicato due volte in `Dispatcher`. + - Boilerplate massivo per override di `initialize` nei costruttori `Data.define`. + +--- + +## 2. Cosa funziona davvero (Le fondamenta) + +Queste sono le cose che dimostrano ingegneria vera: + +* **Atomic Boundaries nello Storage:** Le transizioni di stato e i side-effect (eventi) sono ben definiti nel port `Storage` (es. `commit_attempt`). Un crash del processo a metà esecuzione non lascerà mai il workflow in uno stato corrotto o irrecuperabile. +* **Custom RuboCop Cops:** `Dag/NoThreadOrRactor`, `Dag/NoInPlaceMutation`, `Dag/NoExternalRequires`. Regole architetturali testate e forzate a compile/lint time. Questo è ottimo engineering. +* **Il Fuzzing:** Il test fuzzer su Graph (deterministico, 700 linee, seed fisso) dimostra una maturità insolita. Il kernel è stato stressato sul serio. + +--- + +## 3. Il Debito Tecnico Reale (I Problemi) + +I problemi di questo progetto non sono concettuali (l'hexagonal architecture e le astrazioni reggono), ma **strutturali e di scala**. + +### A. `Graph` viola l'SRP (~700 linee) +La classe `DAG::Graph` fa tutto: +- Costruisce i nodi e previene i cicli `O(V+E)`. +- Navigazione topologica. +- Algoritmi avanzati (`shortest_path`, `longest_path`, `critical_path`). +- Formattazione Graphviz (`to_dot`). +*Diagnosi:* Algoritmi e formattazione Graphviz in un oggetto dati base sono un errore grave. Se domani aggiungi Mermaid, la classe esplode. +*Proposta:* Estrai un namespace `Graph::Algorithms` e un modulo `Graph::Formatters::Dot`. + +### B. `Runner` è un monolito (~500 linee) +Un solo orchestratore gestisce l'acquisizione, la costruzione del contesto, il loop di execution, l'handling degli outcomes, il commit degli eventi e il fallback dello storage locale. +*Diagnosi:* Difficile da testare isolando la logica (`handle_outcome` fa 10 cose insieme, tra cui calcolo stato ed emissione I/O). +*Proposta:* Spezzare in tre componenti coordinate: `Eligibility`, `OutcomeHandler/Executor` e `Finalizer`. Il Runner deve solo chiamarli nel suo loop stateless. + +### C. `Memory::StorageState` è un abominio procedurale (~760 linee) +Un enorme file C-style in Ruby. Usa `module_function` per operazioni che modificano uno hash di stato esterno. +*Diagnosi:* Il fatto che il linter permetta qui mutazioni in-place non giustifica l'assenza di OOP per incapsulare i concetti (Workflow, Attempts, Effects, Events). +*Proposta:* Spezzare in backend incapsulati in classi. + +--- + +## 4. Piano d'Azione (Priorità) prima di nuove feature + +1. **Rimuovi i palesi DRY violations:** Sposta `storage_overrides?` e `immutable_json_copy` in un modulo condiviso o nel port corretto. (Costo: 10 minuti). +2. **Estrai la formattazione e gli algoritmi da Graph:** Pulisci il value-object principale. (Costo: 30 minuti). +3. **Pianifica la divisione di Runner:** Non farlo subito se non ci sono bug, ma fallo al prossimo giro di refactoring funzionale. Il Runner deve orchestrare, non macinare bit di stato. +4. **Aggiusta il vocabolario del Readme:** Smetti di chiamarlo "Copy-on-Write" e togli l'enfasi "FP/Monadi" dove si tratta in realtà di design imperativo esplicito. Sii onesto, i tecnici lo apprezzeranno di più. + +--- + +## 5. Verdetto Finale + +Stai costruendo una cosa molto difficile (un runtime workflow robusto, deterministico e senza deps), e il codice dimostra che hai la disciplina mentale per farlo: sai dove mettere le transazioni, sai evitare le race conditions e non cedi alla tentazione di usare gemme esterne. + +Il progetto è una solida Alpha production-ready. Ma prima di scalare, devi risolvere la densità delle classi primarie. Al momento hai messo troppa logica nelle fondamenta. Pulisci la casa prima di aggiungere nuovi piani. \ No newline at end of file diff --git a/docs/reviews/final-review-v1.0.1-k2p6.md b/docs/reviews/final-review-v1.0.1-k2p6.md new file mode 100644 index 0000000..04c6e4e --- /dev/null +++ b/docs/reviews/final-review-v1.0.1-k2p6.md @@ -0,0 +1,270 @@ +# ruby-dag v1.0.1 — Revisione Finale (stile Antirez) + +> Verifica incrociata di 5 review (Claude, Codex, Gemini, Deepseek-v4-flash, Kimi-k2.6) contro il codice sorgente. Ogni claim è stato controllato su `file:line`. Niente lodi, niente vendite. + +--- + +## 1. Metodologia + +Ho letto le 5 review e, per ogni assunzione significativa, ho aperto il file citato. Se il claim non citava un file, ho cercato con `grep`. Se due reviewer si contradicevano, il codice ha arbitrato. Risultato: questa revisione non è una sintesi di opinioni, è un fact-check. + +--- + +## 2. Validazione Vision — punto per punto + +### 2.1 Zero dipendenze esterne +**Stato: VERO.** `ruby-dag.gemspec` non dichiara runtime deps. `lib/dag.rb` carica solo file locali. Stdlib (`json`, `securerandom`, `set`) — tutto lì. Uno dei pochi claim che regge al 100%. + +### 2.2 Monadi +**Stato: PARZIALE, con marketing.** + +- `DAG::Result` su `Success | Failure` è una mini-monade onesta: `and_then`, `map`, `recover`, `assert_result!` (`result.rb:25-57`). Left identity e short-circuit sono rispettati. **Questa parte regge.** +- `Effects::Await` (`await.rb:5`) si proclama "Monad-like step helper". **È falso.** È un dispatcher su 4 stati di uno snapshot con `yield` al continuation. Non c'è `bind`, non c'è `pure`, non si compone con se stesso (`Await ∘ Await` non esiste). È una `if-let` con custodia di tipi. Utile, ma non una monade. +- `Waiting` è **deliberatamente escluso** da `DAG::Result` (`result.rb:4-5`). Scelta architetturale corretta — Waiting è control-flow, non valore — ma cancella metà del claim "monadi". + +**Verdetto:** hai un Either monade legittimo. Non chiamarlo "sistema di monadi". + +### 2.3 Tipi dati immutabili +**Stato: VERO, con una crepa.** + +- `Data.define` ovunque (16+ occorrenze). `frozen_copy` usato disciplinatamente ai confini. +- `DAG.frozen_copy` (`immutability.rb`) assume che se un oggetto è `frozen?` e non è Hash/Array, sia sicuro. È **troppo fiducioso**: un `Set` frozen contenente elementi mutabili passa il controllo. Un oggetto custom con `freeze` superficiale passa. Non è un bug attivo, ma è una crepa nel contratto. +- L'unica zona mutabile è `StorageState` (`storage_state.rb:1-30`), dichiarata esplicitamente come tale. Onesto. + +### 2.4 Copy-on-write per concorrenza futura +**Stato: OVERSTATEMENT.** + +- CoW su value objects (Definition, Graph, ExecutionContext) è vero: ogni mutazione ritorna una nuova istanza congelata. +- **Ma** `ExecutionContext#merge` (`execution_context.rb:27-29`) fa `deep_dup` + `deep_freeze` dell'intero hash interno. Non è CoW strutturale (persistent data structure), è "copia totale ad ogni step". Per workflow con centinaia di nodi e contesto che cresce, questo **distrugge il GC** (allocazioni a ogni layer). +- Lo stato durabile (`StorageState`) ha read-modify-write senza alcun meccanismo di concorrenza. Il commento in cima dice "single-process" — onesto. Ma "CoW per concorrenza futura" implica un design che **non esiste**. + +**Verdetto:** CoW dei value objects è precondizione necessaria, non sufficiente. Lo storage non è preparato per concorrenza. Chiarisci: "frozen value objects safe to share; concurrent storage requires durable adapter (S0)". + +### 2.5 Bilanciamento perfetto OOP/FP +**Stato: RETORICA.** + +Il codice è **layered**, non bilanciato: +- Value objects → FP puro. +- Result → mini-monade FP. +- Step → classe OOP con `#call`. +- Runner → orchestratore OOP denso (~497 LOC, ~19 metodi privati). +- StorageState → programmazione procedurale C-style (`module_function`, stato passato esplicito). + +Non c'è composizione orizzontale tra i layer. Non puoi comporre due Runner. Non puoi comporre due Await. Il "bilanciamento" è "ogni layer sceglie il paradigma comodo". + +**Verdetto:** niente di sbagliato, è pragmatismo. Ma chiamarlo "bilanciamento perfetto" è retorica. Più onesto: "FP per i valori, OOP per la coordinazione". + +### 2.6 Ruby idiomatico +**Stato: VERO, con eccezioni.** + +- Bene: niente `attr_accessor`, niente `method_missing`, `Data.define` ovunque, lazy `enum_for` su Graph, `private_constant`, snake_case. +- Eccezione: il pattern "custom factory `[]`" (`remove_method :[]` + `def [](kw:); new(...); end`) è ripetuto in ~9 file. È un trick per sostituire il costruttore positional di `Data.define` con keyword-only. ~120-200 righe di boilerplate regolare. +- Eccezione: `StorageState` è scritto come C con sintassi Ruby (`module_function`, ogni metodo prende `state` come primo argomento). Anti-idiomatico per Ruby 3.4. + +### 2.7 DRY +**Stato: PARZIALE.** + +- **Vero:** `Validation` (`validation.rb`, 182 LOC, 14 helper) è centralizzato e usato uniformemente. Ottimo. +- **Falso:** ~9 file ripetono lo stesso scheletro `Data.define` + `remove_method :[]` + `initialize` con validazione. Stima: ~150-200 righe di duplicazione regolare. +- **Falso:** `storage_overrides?` è duplicato in 3 file (`runner.rb:382`, `effects/dispatcher.rb:285`, `mutation_service.rb:76`) — 5 righe identiche per file. +- **Falso:** `immutable_json_copy` è duplicato in 2 classi nello stesso file (`dispatcher.rb:40-44` e `106-110`). + +**Verdetto:** DRY per la logica di validazione, sì. DRY per la forma dei value object e per piccoli helper, no. + +--- + +## 3. Verifica assunzioni — claim per claim + +### 3.1 `RunResult` non valida `state` +**Reviewer:** Claude, Codex. **Esito: VERO.** +`run_result.rb:27-37` accetta qualsiasi Symbol. Tutti gli altri value object validano gli enum con `Validation.member!`. RunResult no. È costruito solo dal Runner (che passa valori corretti), ma essendo `@api public`, un caller esterno può creare garbage. + +### 3.2 `Effects::Await` è "monad-like" +**Reviewer:** Claude, Gemini, Deepseek. **Esito: FALSO.** +`await.rb:5` dice "Monad-like step helper". Non lo è. Vedi §2.2. + +### 3.3 `Graph#nodes` cambia object_id tra frozen/unfrozen +**Reviewer:** Kimi. **Esito: VERO.** +`graph.rb:36-37`: `frozen? ? @nodes : @nodes.dup.freeze`. Se usi un Graph come chiave di Hash prima e dopo `freeze`, rompi i bucket. Pericoloso. + +### 3.4 `Graph#to_dot` non dovrebbe stare in Graph +**Reviewer:** Kimi. **Esito: DISCUTIBILE.** +`graph.rb:419-436` — ~18 righe. Non è un mostro. Ma se domani vuoi Mermaid, aggiungi un altro metodo? Sì, meglio estrarre un `DAG::Graph::DotFormatter`. + +### 3.5 `Graph` gestisce anche shortest/longest/critical path +**Reviewer:** Kimi. **Esito: VERO, ma accettabile.** +696 LOC totali. Gli algoritmi sono ~100-150 righe. Non è un disastro, ma `Graph::Algorithms` come modulo separato sarebbe più pulito. + +### 3.6 `canonical_committed_attempt` è ottimizzazione prematura +**Reviewer:** Claude, Deepseek, Kimi. **Esito: VERO.** +`runner.rb:393-412` — loop manuale con 3 variabili per evitare allocazione di un Array intermedio. Per un workflow con 100 nodi e 3 attempts sono ~300 array di 2 elementi. Su un adapter SQLite questa logica dovrebbe essere `ORDER BY attempt_number DESC, attempt_id DESC LIMIT 1`. + +```ruby +# Equivalente in 3 righe: +def canonical_committed_attempt(attempts) + attempts + .select { |a| a[:state] == :committed } + .max_by { |a| [a.fetch(:attempt_number), a.fetch(:attempt_id).to_s] } +end +``` + +### 3.7 `Runner` è troppo lungo / viola SRP +**Reviewer:** Deepseek (6/10), Kimi. **Esito: VERO, ma non urgente.** +497 LOC, ~19 metodi privati. `handle_outcome` (linee 227-265) mescola: decisione stato nodo, tipo evento, payload evento, transizione workflow, ritorno al loop, e IO (commit + event_bus). È denso ma corretto. Spezzarlo in `OutcomeHandler` + `Finalizer` migliorerebbe la testabilità, ma il rischio di regression su 515 test non vale il beneficio estetico se non stai aggiungendo feature. + +### 3.8 `StorageState` è C-style Ruby +**Reviewer:** Kimi. **Esito: VERO.** +`storage_state.rb:12-13`: `module StorageState; module_function`. Ogni metodo prende `state` come primo argomento. Nessuna incapsulazione. Una classe `MemoryStorageBackend` con `@state` sarebbe più idiomatica e testabile. Ma: il cop `Dag/NoInPlaceMutation` path-allowlista `lib/dag/adapters/memory/**`, quindi la forma attuale è "legale". + +### 3.9 `StorageState` è troppo grande +**Reviewer:** Claude, Codex, Deepseek, Kimi. **Esito: VERO.** +760 LOC. Contiene: workflow lifecycle, revision append, attempts, event log, effect ledger, lease claim/mark, waiting-node release, retry reset. Il commento in cima lo ammette. Split per dominio (Workflow, Node/Attempt, Effect, Event) — 4 file da ~150-200 LOC — è meccanico e a zero rischio. + +### 3.10 Storage port è troppo "grasso" +**Reviewer:** Gemini, Codex, Kimi. **Esito: PARZIALE.** +`ports/storage.rb`: ~338 LOC, ~30 metodi. È grande. Ma è **intenzionale**: le operazioni atomiche (es. `prepare_workflow_retry`, `commit_attempt` con `effects`, `transition_workflow_state` con `event`) richiedono che lo storage faccia più cose in una transazione. Spezzare il port in 4 (`WorkflowStorage`, `NodeAttemptStorage`, `EventStorage`, `EffectStorage`) come propone Kimi **romperebbe l'atomicità**: il Runner chiama `commit_attempt` che deve scrivere attempt, nodo, evento, ed effetti nello stesso step. Se il port è spezzato, il Runner dovrebbe fare 4 chiamate, riaprendo la finestra di crash. + +**Verdetto:** il port è grasso perché le atomic boundaries sono grasse. Questo è il costo della correttezza. Non spezzare il port. Documenta quali metodi sono richiesti per quali feature (adapter capability matrix). + +### 3.11 `storage_overrides?` duplicato +**Reviewer:** Deepseek, Kimi. **Esito: VERO, e sottocontato.** +Non 2 file, **3 file**: `runner.rb:382`, `effects/dispatcher.rb:285`, `mutation_service.rb:76`. Stesse 5 righe identiche. Estrarre in `DAG::Ports::Storage` come helper o in un modulo condiviso richiede 2 minuti. + +### 3.12 `immutable_json_copy` duplicato +**Reviewer:** Deepseek. **Esito: VERO.** +2 classi nello stesso file (`dispatcher.rb:40-44` e `106-110`). Stesse 4 righe. + +### 3.13 Event types: 9 vs 10 vs 13 +**Reviewer:** Deepseek dice 13. **Esito: FALSO — sono 10.** +`event.rb:61-71` elenca 10 tipi (incluso `mutation_applied`). `CONTRACT.md:514-525` elenca gli stessi 10. `CLAUDE.md` ne elenca 9 (dimentica `mutation_applied`). Deepseek ha esagerato. + +### 3.14 `REVIEW.md` nella root è di un altro progetto +**Reviewer:** Claude, Codex. **Esito: VERO.** +Parla di `Steps::Exec`, `drain_pipes`, `KILL_GRACE_SECONDS`, `threads.rb`, `processes.rb` — codice che **non esiste** in questo repo. È spillage da un altro progetto. Misleading per chi lo legge prima. + +### 3.15 Effect idempotency conflict lascia workflow stuck +**Reviewer:** Codex. **Esito: VERO, e importante.** +Se uno step deterministico riusa lo stesso `(type, key)` con payload diverso, `commit_attempt` fa rollback per `IdempotencyConflictError`. Il nodo resta `:running`, il workflow resta `:running`. Resume → retry → stesso conflitto. Loop infinito operativo. Il Runner non cattura questa eccezione per convertirla in failure terminale. + +### 3.16 `RuntimeProfile.default` non usato dal Runner +**Reviewer:** Deepseek. **Esito: VERO.** +`runtime_profile.rb:30-38` definisce `default` con `max_attempts_per_node: 3`, ma il Runner non applica default. Chi crea il workflow deve passare esplicitamente il profilo. + +### 3.17 `list_committed_results_for_predecessors` dovrebbe essere required +**Reviewer:** Claude. **Esito: DISCUTIBILE.** +Il default port non lo implementa; il Runner fa fallback O(N×M) query (`runner.rb:353-379`). Il fast-path (`storage_overrides?`) salva su Memory adapter, ma lascia il default port debole. Promuoverlo a required semplificherebbe il Runner, ma obbligherebbe ogni adapter a implementarlo. Trade-off valido. + +### 3.18 `Record#to_snapshot` espone campi infrastrutturali +**Reviewer:** Claude. **Esito: VERO.** +`record.rb:5-19` include `payload_fingerprint`, `not_before_ms`, `external_ref` nello snapshot. Questi sono campi di storage/lease. Gli step li vedono in `metadata[:effects]`. Dovrebbero essere filtrati. + +--- + +## 4. Sintesi per componente + +| Componente | LOC | Consensus | Problema principale | +|---|---|---|---| +| **Graph** | 696 | Il migliore del progetto (9/10) | `to_dot` e algoritmi path potrebbero essere estratti; `nodes` cambia object_id | +| **Runner** | 497 | Corretto ma troppo denso (6/10) | SRP violato; `handle_outcome` mescola decisione e side-effect; `canonical_committed_attempt` ottimizzazione prematura | +| **StorageState** | 760 | Il rischio più grande (5/10) | C-style procedurale; troppi concern; unico punto di mutazione | +| **Effects subsystem** | ~1078 | Solido (8/10) | 2 DRY violations minori; idempotency conflict non gestito in Runner | +| **Validation** | 182 | Funziona (7/10) | Verboso ma esplicito; non vale il refactoring | +| **Test suite** | ~7000 | Eccellente (9/10) | 515 test, 0 fallimenti; fuzz, crash simulation, fingerprint stability | +| **Ports** | ~338 | Necessariamente grassi | Atomic boundaries richiedono metodi grossi; non spezzare | + +--- + +## 5. Proposte concrete — graduate per costo/beneficio + +### 5.1 Basso costo, alto valore (~1h totale) + +| # | Cambiamento | File | Motivazione | +|---|---|---|---| +| 1 | Rinomina commento `Await` "Monad-like" → "Effect snapshot dispatcher" | `effects/await.rb:5` | Onestà semantica. 1 minuto. | +| 2 | Aggiungi `Validation.member!(state, RUN_RESULT_STATES)` in `RunResult#initialize` | `run_result.rb:27` | Uniformità con tutti gli altri value object. 3 righe. | +| 3 | Filtra campi infrastrutturali da `Record#to_snapshot` | `effects/record.rb:5-19` | Separa semantica (step) da infra (storage). 4 righe. | +| 4 | Estrai `storage_overrides?` in helper condiviso | `ports/storage.rb` o modulo | Elmina duplicazione in 3 file. 5 minuti. | +| 5 | Elimina duplicazione `immutable_json_copy` | `effects/dispatcher.rb` | Stesso file, 2 classi. 2 minuti. | +| 6 | Sostituisci `canonical_committed_attempt` con `select.max_by` | `runner.rb:393-412` | Leggibilità > micro-perf. 3 righe. | +| 7 | Rimuovi o sposta `REVIEW.md` obsoleto | root | Misleading per future revisioni. 1 minuto. | +| 8 | Documenta in `CLAUDE.md` che il boilerplate Data.define è intenzionale | `CLAUDE.md` | Chiude il falso claim DRY. 5 minuti. | + +### 5.2 Costo medio, valore medio (~3-4h) + +| # | Cambiamento | Motivazione | +|---|---|---| +| 9 | Cattura `IdempotencyConflictError` in Runner e converti in failure terminale | Evita loop operativi infiniti su conflitto idempotenza | +| 10 | Tighten validazione su `StepInput`, `Event`, `RuntimeProfile` | Codex ha ragione: API pubblica deve rifiutare garbage | +| 11 | Aggiorna production readiness script per usare `Definition::Builder` | Usa il fast path previsto dal design | +| 12 | Documenta CoW limitato a value objects + storage-CoW pendente | Onestà del claim "concorrenza futura" | + +### 5.3 Costo alto, valore architetturale (~6-8h, pre-S0) + +| # | Cambiamento | Motivazione | +|---|---|---| +| 13 | Split `StorageState` per dominio (Workflow, Node/Attempt, Effect, Event) | 760 LOC sono troppi per un file; prepara il terreno per SQLite | +| 14 | Estrai `OutcomeHandler` e `Finalizer` da Runner | Solo quando aggiungi feature al Runner, non per estetica | +| 15 | Estrai `Graph::Algorithms` e `Graph::DotFormatter` | SRP; ma non urgente | + +### 5.4 Proposte da RIFIUTARE — con motivazione + +| Proposta | Reviewer | Perché rifiutare | +|---|---|---| +| Spezza Storage port in 4 port separati | Kimi | Romperebbe le atomic boundaries (`commit_attempt` scrive attempt+nodo+evento+effetti). Il port è grasso perché le transazioni sono grasse. | +| Rendi `Waiting` un `Result` con `and_then` | Kimi | Waiting è control-flow, non valore. La scelta di escluderlo è corretta e difesa da Codex. | +| Aggiungi `value_or`, `tap`, `map_error` a Result | Kimi | `result.rb:21-24` spiega perché sono esclusi: "trivially expressible in two lines of caller code". Surface minima = commitment a lungo termine. | +| Sostituisci `StorageState` con classi incapsulate | Kimi | La forma `module_function` è path-allowlistata dal cop `NoInPlaceMutation`. Cambiare forma senza cambiare semantica è churn. | +| OCC invece di storage fat (Gemini) | Gemini | Spostare la logica transazionale nel Runner significherebbe più round-trip e riaprire finestre di crash. Le atomic boundaries attuali sono intenzionali. | +| Rinomina CoW in `ImmutableCopy` | Kimi | `ExecutionContext#merge` usa `@data.merge(patch)` che è CoW dell'hash Ruby (shallow), poi `ExecutionContext.new` fa deep-freeze. Non è "copia totale" in tutti i casi. Il nome è approssimato ma non fuorviante. | + +--- + +## 6. Verdetto finale + +Il progetto è **serio**. Chi l'ha scritto conosce Wadler e ha shippato Ruby in produzione. I constraint sono enforced in codice (custom cops), non solo in prosa. I test sono di alta qualità (fuzz, crash simulation, fingerprint stability a 100 run). Non ci sono bug critici. + +**Cosa regge:** +- Zero deps, immutabilità, ports-and-adapters, test suite, custom cops, atomic boundaries nel storage, determinismo bit-identico. + +**Cosa non regge:** +- Il claim "monadi" è parziale (solo `Result` lo è). +- Il claim "CoW per concorrenza futura" è parziale (storage non pronto). +- Il claim "bilanciamento perfetto OOP/FP" è retorica. +- Il claim "DRY" è parziale (~200 righe di duplicazione regolare su value object). + +**I tre debiti veri:** +1. **Boilerplate Data.define** (~200 righe). Vivila o documentala come intenzionale. +2. **Overstatement nei commenti** (`Await` "monad-like", CoW "per concorrenza"). Riformula. +3. **Runner e StorageState sono troppo densi** per la loro età. Non rifattorizzare per estetica — aspetta che una nuova feature (S0 SQLite) costringa lo split naturale. + +**Useresti questa libreria?** +Sì, per workflow deterministico single-process con futuro durable adapter. No, se ti aspetti multi-process out-of-the-box. + +**Stato:** production-ready alpha per il workload dichiarato. Una lista di nit di ~3 ore chiude il gap tra il codice e la sua narrazione. + +--- + +## 7. Tabella di verifica — revisione delle review + +| Claim | Reviewer | Esito | File:line verificato | +|---|---|---|---| +| `RunResult` non valida state | Claude, Codex | ✅ VERO | `run_result.rb:27-37` | +| `Await` non è monade | Claude, Gemini, Deepseek | ✅ VERO | `await.rb:5` | +| `Graph#nodes` cambia object_id | Kimi | ✅ VERO | `graph.rb:36-37` | +| `Runner` troppo lungo | Deepseek, Kimi | ✅ VERO | `runner.rb` (497 LOC) | +| `StorageState` C-style | Kimi | ✅ VERO | `storage_state.rb:12-13` | +| `StorageState` troppo grande | Tutti | ✅ VERO | `storage_state.rb` (760 LOC) | +| `storage_overrides?` duplicato | Deepseek, Kimi | ✅ VERO, 3 file | `runner.rb:382`, `dispatcher.rb:285`, `mutation_service.rb:76` | +| `immutable_json_copy` duplicato | Deepseek | ✅ VERO | `dispatcher.rb:40-44, 106-110` | +| Event types = 13 | Deepseek | ❌ FALSO | `event.rb:61-71` (10 tipi) | +| `REVIEW.md` obsoleto | Claude, Codex | ✅ VERO | `REVIEW.md:1` (parla di `Exec`, `drain_pipes`) | +| Effect conflict lascia stuck | Codex | ✅ VERO | `spec/support/storage_contract/effects.rb:87` | +| Spezza Storage port in 4 | Kimi | ❌ RIFIUTATO | `ports/storage.rb` (atomic boundaries) | +| Rendi Waiting un Result | Kimi | ❌ RIFIUTATO | `result.rb:4-5` (scelta deliberata) | +| `canonical_committed_attempt` ottimizzazione prematura | Claude, Deepseek, Kimi | ✅ VERO | `runner.rb:393-412` | +| `to_dot` non dovrebbe stare in Graph | Kimi | ✅ VERO, ma bassa priorità | `graph.rb:419-436` | +| `frozen_copy` troppo fiducioso su Set/custom | Kimi | ✅ VERO | `immutability.rb` (controllo `frozen?` superficiale) | + +--- + +*Fine. Niente da riscrivere, molto da aggiustare.* diff --git a/docs/reviews/final0review0-v1.0.1-deepseek-v4-flash.md b/docs/reviews/final0review0-v1.0.1-deepseek-v4-flash.md new file mode 100644 index 0000000..842dd14 --- /dev/null +++ b/docs/reviews/final0review0-v1.0.1-deepseek-v4-flash.md @@ -0,0 +1,343 @@ +# Final Review: ruby-dag v1.0.1 + +> Sintesi di 5 review incrociate (Claude, Codex, Gemini, DeepSeek, Kimi), +> ogni claim verificato contro il codice reale con `file:line`. +> Stile Antirez: niente lodi, niente carezze, solo fatti e priorità. + +--- + +## Come è stata costruita questa finale + +Ho letto tutte e 5 le review, poi ho aperto il codice a ogni `file:line` +citato per verificare. Alcune review hanno fatto claim falsi o imprecisi; +altre hanno trovato cose reali ma non tutto. Questa finale racconta cosa +emerge quando le incroci. + +--- + +## 1. La visione — verdetto dopo verifica incrociata + +### 1.1 Zero dipendenze esterne — ✅ CONFERMATO DA TUTTI + +`ruby-dag.gemspec` senza runtime deps, solo stdlib (`json`, `digest`, +`securerandom`). Nessuna review contesta. Il claim regge. + +### 1.2 Monadi — ⚠️ PARZIALE (tutti d'accordo) + +Tutte e 5 le review concordano: `Result` su `Success | Failure` è una +mini-monade legittima (`and_then`, `map`, `recover`). Ma: + +- **`Waiting` non è `Result`** — scelta di design esplicita e difendibile, + ma significa che il tipo di ritorno degli step è eterogeneo. +- **`Effects::Await`** (`await.rb:5`) si autodefinisce "Monad-like" — è + falso. È un case dispatch con `yield`. Nessuna composizione, nessun + `bind`. Claude, DeepSeek, e Kimi lo notano tutti. + +**Verdetto**: togli la parola "monade" da `Await`. Tienila per `Result` +che se la è guadagnata. + +### 1.3 Tipi dati immutabili — ✅ CONFERMATO (con eccezione nota) + +16+ `Data.define`, `frozen_copy` ai confini, `deep_freeze` con cycle +detection. Disciplina reale. + +Kimi nota un buco reale: `frozen_copy` (`immutability.rb:16-19`) controlla +`solo` se l'oggetto è `frozen?` e non è Hash/Array. Un `Set` congelato con +elementi mutabili passa. Non è un bug oggi (ruby-dag non usa Set per dati +utente), ma è una falla nel contratto di `frozen_copy`. + +**Verdetto**: aggiungi `Set` alla guardia di `frozen_copy`. Due minuti, +chiude un buco dichiarato. + +### 1.4 Copy-on-write — ❌ SBAGLIATO NEL NOME (tutti d'accordo) + +Tutte le review dicono la stessa cosa: non è CoW, è "copy every time". +`ExecutionContext#merge` fa `deep_dup` + `deep_freeze` completo dell'intero +hash. Gemini avverte di pressione GC su workflow grandi — è teorico ma non +sbagliato. + +**Verdetto**: rinomina il concetto. Non è "copy-on-write", è +"immutable-by-copy". È comunque corretto come disciplina — è solo un nome +fuorviante. + +### 1.5 Bilanciamento OOP/FP — DIVISO + +Qui le review si spaccano: + +- **Claude**: "retorica" — ogni layer sceglie il paradigma comodo +- **Codex**: "uno dei punti migliori" — split naturale +- **DeepSeek**: 8/10 — "genuino e applicato coerentemente" +- **Gemini**: "eccellente" — paragone con Raft +- **Kimi**: "mescolanza senza regola" — vuole Graph FP puro con builder + +**Verdetto**: Kimi è troppo severo, Claude è troppo cinico. Il design è +pragmatico e funziona: FP per i valori, OOP per orchestrazione, Ports per +confini. Non è "perfetto bilanciamento" ma non serve che lo sia. + +### 1.6 Ruby idiomatico — ✅ BUONO + +Tutti concordano: niente `method_missing`, niente `attr_accessor`, +`Data.define`, `each_predecessor` con `enum_for`, cop custom. Il pattern +`class << self; remove_method :[]; end` è boilerplate ma è Ruby esplicito. + +### 1.7 DRY — ⚠️ PARZIALE + +Quello che TUTTI hanno perso: **`storage_overrides?` è triplicato** in: +- `runner.rb:382-386` +- `dispatcher.rb:285-289` +- `mutation_service.rb:76-80` + +DeepSeek ne ha trovati 2. Claude e Codex 1. Kimi 0. Nessuno ha beccato +tutte e 3 le copie. + +`immutable_json_copy` è duplicato dentro `dispatcher.rb` stesso (`HandlerOutcome` linea 40 e `DispatchOutcome` linea 106). + +Il boilerplate `Data.define` + `remove_method :[]` + keyword constructor +custom si ripete in ~9 file (~150 righe). Claude lo documenta bene. + +**Verdetto**: estrai `storage_overrides?` in `DAG::Ports::Storage` come +helper. 5 minuti, elimina 3 copie identiche. Il boilerplate Data.define +lascialo stare — la duplicazione è regolare e `CLAUDE.md` la giustifica. + +--- + +## 2. Cosa ogni review HA TROVATO DI GIUSTO (che le altre hanno perso) + +| Finding | Review | Verifica | +|---------|--------|----------| +| `IdempotencyConflictError` NON gestito dal Runner → workflow bloccato | **Codex** | ✅ `runner.rb:285-291` nessun `rescue`. Tentativo resta `:running`, irrecuperabile | +| `RunResult` non valida `state` | **Claude** | ✅ `run_result.rb:27-37` — nessun `Validation.member!` | +| `nodes` object_id diverso frozen/unfrozen | **Kimi** | ✅ `graph.rb:36-38` — `frozen? ? @nodes : @nodes.dup.freeze` | +| `to_dot` non dovrebbe stare in `Graph` | **Kimi** | ✅ `graph.rb:419-436` — rendering in classe struttura dati | +| `canonical_committed_attempt` 19 linee per micro-ottimizzazione | **Claude** | ✅ `runner.rb:393-412` — loop manuale seleziona.max_by | +| Event types: contract 10, codice 13 | **DeepSeek** | ✅ disallineamento documentazione | +| `RuntimeProfile.defaults` non usati dal Runner | **DeepSeek** | ✅ i default esistono ma non sono applicati dal costruttore | +| GC pressure da deep_freeze su contesti grandi | **Gemini** | Teorico, non verificato empiricamente | + +--- + +## 3. Cosa ogni review HA SBAGLIATO o SOPRAVVALUTATO + +### Gemini (45 linee) + +Il più superficiale. Claim su GC pressure da `deep_freeze` è una +preoccupazione legittima ma **non verificata** — nessun benchmark, nessun +profiling, nessuna metrica. Per workflow con decine di nodi (caso d'uso +dichiarato), è FUD. + +La proposta di OCC (Optimistic Concurrency Control) al posto di transazioni +atomiche è tecnicamente valida ma ignora che `CONTRACT.md` già specifica +`prepare_workflow_retry` come operazione atomica per ragioni di crash +safety. Sostituirlo con CAS + read-retry aprirebbe una finestra di crash +proprio dove il design la chiude. + +### Kimi (255 linee) + +L'aggressività a volte supera la precisione: + +- "`Data.define` + `initialize` sovrascritto è anti-idiomatico Ruby 3.4" + — falso. Ruby 3.4 `Data.define` documenta esplicitamente che + `initialize` può essere sovrascritto per validazione. È uso previsto, + non abuso. +- `Graph` ha `to_dot` e path algorithms → "Fa troppe cose". Vero per + `to_dot`, ma `shortest_path`/`longest_path` su DAG sono operazioni + naturali su un grafo — spostarle in `Graph::Algorithms` è pulizia, non + necessità. +- "StorageState è C-style Ruby" — vero formalmente (`module_function` + + `state` esplicito), ma il design è **intenzionale**: è l'unico posto in + tutto `lib/dag/**` dove la mutazione è permessa, e `module_function` + senza variabili d'istanza rende impossibile avere stato leakato. È una + scelta di isolamento, non di stile. + +### Claude (623 linee) + +La più completa, ma anche lei ha perso la terza copia di +`storage_overrides?` e l'IdempotencyConflictError non gestito. Il claim +che `handle_outcome` mescola decisione e side-effect è vero, ma la +separazione proposta (+30-40 LOC) aggiunge complessità per testabilità +che oggi non serve — `handle_outcome` è testato indirettamente via +integration test. + +### Codex (454 linee) + +Il più equilibrato. La raccomandazione "freeze the growth of the storage +port" è saggia. Ha perso solo finding minori (`to_dot`, `nodes object_id`). + +### DeepSeek (382 linee) + +Il più concreto sui DRY violations. Score system utile. Ha perso la terza +copia di `storage_overrides?` e l'IdempotencyConflictError. + +--- + +## 4. Problemi veri (dopo verifica incrociata, in ordine di priorità) + +### 🔴 HIGH — `IdempotencyConflictError` rende il workflow irrecuperabile + +**File**: `runner.rb:267-293` + `storage_state.rb:603` +**Verifica**: `IdempotencyConflictError` NON è mai catturato nel Runner. +Propaga al chiamante, l'attempt resta `:running`, il nodo resta `:running`, +il workflow è bloccato permanentemente. +**Costo fix**: ~20 righe in `Runner#call` o `commit_and_emit` per catturare +l'eccezione e convertirla in `Failure` terminale con errore strutturato. +**Chi l'ha trovato**: solo Codex. + +### 🔴 HIGH — `storage_overrides?` triplicato in 3 file + +**File**: `runner.rb:382`, `dispatcher.rb:285`, `mutation_service.rb:76` +**Verifica**: 3 copie identiche di 5 righe, stessa logica, stessi nomi. +**Costo fix**: 5 minuti — estrarre in `DAG::Ports::Storage` come helper. +**Chi l'ha trovato**: DeepSeek (2 copie), Claude (1), Codex (1), +Kimi (0). **Nessuno ha beccato tutte e 3**. + +### 🟡 MEDIUM — `RunResult` non valida `state` + +**File**: `run_result.rb:27-37` +**Costo fix**: 3 righe — `Validation.member!(state, RUN_RESULT_STATES)`. +**Chi l'ha trovato**: Claude. + +### 🟡 MEDIUM — `canonical_committed_attempt` è micro-ottimizzazione prematura + +**File**: `runner.rb:393-412` +**Verifica**: 19 righe di loop manuale con `best`/`best_id`/`candidate_id` +per risparmiare l'allocazione di un Array intermedio. Per ogni workflow +con N nodi e M tentativi, risparmia O(N×M) allocazioni di array da 2 +elementi. Su qualunque profilo realistico, è polvere. +**Alternativa**: 3 righe con `select.max_by`. +**Chi l'ha trovato**: Claude. + +### 🟡 MEDIUM — `nodes` restituisce oggetto diverso prima/dopo freeze + +**File**: `graph.rb:36-38` +**Problema**: `frozen? ? @nodes : @nodes.dup.freeze` — l'object_id cambia. +Chi usa il grafo come chiave di Hash o in comparazione prima e dopo freeze +rompe i bucket. Fix: ritorna sempre `@nodes.dup.freeze` o sempre `@nodes`. +**Chi l'ha trovato**: Kimi. + +### 🟢 LOW — `to_dot` in `Graph` + +**File**: `graph.rb:419-436` +**Problema**: metodo di rendering in classe struttura dati. +**Fix**: sposta in `DAG::Graph::DotFormatter` o lascia stare — Graphviz è +un formato stabile, non un accoppiamento volatile. Kimi ha ragione in +teoria, ma il costo del refactor supera il beneficio oggi. +**Chi l'ha trovato**: Kimi. + +### 🟢 LOW — `Effects::Await` si autodefinisce "Monad-like" + +**File**: `await.rb:5` +**Fix**: cambia il commento. 1 minuto. +**Chi l'ha trovato**: Claude (più dettaglio), tutti gli altri lo notano. + +### 🟢 LOW — Event type count disallineato (contract 10, codice 13) + +**File**: `CONTRACT.md` vs eventi reali +**Chi l'ha trovato**: DeepSeek. + +### 🟢 LOW — `RuntimeProfile.defaults` non applicati dal Runner + +**File**: runtime_profile.rb (defaults) vs runner.rb (non li usa) +**Chi l'ha trovato**: DeepSeek. + +### 🟢 LOW — `immutable_json_copy` duplicato dentro `dispatcher.rb` + +**File**: `dispatcher.rb` — `HandlerOutcome` e `DispatchOutcome` +**Chi l'ha trovato**: DeepSeek. + +--- + +## 5. Cosa le review NON hanno detto (buchi collettivi) + +Nessuna review ha esaminato a fondo: + +1. **Security**: injection/cross-contamination tra workflow diversi + tramite lo stesso Memory::Storage. Non è stata testata isolamento. +2. **Property-based testing**: il kernel deterministico è un candidato + perfetto per property testing (Rantly o similare). Nessuna review + lo suggerisce. +3. **Documentazione API pubblica/privata**: non è stata valutata la + completezza della documentazione YARD o la superficie pubblica. +4. **Benchmark reali**: nessuno ha prodotto un benchmark numerico del + costo di `deep_freeze` su contesti di dimensioni reali. +5. **Release readiness**: nessuno ha valutato se v1.0.1 può essere + rilasciata così com'è — tutte le review si sono concentrate sul + codice, non sulla prontezza di shipping. + +--- + +## 6. Verdetto finale + +### Il codice è solido + +Dopo aver verificato ogni claim di 5 review contro il codice reale, +posso dire: **non ci sono bug critici**. I problemi sono di scala, +di naming, e di confini — non di correttezza. + +- 490 test passano +- 2 cop custom proteggono i constraint architetturali +- Il kernel è deterministico (fingerprint test a 100 run) +- Crash simulation testa resume +- Storage contract test condivisi + +### Tre cose da fixare PRIMA del prossimo rilascio + +| # | Cosa | Dove | Tempo | +|---|------|------|-------| +| 1 | Cattura `IdempotencyConflictError` nel Runner | `runner.rb` | 30min | +| 2 | Estrai `storage_overrides?` in helper condiviso | 3 file | 5min | +| 3 | Aggiungi validazione `state` in `RunResult` | `run_result.rb` | 3min | + +### Tre cose da fare entro R3/R4 + +| # | Cosa | Perché | +|---|------|--------| +| 1 | Rinomina "CoW" in "immutable-by-copy" in docs | Onestà semantica | +| 2 | Rinomina commento `Await` "monad-like" | Onestà semantica | +| 3 | Refactor `canonical_committed_attempt` in 3 righe | Leggibilità | + +### Cosa NON fare + +- Non splittare StorageState in 4 sub-componenti ora (Kimisuggerisce) — + il file è 760 LOC ma è l'unico posto mutabile. Una volta che arriva + SQLite (S0), StorageState sarà la blueprint. Split interno va bene + come refactor pre-S0, non come emergenza. +- Non splittare `Graph` in Graph + Algorithms + DotFormatter — + `to_dot` va spostato, `shortest_path`/`longest_path` possono restare. + Non serve una libreria di algoritmi separata per un DAG di 100 nodi. +- Non rendere `Waiting` un `Result` (Kimi) — è una scelta architetturale + difendibile e 4 review su 5 la approvano. + +### Voto riassuntivo + +| Componente | Voto | Fonte principale | +|---|---|---| +| Graph | 9/10 | DeepSeek | +| Runner | 6/10 | DeepSeek | +| StorageState | 5/10 | DeepSeek + Kimi | +| Effects subsystem | 8/10 | DeepSeek | +| Test suite | 9/10 | DeepSeek | +| Immutabilità | 8/10 | Claude + Kimi (corretto per buco `frozen_copy`) | +| Vision accuracy | 6/10 | Claude (più onesta sulle sbavature) | +| Overall | 7.5/10 | | + +### Giudizio finale + +Il progetto è **solido ma si vende meglio di quanto sia**. La visione +dice "monadi, CoW, bilanciamento perfetto OOP/FP, DRY". La realtà dice: +"una monade vera (`Result`), copia totale difensiva (`deep_dup` + +`deep_freeze`), pragmatismo layer-by-layer, DRY con 3 copie identiche +di `storage_overrides?` e 150 righe di boilerplate Data.define". + +Togli la distanza tra il codice e la sua narrazione e hai una libreria +da 8/10. Lasciala e hai un progetto che suona meglio di come è — +peccato mortale per un ingegnere onesto. + +I fix urgenti sono 3 e richiedono ~40 minuti totali. I refactor +strutturali (Runner splitting, StorageState splitting, port granulari) +aspettano R3/R4 — e vanno fatti PRIMA di aggiungere feature, non dopo. + +--- + +*Review generata incrociando 5 revisioni (Claude 623L, Codex 454L, +Gemini 45L, DeepSeek 382L, Kimi 255L) e verificando ogni `file:line` +citato contro il codice reale in `lib/dag/`.* diff --git a/docs/reviews/gemini-review-v1.0.1.md b/docs/reviews/gemini-review-v1.0.1.md new file mode 100644 index 0000000..ff211d9 --- /dev/null +++ b/docs/reviews/gemini-review-v1.0.1.md @@ -0,0 +1,45 @@ +# Antirez Review - ruby-dag + +Ciao. Mi hai chiesto un'analisi onesta e diretta, stile "Antirez", senza accarezzare l'ego e basata sui fatti. Parliamo di ingegneria. + +Ho letto il tuo codice, l'architettura, i file di contratto (`CONTRACT.md`, `CLAUDE.md`) e il design del kernel (`runner.rb`, `graph.rb`, `immutability.rb`, `result.rb`). + +Partiamo dalla tua **Vision**. + +### La Vision: Onesta validazione + +1. **Zero dipendenze esterne:** **Assolutamente sì.** Questo è il modo in cui si costruisce software che sopravvive 10 anni. Un kernel non dovrebbe mai dipendere dai capricci dell'ecosistema. +2. **Monadi (`Success`, `Failure`, `Waiting`):** Sono d'accordo con la tua implementazione perché l'hai tenuta *vincolata*. Non hai cercato di trasformare Ruby in Haskell con `method_missing` e categorie astratte. Hai usato tipi somma (Sum Types) espliciti al confine dei tuoi step. Questo rende il contratto di I/O prevedibile. Va bene così, non aggiungere altra complessità a questo livello. +3. **Tipi di dati immutabili & Copy-on-Write (CoW):** In linea di principio, è la scelta corretta per sistemi distribuiti o concorrenti. Evita le "race conditions" by design. **Ma c'è un problema enorme di performance in Ruby** (ne parliamo nell'analisi). +4. **Bilanciamento perfetto tra OOP e FP:** L'hai raggiunto. Il tuo `Runner` è essenzialmente una funzione pura iniettata (dependency injection) che prende uno stato dal DB, calcola le transizioni e restituisce gli intenti. I dati (`ExecutionContext`, `Graph`) sono stupidi e immutabili. Le classi OOP (`Runner`, `Dispatcher`) non hanno stato mutabile. Questo è un design eccellente, simile a come si scrivono le macchine a stati nei sistemi distribuiti seri (es. Raft). +5. **Ruby idiomatico e DRY:** Il tuo approccio al DRY ("non renderlo astratto o magico") è musica per le mie orecchie. Il codice è noioso da leggere. **Il codice noioso è codice perfetto.** + +### Analisi Tecnica: Pro, Contro e Realtà dei fatti + +Sei riuscito a separare in modo netto il "calcolo" (il grafo, l'eleggibilità dei nodi) dall'"esecuzione" (effetti collaterali, storage). Il design degli **Effect Intents** (`DAG::Effects::Intent`) è brillante: separare la dichiarazione di un I/O dalla sua esecuzione fisica mantiene il tuo kernel puro e deterministico. + +Tuttavia, ci sono dei compromessi strutturali che devi guardare in faccia. + +#### 1. Il costo nascosto dell'Immutabilità in Ruby (Il problema del CoW) +Il tuo file `lib/dag/immutability.rb` implementa `deep_freeze` e `deep_dup` ricorsivi, tenendo traccia degli `object_id` (`seen = {}`) per evitare cicli. Il tuo `ExecutionContext` fa un `merge` e poi un `deep_freeze` su payload arbitrari JSON-safe. +* **Il Fatto:** Ruby (MRI) non ha vere strutture dati persistenti (come i Trie in Clojure). Quando fai CoW su un Hash, stai letteralmente copiando in memoria l'intero albero di oggetti. +* **Il Contro:** In un workflow con centinaia di nodi, dove il contesto cresce ad ogni step, questo design **distruggerà le performance del Garbage Collector**. Avrai pause enormi. La "concorrenza futura" di cui parli usando il CoW in memoria è un'illusione se il GC ti ferma il mondo per spazzare via migliaia di hash allocati e buttati ad ogni transizione di nodo. +* **Alternativa che rispetta la vision:** Invece di fare deep copy e deep freeze ad ogni merge in Ruby, tratta l'`ExecutionContext` come un layer di sola lettura (read-through). Conserva la patch history. Quando un nodo legge, vai a ritroso nelle patch (come un log-structured tree). Risolvi l'immutabilità serializzando/deserializzando al confine dello Storage, non creando cloni in memoria ad ogni step di un ciclo while. + +#### 2. Il Contratto dello Storage è troppo "grasso" +Hai un kernel molto puro, ma hai scaricato una quantità enorme di logica complessa sull'adattatore di storage. +* **Il Fatto:** `CONTRACT.md` mostra metodi come `prepare_workflow_retry` che devono fare un CAS (Compare-And-Swap) sullo stato, validare un budget, resettare tentativi multipli, incrementare contatori e loggare eventi... **tutto in una singola transizione atomica**. +* **Il Contro:** Stai forzando chiunque scriva un adattatore (Postgres, SQLite) a scrivere logica di business in SQL (o peggio, a prendere lock distribuiti pesanti). Se il DB è stupido e il kernel è intelligente, perché chiedi al DB di fare calcoli transazionali complessi? +* **Alternativa che rispetta la vision:** Il kernel dovrebbe leggere lo stato, decidere cosa fare, e dire allo storage: *"Applica questo pacchetto di mutazioni esatto, ma SOLO SE il `revision_id` o `last_event_seq` del workflow è ancora X"*. Questo si chiama Optimistic Concurrency Control (OCC). Riduci il contratto dello storage a primitive elementari di inserimento e CAS. Sarà mille volte più facile scrivere un adapter concorrente (S0 per SQLite ti ringrazierà). + +#### 3. Cicli nel Grafo O(V+E) ad ogni `add_edge` +Hai scelto di validare l'aciclicità ricalcolando la raggiungibilità ogni volta che aggiungi un arco. +* **Il Fatto:** È una scelta pragmatica per grafi piccoli, e impedisce di avere uno stato intermedio non valido. +* **Il Contro:** Per workflow dinamici generati a runtime, questo è computazionalmente quadratico. +* **Il Pro (Sì, un pro):** Va benissimo così. Non ottimizzarlo finché un utente non si lamenta che costruire un DAG di 50.000 nodi è lento. E se lo fa, l'alternativa giusta (come hai già commentato nel codice) è una costruzione "bulk" che valida solo alla fine. Ottima scelta ingegneristica. + +### Il Verdetto di Antirez + +Il progetto è solido. Non ci sono astrazioni inutili. L'idea di un runtime deterministico che orchestra effetti astratti è potente e scalabile. + +Sei sulla strada giusta. Ma fai molta attenzione alla penalità di performance del `deep_freeze` in Ruby e non trasformare l'interfaccia del tuo Storage Adapter in un mostro monolitico. Mantieni le primitive del DB stupide (CAS e Append-Only) e fai fare il lavoro duro al tuo deterministico e immutabile `Runner`. \ No newline at end of file diff --git a/docs/reviews/opencode-deepseek-v4-flash-review-v1.0.1.md b/docs/reviews/opencode-deepseek-v4-flash-review-v1.0.1.md new file mode 100644 index 0000000..713d884 --- /dev/null +++ b/docs/reviews/opencode-deepseek-v4-flash-review-v1.0.1.md @@ -0,0 +1,382 @@ +# Code Review: ruby-dag v1.0.1 + +*Recensione nello spirito di antirez — diretta, onesta, focalizzata sulla +semplicità e correttezza. 46 commit analizzati su master.* + +--- + +## La tua visione — valutazione onesta + +### Zero dipendenze esterne — **Raggiunto al 100%** + +Solo stdlib Ruby: `digest`, `json`, `securerandom`. Niente Gemfile.lock +sorprese, niente CVEs transitive, niente "funziona sulla mia macchina". +Questo è il fondamento più solido del progetto. Rispetto. + +### Monadi — **Parzialmente** + +`Success` e `Failure` formano un Either monade con `and_then`, `map`, +`recover`. Funziona. I tipi sono immutabili, il `Result.try` cattura +eccezioni in modo pulito. + +Ma `Waiting` è volutamente escluso dal `Result` marker. Lo step protocol +è `Success | Waiting | Failure`, non una monade a 3 stati. Il contratto +lo dice esplicitamente: *"Waiting is a valid step outcome, but it is not +a DAG::Result"*. + +Questa è una scelta architetturale difendibile — `Waiting` ha semantica +diversa (non è un terminal value, è un "riprova dopo") — ma non chiamiamolo +"monadi" in senso pieno. Hai un Either monade e un tipo separato che il +Runner tratta come caso speciale nel suo state machine. + +**Proposta**: Non cambiare. La chiarezza del contratto (3 outcome separati) +vale più della purezza algebrica. Se in futuro servisse una monade a 3 +stati, si può unificare, ma oggi non serve. + +### Tipi dati immutabili — **Fatelo bene** + +`Data.define` ovunque. `deep_freeze` con cycle detection (visto! +`seen[value.object_id]`). `frozen_copy` come boundary helper. JSON safety +enforced in ogni costruttore. La disciplina è reale e applicata. + +### Copy-on-write per mutazioni — **Onestamente: copia profonda, non CoW** + +`ExecutionContext#merge` ritorna una nuova istanza ma fa deep-dup completo +dell'hash interno. Vero CoW (strutture condivise con lazy clone) sarebbe +più performante ma molto più complesso. Quello che hai è semanticamente +immutabile per copia. Funziona. Non cambiarlo — la complessità di vero CoW +non vale il beneficio per workflow con decine di nodi. + +### Bilanciamento OOP e FP — **Il meglio del progetto** + +Il Graph è OOP classico con stato interno mutabile prima del freeze. I +value objects (Success, Failure, Event, Intent, Record) sono FP puro. Il +Runner è un oggetto congelato che orchestra funzioni pure. `Step::Base` è +OOP con un contratto FP (`#call(StepInput) -> Success|Waiting|Failure`). +L'equilibrio è genuino e applicato coerentemente. + +### Ruby idiomatico — **Sì, con un'eccezione** + +`Data.define` + `remove_method :[]` + keyword constructor custom è una +pattern ripetuto ~15 file × 8 linee ≈ 120 linee di boilerplate. Funziona, +è esplicito, è debuggabile. La scelta è difendibile. + +D'altro canto, la decisione di non includere `Enumerable` in Graph (per +evitare ambiguità `graph.map` = nodes? edges?) è idiomatica e coraggiosa. +Nomi in snake_case, `!` per metodi validanti, `module_function` — tutto +Ruby classico. + +### DRY — **Promosso con riserva** + +C'è una copia reale di `storage_overrides?` in: + +- `runner.rb:382-386` +- `effects/dispatcher.rb:285-289` + +Stessa logica, stesse 5 linee. Identiche. Questo è un DRY violation. + +Anche `immutable_json_copy` è duplicato in: +- `effects/dispatcher.rb` classe `HandlerOutcome` (linea 40-46) +- `effects/dispatcher.rb` classe `DispatchOutcome` (linea 106-112) + +Stesso file! + +--- + +## Analisi per componente + +### Graph (`lib/dag/graph.rb`, 696 linee) — **Voto 9/10** + +Il miglior pezzo del progetto. Scelte giuste ovunque: + +- Nodi come Symbol (`to_sym` su input) +- Edge come `Data.define(:from, :to, :metadata)` — primo cittadino +- Cycle detection O(V+E) su ogni `add_edge` +- Topological sort deterministico con tie-break ASCII ad ogni frontiera Kahn +- Lazy caching su freeze di layers, sort, roots, leaves, edges +- `each_node` / `each_edge` SOLI entry point di iterazione +- Senza `Enumerable` mixin — scelta coraggiosa e corretta +- Path algorithms (shortest, longest, critical) con topological relaxation +- `frozen_layers` che freeze ogni layer individualmente + +```ruby +def freeze + return self if frozen? + @nodes.freeze + @adjacency.each_value(&:freeze) + @adjacency.freeze + @reverse.each_value(&:freeze) + @reverse.freeze + @edge_metadata.each_value(&:freeze) + @edge_metadata.freeze + @cached_layers = frozen_layers(compute_topological_layers) + @cached_sort = @cached_layers.flatten.freeze + @cached_roots = nodes_with_no(@reverse).freeze + @cached_leaves = nodes_with_no(@adjacency).freeze + @cached_edges = compute_edges.freeze + super +end +``` + +La `:nocov:` a linea 575-579 con spiegazione del perché è irraggiungibile +ma tenuta come difesa mostra attenzione ai dettagli rara. + +**Cosa non mi piace**: +- `replace_node` è 20 linee. Non complesso, ma lungo. +- `relax` usa `Array(sources)` per normalizzare singolo/array — flessibilità + che costa chiarezza nell'API. + +### Runner (`lib/dag/runner.rb`, 497 linee) — **Voto 6/10** + +Fa troppo. Un singolo orchestratore che: + +1. `run_workflow` (linea 102-127) — loop principale con controllo flow +2. `acquire_running` (linea 129-139) — transizione stato +3. `build_run_context` (linea 142-161) — costruzione contesto +4. `build_step_instances` (linea 163-174) — caching step +5. `append_workflow_started_once` (linea 177-184) — emissione eventi +6. `eligible_nodes` (linea 189-198) — calcolo eligibility +7. `execute_node` (linea 200-225) — esecuzione singolo nodo +8. `handle_outcome` (linea 227-265) — gestione 3 outcome × 2+ sub-casi +9. `commit_and_emit` (linea 267-293) — commit atomico + eventi +10. `build_step_input` (linea 295-306) — costruzione input +11. `prepare_effects` (linea 311-328) — preparazione effetti +12. `effective_context` (linea 353-364) — merge contesto +13. `committed_results_for_predecessors` (linea 366-380) — batch query +14. `canonical_committed_attempt` (linea 393-412) — scelta attempt vincitore +15. `safe_call_step` (linea 414-429) — invocazione con safety net +16. `finalize` (linea 440-453) — terminal state machine +17. `transition_and_emit_terminal` (linea 455-458) — transizione atomica +18. `atomic_transition_with_event` (linea 463-468) — CAS + evento +19. `build_run_result` / `build_event` / `append_event` — factory + +`handle_outcome` (38 linee) ha logica di retry, logica di commit, +emissione eventi, e transizione workflow in un unico case statement. + +**Proposta concreta**: Estrarre: + +- **`Eligibility`** (`eligible_nodes`, ~50 linee) +- **`OutcomeHandler`** (`handle_outcome` + `commit_and_emit`, ~100 linee) +- **`Finalizer`** (`finalize` + `build_run_result`, ~80 linee) + +Il Runner resterebbe un orchestratore di ~200 linee. Ancora frozen, +ancora stateless, ancora zero dipendenze. + +**Nota importante**: La logica è **corretta**. I test di fingerprint +deterministico a 100 run lo confermano. Ma 497 linee per un orchestratore +fanno male alla manutenibilità. Fallo quando tocchi il Runner per la +prossima feature. + +### Memory::StorageState (`lib/dag/adapters/memory/storage_state.rb`, 760 linee) — **Voto 5/10** + +Unico file mutabile in tutto `lib/dag/`. 760 linee. Nessuna struttura +interna. Qui è dove vivono i bug. + +Il cop `Dag/NoInPlaceMutation` lo esenta — giustamente — ma 760 linee di +hash mutations, CAS checks, e bookkeeping è troppo per un singolo file. + +I metodi spaziano da workflow lifecycle a node state a attempt management +a effect reservation a event log append a retry prepare. Ogni metodo muta +stato direttamente. + +**Proposta**: Dividere in 3 sub-componenti sotto `StorageState`: + +- **`StorageState::WorkflowStorage`** — create, load, transition, retry +- **`StorageState::NodeStorage`** — node states, attempts, abort +- **`StorageState::EffectStorage`** — effect reservation, claim, lease, + release, mark + +Ogni sub-componente resta l'unica fonte di mutazione per il suo dominio. +`StorageState` diventa un facade che delega. Il cop esenta il modulo, +non il singolo file. + +### Effetti Subsystem (`lib/dag/effects/`, ~1078 linee totali) — **Voto 8/10** + +Architettura solida: `Intent` → `PreparedIntent` → `Record` con status +set chiuso, lease management, handler dispatcher. La catena di +elaborazione è lineare e tracciabile. + +`Dispatcher#tick` (linea 140-159) è pulito: + +```ruby +def tick(limit:) + now_ms = @clock.now_ms + claimed = @storage.claim_ready_effects(limit:, owner_id: @owner_id, + lease_ms: @lease_ms, now_ms: now_ms) + outcomes = claimed.map { |record| dispatch_record(record) } + DispatchReport[claimed:, succeeded: outcomes.map(&:succeeded_record).compact, + failed: outcomes.map(&:failed_record).compact, + released: outcomes.flat_map(&:released), + errors: outcomes.map(&:error).compact] +end +``` + +**DRY violations reali** (stesso file `dispatcher.rb`): + +1. `storage_overrides?` identico a `runner.rb:382-386`: + ```ruby + def storage_overrides?(method_name) + return false unless @storage.respond_to?(method_name) + @storage.method(method_name).owner != DAG::Ports::Storage + end + ``` + +2. `immutable_json_copy` duplicato in `HandlerOutcome` e `DispatchOutcome`: + ```ruby + def immutable_json_copy(value) + return nil if value.nil? + return value if value.frozen? + DAG.frozen_copy(value) + end + ``` + +Entrambi da estrarre in helper condivisi (`DAG::Ports::Storage` o +`DAG::Effects` come module_function). + +### Validation (`lib/dag/validation.rb`, 182 linee) — **Voto 7/10** + +20 metodi che fanno tutti `is_a?` + raise con messaggio. `array!`, +`hash!`, `string!`, `symbol!`, `integer!`, `boolean!`, `string_or_symbol!`, +`optional_hash!`, `optional_integer!`, `optional_instance!`, +`positive_integer!`, `nonnegative_integer!`, `member!`, `revision!`, +`node_id!`, `dependency!`, `nonempty_string!`, `instance!`. + +Potresti ridurlo a ~5 metodi polimorfi: +```ruby +def type!(value, klass, label) +def optional!(value, klass, label) +def range!(value, range, label) +``` + +Ma onestamente: funziona. È esplicito. Ogni chiamata dice esattamente +cosa controlla. Il costo di refactoring non vale il beneficio. Il mio +voto è "lascia stare, funziona". + +### Test Suite (52 file, ~7000 linee, 490 test) — **Voto 9/10** + +Eccellente. Cose che pochi progetti fanno: + +- **Graph fuzz test** (`graph_fuzz_test.rb`, 708 linee, 25 test) con seed + deterministico (`DAG_FUZZ_SEED`) e iterazioni configurabili + (`DAG_FUZZ_ITERATIONS`). Checks differenziali contro implementazione naive. +- **Fingerprint stability test** a 100 run indipendenti con stesso risultato + (`context_merge_order_test.rb`) +- **Crash simulation** (`CrashableStorage`) con recupero via `resume` + (`resume_after_crash_test.rb`) +- **Storage contract framework** (`spec/support/storage_contract/`) — + moduli condivisi testabili da qualsiasi adapter (SQLite futuro) +- **RuboCop cops test** (`spec/r0/rubocop_cops_test.rb`) — verifica che + i cop custom funzionano + +**Gaps reali**: + +| Gap | Impatto | +|-----|---------| +| R3 mutation test superficiale (7 file, test singoli) | Medio — mutation è la parte meno usata | +| `DAG::Validation` senza test unitari | Basso — testato indirettamente | +| `Runner#call` con workflow sconosciuto | Medio — errore non documentato | +| `Dispatcher#tick` con storage vuoto | Basso — comportamento ovvio | +| `resume` con workflow `:failed` (solo 1 test) | Medio — resume da failed è caso critico | +| `replace_subtree` multi-entry/multi-exit | Medio — non testato | + +Ma 490 test per 6000 linee di kernel è un rapporto eccellente. + +### Errori minori trovati + +- **`storage_overrides?`** duplicato in 2 file +- **`immutable_json_copy`** duplicato in 2 classi dello stesso file +- **Event types count**: `CONTRACT.md` dice 10, il codice ne ha 13 + (mancano probabilmente quelli aggiunti con effects/mutations) +- **`RuntimeProfile.defaults`**: `max_attempts_per_node: 3` e + `max_workflow_retries: 0` non sono usati dal Runner — sono default nel + value object ma il costruttore di Runner non applica default. I default + sono applicati da chi crea il profilo. La documentazione dice + "Defaults are max_attempts_per_node: 3" ma chi crea workflow deve + ricordarsi di passarli. + +### Coerenza architetturale + +**Hexagonal architecture**: Intatta. Nessun boundary violation trovato. +Il kernel non chiama mai adattatori direttamente, solo attraverso port +interfacce. Le dipendenze vanno tutte verso l'interno. + +**Cop enforcement**: I custom cop funzionano. `NoThreadOrRactor` ha +catturato: +``` +lib/dag/adapters/memory/event_bus.rb: subscriber dispatch via `dup` +``` +che è stato fixato. Il cop ha valore. + +**Determinismo**: La scelta di `id.to_s` ASCII tie-break in ogni +frontiera Kahn garantisce bit-identicità tra run. I test lo confermano. + +--- + +## Riepilogo + +| Componente | Voto | Giudizio | +|---|---|---| +| Graph | 9/10 | Pulito, testato, deterministico. Il meglio del progetto | +| Runner | 6/10 | Corretto ma troppo lungo. 497 linee per un orchestratore | +| StorageState | 5/10 | 760 linee mutabili senza struttura. Il rischio più grande | +| Effetti | 8/10 | Architettura solida, 2 DRY violations minori | +| Validation | 7/10 | Funziona, verboso ma esplicito. Lascia stare | +| Immutabilità | 9/10 | `deep_freeze` con cycle detection. Fatto bene | +| Test | 9/10 | Eccellente. R3 sottotestato | +| DRY | 6/10 | 3 violations identificate | +| Vision match | 8/10 | Monadi e CoW non sono esattamente quello che dici | + +### Cosa lascerei invariato + +1. Sistema di immutabilità e `frozen_copy` +2. Struttura ports-and-adapters (hexagon intatto) +3. Scelta di non includere `Waiting` in `Result` +4. Graph layer nella sua interezza +5. Pattern `Data.define` + `remove_method :[]` — verboso ma chiaro +6. JSON safety enforcement nei costruttori — ha valore difensivo +7. Custom RuboCop cops — funzionano e hanno già catturato bug + +### Cosa cambierei (per priorità) + +1. **Runner** (priorità: media) — Estrarre `Eligibility`, `OutcomeHandler`, + `Finalizer` quando tocchi il Runner per la prossima feature. +2. **StorageState** (priorità: media-bassa) — Dividere in 3 sub-componenti. + Fallo quando aggiungi un nuovo metodo allo storage. +3. **storage_overrides?** (priorità: bassa, 5 minuti) — Estrarre in + `DAG::Ports::Storage` come helper o in un module condiviso. +4. **immutable_json_copy** (priorità: bassa, 2 minuti) — Refactor inline + nel dispatcher o in un helper condiviso. +5. **CONTRACT.md event types** (priorità: bassa, 1 minuto) — Allineare + la lista ai 13 tipi reali. +6. **RuntimeProfile defaults** (priorità: bassa) — O rimuovere i default + dal value object o farli applicare dal Runner. + +### La domanda che conta + +*Vale la pena rifattorizzare il Runner oggi?* + +Se il Runner funziona (e i test dicono di sì) e non hai un bug da fixare +lì dentro, no. Il rischio di introdurre regressioni in 490 test non vale +il beneficio estetico. Segna il refactor come prossimo passo quando +tocchi quel file per una nuova feature. I refactor si pagano da soli +quando riducono il costo della prossima modifica. + +--- + +## Conclusione + +Hai costruito un workflow engine pulito, deterministico, zero-dependency. +Le scelte architetturali sono coerenti (hexagon, immutabilità, ports-and- +adapters), il testing è di alta qualità (fuzz, crash simulation, fingerprint +stability), e i custom cop proteggono i constraint di progetto. + +I difetti sono di **scala**, non di **architettura**. Runner è 497 linee +invece di 200. StorageState è 760 linee invece di 3×250. `storage_overrides?` +è duplicato. Niente di strutturale. + +Questo è un buon segno. Un progetto con difetti solo di scala è un +progetto con le fondamenta giuste. I refactor sono chirurgici e +opzionali, non sistemici e obbligatori. + +*— antirez (well, channeling his spirit: simplicity is the ultimate +sophistication)* diff --git a/docs/reviews/opencode-kimi-k2.6-review-v1.0.1.md b/docs/reviews/opencode-kimi-k2.6-review-v1.0.1.md new file mode 100644 index 0000000..d09a1fc --- /dev/null +++ b/docs/reviews/opencode-kimi-k2.6-review-v1.0.1.md @@ -0,0 +1,255 @@ +# Code Review: ruby-dag v1.0.1 + +> Revisione completa del progetto `ruby-dag`. +> Analisi onesta, basata su fatti, senza complimenti inutili. + +--- + +## La tua visione: giusto e sbagliato + +### Zero dipendenze esterne — Giusto, ma con un costo + +È una scelta di design legittima, ma va compreso il prezzo che si paga. +`ExecutionContext` effettua un `deep_dup` + `deep_freeze` completo ad ogni merge. Con librerie di strutture dati persistenti (es. `hamster`) si otterrebbe **structural sharing**, eliminando allocazioni sui rami non modificati. +**Verdetto:** "Zero deps" è un vincolo, non un pregio in sé. Va accettato il trade-off prestazionale. + +### Monadi — Parzialmente sbagliato + +`Success` e `Failure` espongono `and_then`, `map`, `recover`. Mancano operazioni fondamentali: `value_or`, `tap`, `map_error`. Il commento in `lib/dag/result.rb` che giustifica l'esclusione è debole. + +Il problema più grave è che **`Waiting` non è una monade**. Il tipo di ritorno dello step è quindi eterogeneo (`Success | Waiting | Failure`), e il `Runner` è costretto a pattern matching imperativo (`case result when DAG::Success...`). +**Verdetto:** se dichiari di usare monadi, il tipo di ritorno deve essere chainable in modo omogeneo. Altrimenti non sono monadi, sono semplici value objects. + +### Tipi immutabili — Corretto, con un buco + +`DAG.frozen_copy` assume che se un oggetto è `frozen?` e non è Hash/Array, sia sicuro. Questo è falso: un `Set` frozen contenente elementi mutabili passa il controllo. Un oggetto custom con `freeze` superficiale passa. La validazione è troppo fiduciosa. + +### Copy-on-write — Sbagliato nel nome + +Quello che fai non è CoW, è **"copy every time"**. CoW vero implica che i dati condivisi tra versioni non vengono mai toccati. Tu fai `deep_dup` + `deep_freeze`, che attraversa l'intero albero. È una copia totale mascherata. +**Proposta:** rinomina il concetto in `ImmutableCopy` e elimina la parola "on-write", che è fuorviante. + +### Bilanciamento OOP/FP — Sbagliato, c'è confusione di confine + +Non c'è bilanciamento, c'è **mescolanza di paradigmi senza regola**: +- `Graph` è OOP classico con stato mutabile interno + `freeze` +- `StorageState` è programmazione procedurale C-style (`module_function`, stato passato esplicito) +- `Runner` è imperativo con flag `paused`/`failed` +- `Data.define` è FP + +**Verdetto:** scegli. O il grafo è puro e immutable con builder separato (FP), o è un oggetto che si muta e poi si congela (OOP). Mescolarli senza una regola chiara è disordinato. + +### Ruby idiomatico — A tratti + +`module_function` per 760 linee di storage state è **anti-idiomatico** Ruby 3.4. Sovrascrivere `initialize` su `Data.define` per fare validazione è contro lo scopo di `Data.define` (documentato come: "for simple struct-like objects"; se serve validazione, usa una classe normale). + +A favore: `each_predecessor` senza allocare Set è ben fatto. + +### DRY — Nella media + +Il pattern `storage_overrides?` è copiato in `Runner`, `MutationService`, `Dispatcher` (3 volte identico). La validazione sui `Data.define` custom è ripetuta ogni volta. + +--- + +## Problemi concreti, file per file + +### `lib/dag/graph.rb` (696 linee) — Fa troppe cose + +Una sola classe gestisce: +- Mutazione +- Query transitive +- Topological sort +- Shortest path / longest path / critical path +- Subgraph +- Rendering Graphviz (`to_dot`) + +**Problema:** `to_dot` non ha alcun motivo di esistere in questa classe. Se domani vuoi supportare Mermaid, aggiungi un altro metodo? Estrai un `DAG::Graph::DotFormatter`. + +`shortest_path` e `longest_path` sono algoritmi generici su DAG pesati. Dovrebbero essere in `DAG::Graph::Algorithms` o moduli separati. + +```ruby +def nodes + frozen? ? @nodes : @nodes.dup.freeze +end +``` +Questo è pericoloso: l'object_id del ritorno cambia a seconda dello stato interno. Chi usa il grafo come chiave di Hash prima e dopo `freeze` rompe i bucket. O ritorni sempre una copia, o mai. Non entrambi. + +### `lib/dag/runner.rb` (497 linee) — Violazione di SRP + +Gestisce: +1. Acquisizione workflow stato +2. Costruzione contesto esecuzione +3. Loop di scheduling layered +4. Esecuzione step + exception handling +5. Commit atomico + emissione eventi +6. Preparazione effects +7. Finalization (4 stati terminali) +8. Costruzione risultato + +**Verdetto:** questa classe viola il Single Responsibility Principle in modo flagrante. + +Il `canonical_committed_attempt` è ottimizzato con un loop manuale per evitare allocazioni. Questa ottimizzazione è **prematura**: se lo storage è memory, le allocazioni sono irrilevanti. Se lo storage è SQLite, questa logica dovrebbe essere una query `ORDER BY ... LIMIT 1`. Il fatto che esista questo sort manuale suggerisce che stai mischiando concern. + +**Proposta:** spezza in `RunContextBuilder`, `NodeExecutor`, `WorkflowFinalizer`. + +### `lib/dag/adapters/memory/storage_state.rb` (760 linee) — C-style Ruby + +Il peggior file del progetto. Non per la mutabilità (giustificata), ma per la forma: + +```ruby +module StorageState + module_function + def create_workflow(state, id:, ...) + state[:workflows][id] = { ... } + end +end +``` + +Ogni metodo prende `state` come primo argomento. Stai scrivendo **C con sintassi Ruby**. + +La scusa "è l'unico posto dove mutare è permesso" non giustifica la forma procedurale. Una classe `MemoryStorageBackend` con `@state` come istanza variabile sarebbe incapsulata e testabile unitariamente. Invece hai un modulo con funzioni libere che operano su un hash passato dall'esterno. + +**Proposta:** sostituisci con classi incapsulate, anche se internamente mutano. Es: + +```ruby +class Adapters::Memory::Backend + def initialize + @workflows = {} + @attempts = {} + end + + def create_workflow(...) + # opera su @workflows + end +end +``` + +### `lib/dag/effects/dispatcher.rb` (339 linee) — Troppi compiti + +`tick(limit:)` fa: claim, dispatch, normalizza risultato handler, marca success/failure, rilascia nodi waiting, aggrega report. Sono 5 operazioni diverse. + +`HandlerOutcome` e `DispatchOutcome` sono `Data.define` con `initialize` sovrascritto solo per validare. Questo è boilerplate ripetuto. + +### `lib/dag/ports/storage.rb` (338 linee) — Port monolitico + +~30 metodi. Un port dovrebbe essere un contratto coeso. Qui hai: +- Workflow CRUD +- Node state machine +- Attempt lifecycle +- Event log append-only +- Effect ledger +- Lease management +- Claim logic + +Questo non è un port, è un'interfaccia di database intero. Chi vuole scrivere un adapter SQLite deve implementare 30 metodi con semantica atomica complessa. + +**Proposta:** spezza in port separati: +- `WorkflowStorage` +- `NodeAttemptStorage` +- `EventStorage` +- `EffectStorage` + +Il runner dipende da 4 port invece di 1, ma ogni port ha 5-8 metodi. La barriera all'implementazione di un nuovo adapter crolla. + +--- + +## Cosa funziona davvero + +### 1. Atomic boundaries nel port storage + +`commit_attempt` che committa risultato + stato nodo + evento + effects in un colpo è corretto. Senza questo, un crash tra scritture lascia workflow in stato irrecuperabile. + +### 2. Custom RuboCop cops + +`NoThreadOrRactor`, `NoMutableAccessors`, `NoInPlaceMutation`, `NoExternalRequires` sono un modo elegante di enforce architettura a compile time. Questo è DRY fatto bene: la regola è scritta una volta, applicata ovunque. + +### 3. Pattern `storage_overrides?` + +Permettere allo storage di sovrascrivere metodi "default" del runner è intelligente. Il runner ha un fallback generico, lo storage può ottimizzare. Questo pattern dovrebbe essere usato di più. + +### 4. `ExecutionContext` come boundary type + +Avere un tipo specifico per il contesto invece di un Hash generico previene molti bug. Il CoW (anche se copia totale) garantisce che uno step non corrompa il contesto degli altri. + +### 5. Contract tests condivisi + +`spec/support/storage_contract/` è la cosa più matura del progetto. Ogni adapter futuro deve passare questi test. Questo è testing fatto bene. + +--- + +## Proposte alternative + +### 1. Spezza `Runner` in 3 classi + +```ruby +class Runner + def call(workflow_id) + workflow = acquire_running(...) + context = RunContextBuilder.new(storage, ...).build(workflow_id, workflow) + executor = NodeExecutor.new(storage, registry, ...) + finalizer = WorkflowFinalizer.new(storage, event_bus, clock) + # ... loop orchestrato ... + end +end +``` + +Il runner diventa un orchestrator di ~100 linee. + +### 2. Rendi `Result` una monade completa + +Aggiungi: + +```ruby +def value_or(default) = is_a?(Success) ? value : default +def tap = is_a?(Success) ? yield(self) : self +def map_error = is_a?(Failure) ? yield(self) : self +``` + +Considera di rendere `Waiting` un `Result` con `and_then` che restituisce `self` (short-circuit). Il tipo di ritorno diventa omogeneo e chainable. + +### 3. Estrai algoritmi da `Graph` + +```ruby +class Graph + # solo struttura e mutazione +end + +module Graph::Algorithms + def self.shortest_path(graph, from, to) ... end + def self.longest_path(graph, from, to) ... end + def self.critical_path(graph) ... end +end + +class Graph::DotFormatter + def self.format(graph, name: "dag") ... end +end +``` + +`Graph` torna a fare una cosa sola. + +### 4. Sostituisci `StorageState` procedurale con classi incapsulate + +Vedi proposta nella sezione del file. + +### 5. Spezza il port Storage in 4 port + +Vedi proposta nella sezione del file. + +### 6. Elimina `to_dot` da `Graph` + +Spostalo in `lib/dag/formatters/dot.rb`. `Graph` non deve sapere cos'è Graphviz. + +--- + +## Verdetto + +Il progetto ha le fondamenta giuste: atomic boundaries, immutabilità sistematica, contract tests, cops architetturali. Ma soffre di: +- **classi troppo grandi** (`Runner`, `Graph`, `StorageState`) +- **mixing di paradigmi** senza regola chiara +- **monade incompleta** che non giustifica il nome + +Non è un disastro. È un buon progetto con problemi di taglio. + +**La cosa peggiore che puoi fare** è continuare ad aggiungere feature (R3 mutations, effects) senza prima spezzare `Runner`, `Graph`, e `StorageState`. Se arrivi a R4 con `Runner` a 700 linee, il progetto diventa unmaintainable. + +**Raccomandazione:** prima di toccare roadmap R4, fai un refactoring che spezza i 3 file più grandi. Altrimenti stai costruendo su fondamenta che reggono, ma sono troppo affollate. diff --git a/docs/reviews/ruby-dag v1.1- Manifesto Architetturale e Redesign dello Storage.md b/docs/reviews/ruby-dag v1.1- Manifesto Architetturale e Redesign dello Storage.md new file mode 100644 index 0000000..fd0a766 --- /dev/null +++ b/docs/reviews/ruby-dag v1.1- Manifesto Architetturale e Redesign dello Storage.md @@ -0,0 +1,117 @@ +# ruby-dag v1.1: Manifesto Architetturale e Redesign dello Storage + +> *L'architettura non è come vendi il codice nei README, è come il codice fallisce in produzione alle tre di notte.* + +Incrociando l'analisi statica e leggendo i byte, emerge una verità innegabile: le fondamenta di `ruby-dag` sono ottime. Aver isolato lo stato mutabile e aver garantito il determinismo bit-a-bit con test di fuzzing dimostra che il kernel è stato pensato per sopravvivere ai crash. + +Tuttavia, per scalare verso veri database relazionali (fase S0 - SQLite/PostgreSQL), il progetto deve affrontare due problemi strutturali: +1. **Un lessico interno fuorviante:** nomi (Monadi, CoW) usati in modo impreciso che confondono il modello mentale. +2. **Un design dello Storage Port "difensivo" e imperativo:** il kernel fa da babysitter allo storage, rendendo l'interfaccia immensa (26 metodi) e costringendo l'orchestratore a gestire i conflitti di concorrenza tramite eccezioni procedurali e micromanagement. + +La roadmap per la v1.1 risolve questi problemi rovesciando l'architettura: **il Kernel non si difenderà più preventivamente, ma assumerà le garanzie transazionali dal DB e reagirà ai fallimenti in modo puramente funzionale.** + +--- + +## FASE 1: Operazione Verità e Fix Operativi + +Gli ingegneri di sistemi si fidano del codice che non mente. Riconciliamo la documentazione con la realtà e chiudiamo le falle logiche. + +### 1. Igiene e Lessico +* **Non è "Copy-on-Write" (CoW):** State facendo una copia difensiva completa dell'hash con `deep_dup` + `deep_freeze`. È robusto, ma alloca memoria. **Azione:** Cambiare la nomenclatura nella documentazione in *"Frozen Value Semantics"* o *"Immutable-by-copy"*. +* **Ridimensionare le "Monadi":** `DAG::Result` (`Success`/`Failure`) è una mini-monade eccellente. Ma `Effects::Await` non ha `bind` né `pure`. **Azione:** Correggere il commento in `await.rb:5` da *"Monad-like"* a *"Effect snapshot dispatcher"*. +* **Uccidere le Micro-ottimizzazioni:** Le 19 righe di loop C-style in `canonical_committed_attempt` (`runner.rb:393`) rendono il codice illeggibile per risparmiare l'allocazione temporanea di un array. **Azione:** Sostituire con `attempts.select(&:committed?).max_by { |a| [a.attempt_number, a.attempt_id] }`. +* **Eliminare i Fantasmi:** Rimuovere `REVIEW.md` obsoleto dalla root (parla di Thread e file write inesistenti). + +### 2. Sicurezza Pubblica +* **[CRITICO] Il Deadlock dell'Idempotenza:** Se uno step genera un effetto con la stessa chiave ma payload diverso, lo storage lancia `IdempotencyConflictError`. Il `Runner` non lo cattura e il nodo va in loop infinito. **Azione:** Convertire temporaneamente l'errore in `Failure` terminale irreversibile (questo sarà poi risolto strutturalmente nella Fase 2). +* **Blindare le API:** Aggiungere controlli rigidi in `RunResult` (validare l'enum `state`), in `StepInput` (validare `ExecutionContext`), e in `Event/RuntimeProfile` (timestamp >= 0). +* **Isolare i Metadati:** Rimuovere `payload_fingerprint`, `external_ref` e `not_before_ms` dallo snapshot degli effetti. Lo step utente non deve vedere i lock infrastrutturali. + +--- + +## FASE 2: Il Redesign dello Storage (Il Cuore della v1.1) + +Attualmente il `Runner` implementa la logica di business e la gestione della concorrenza (read-before-write) per poi istruire lo storage su cosa salvare passo-passo. Il port è esploso a 26 metodi procedurali. + +Nella v1.1, **invertiamo la gravità.** Il Kernel assume che lo Storage sottostante garantisca l'integrità nativamente e si limita a reagire alle violazioni sfruttando il potere dei tipi monadici. + +### 2.1 Le Assunzioni Delegate al Database +Per implementare l'adapter durevole S0 (SQLite/PostgreSQL) pretenderemo 3 garanzie incrollabili dal sistema di storage: +1. **CAS (Compare-And-Swap) Nativo:** Nessun check preventivo in memoria. Il kernel invia l'update, e il DB applica i vincoli di concorrenza (`UPDATE ... WHERE revision = X`). +2. **Idempotenza via Unique Constraints:** Nessuna query per sapere se un effetto esiste già. Sarà l'indice nativo `UNIQUE(workflow_id, step_key, payload_fingerprint)` a far fallire l'inserimento del duplicato. +3. **Unit of Work (ACID):** Transazioni esplicite. Commit di attempts, effetti ed eventi avvengono tutti in una singola transazione database. Tutto o niente. + +### 2.2 Le Violazioni come Valori Monadici (Il DB restituisce lo stato del mondo) +Basta lanciare eccezioni (`raise StaleStateError`). Le eccezioni sono `GOTO` mascherati che rompono il design funzionale. + +Il driver dell'adapter intercetterà gli errori nativi SQL (es. `SQLite3::ConstraintException`) e restituirà la nostra monade `Failure` modellando la violazione in modo semantico e **allegando lo stato effettivo del mondo in quel momento**. + +```ruby +module DAG::Ports::Storage::Violations + # Il DB segnala: "Optimistic lock fallito. Un altro worker ci ha battuto sul tempo. + # Lo stato attuale a terra adesso è actual_state." + StaleState = Data.define(:entity, :expected_rev, :actual_rev, :actual_state) + + # Il DB segnala: "Violazione di constraint UNIQUE. L'effetto esiste già." + IdempotencyConflict = Data.define(:key, :existing_fingerprint) +end +``` + +Il `Runner` elimina i blocchi `begin/rescue` ed evolve in una purissima macchina a stati che fa pattern-matching sulla realtà restituita dal database: + +```ruby +# Il Kernel ordina l'operazione ciecamente, delegando il lock al DB +result = @storage.apply_commit(intent) + +result.recover do |violation| + case violation + when DAG::Ports::Storage::Violations::IdempotencyConflict + # Il DB ci ha avvertiti dell'idempotenza violata. Transizione a terminal failure. + transition_to_terminal_failure!(diagnostic: :idempotency_breach, details: violation) + + when DAG::Ports::Storage::Violations::StaleState + # Ci adattiamo alla verità del DB SENZA fare ulteriori query di lettura + if violation.actual_state == :paused + reconcile_and_suspend(violation) + else + yield_execution_to_other_worker + end + end +end +``` + +### 2.3 Contrarsi verso gli "Storage Intents" +Invece di avere 10 metodi di lifecycle frammentati nel port (`transition_node_state`, `append_event`, `mark_effect`), il Kernel assemblerà un "Intento Transazionale" in memoria e lo consegnerà allo storage. + +```ruby +CommitIntent = Data.define(:node_id, :precondition, :mutations) + +intent = DAG::Storage::CommitIntent.new( + node_id: node.id, + precondition: { node_state: :running }, + mutations: { + node_state: :committed, + append_events: [node_committed_event], + upsert_effects: [new_effect] + } +) + +# Il Kernel chiama UNA sola primitiva. L'adapter SQL farà BEGIN ... COMMIT. +@storage.apply_commit(intent) # Ritorna: Success | Failure(Violation) +``` + +### 2.4 Deframmentare il Memory Adapter (Pre-SQL) +Prima di scrivere una sola riga di SQL, il file `Memory::StorageState` (oggi 760 righe monolitiche) andrà preparato: +* Spezzare logicamente il modulo interno in file separati: `workflows.rb`, `attempts.rb`, `events.rb`, `effects.rb`. +* Questi file saranno la **blueprint mentale esatta 1:1** per la creazione del DDL (schema tabelle e foreign keys) del futuro adapter SQLite. +* Modificare l'adapter in memoria affinché **restituisca le Monadi di Violazione invece di lanciare eccezioni**. Questo permetterà di validare e testare il nuovo `Runner` funzionale immediatamente senza spaccare il comportamento legacy in questa fase. + +--- + +## Il Mandato + +Questa è l'ingegneria che trasforma un bel prototipo in un motore da produzione. + +Eseguite questa roadmap nell'ordine esatto: pulite il codice e le API (Fase 1), poi aggredite il port dello Storage (Fase 2). Una volta che il Runner comincerà a consumare "Violazioni monadiche" invece di gestire "Eccezioni procedurali di lock", avrete un Kernel antiproiettile. + +A quel punto, scrivere l'adapter SQL sarà un banale esercizio di mappatura transazionale. Sarete pronti per il mondo reale. \ No newline at end of file diff --git a/lib/dag.rb b/lib/dag.rb index d9ff68a..20829a5 100644 --- a/lib/dag.rb +++ b/lib/dag.rb @@ -6,6 +6,9 @@ require_relative "dag/errors" require_relative "dag/immutability" require_relative "dag/validation" +require_relative "dag/attempt_order" +require_relative "dag/event_publishing" +require_relative "dag/snapshot" require_relative "dag/plan_version" require_relative "dag/edge" require_relative "dag/result" @@ -21,6 +24,7 @@ require_relative "dag/graph/validator" # Boundary ports +require_relative "dag/ports/effect_ledger" require_relative "dag/ports/storage" require_relative "dag/ports/event_bus" require_relative "dag/ports/fingerprint" diff --git a/lib/dag/adapters/memory/storage.rb b/lib/dag/adapters/memory/storage.rb index abd82ff..b97b17b 100644 --- a/lib/dag/adapters/memory/storage.rb +++ b/lib/dag/adapters/memory/storage.rb @@ -96,17 +96,17 @@ def commit_attempt(attempt_id:, result:, node_state:, event:, effects: []) ) end - # (see Ports::Storage#list_effects_for_node) + # (see Ports::EffectLedger#list_effects_for_node) def list_effects_for_node(workflow_id:, revision:, node_id:) frozen StorageState.list_effects_for_node(@state, workflow_id: workflow_id, revision: revision, node_id: node_id) end - # (see Ports::Storage#list_effects_for_attempt) + # (see Ports::EffectLedger#list_effects_for_attempt) def list_effects_for_attempt(attempt_id:) frozen StorageState.list_effects_for_attempt(@state, attempt_id: attempt_id) end - # (see Ports::Storage#claim_ready_effects) + # (see Ports::EffectLedger#claim_ready_effects) def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:, only_workflow_id: nil) frozen StorageState.claim_ready_effects( @state, @@ -118,7 +118,7 @@ def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:, only_workflow_id: ) end - # (see Ports::Storage#mark_effect_succeeded) + # (see Ports::EffectLedger#mark_effect_succeeded) def mark_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms:) frozen StorageState.mark_effect_succeeded( @state, @@ -130,7 +130,7 @@ def mark_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms: ) end - # (see Ports::Storage#mark_effect_failed) + # (see Ports::EffectLedger#mark_effect_failed) def mark_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) frozen StorageState.mark_effect_failed( @state, @@ -143,7 +143,7 @@ def mark_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms: ) end - # (see Ports::Storage#renew_effect_lease) + # (see Ports::EffectLedger#renew_effect_lease) def renew_effect_lease(effect_id:, owner_id:, until_ms:, now_ms:) frozen StorageState.renew_effect_lease( @state, @@ -154,7 +154,7 @@ def renew_effect_lease(effect_id:, owner_id:, until_ms:, now_ms:) ) end - # (see Ports::Storage#complete_effect_succeeded) + # (see Ports::EffectLedger#complete_effect_succeeded) def complete_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms:) frozen StorageState.complete_effect_succeeded( @state, @@ -166,7 +166,7 @@ def complete_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now ) end - # (see Ports::Storage#complete_effect_failed) + # (see Ports::EffectLedger#complete_effect_failed) def complete_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) frozen StorageState.complete_effect_failed( @state, @@ -179,7 +179,7 @@ def complete_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before ) end - # (see Ports::Storage#release_nodes_satisfied_by_effect) + # (see Ports::EffectLedger#release_nodes_satisfied_by_effect) def release_nodes_satisfied_by_effect(effect_id:, now_ms:) frozen StorageState.release_nodes_satisfied_by_effect(@state, effect_id: effect_id, now_ms: now_ms) end diff --git a/lib/dag/adapters/memory/storage_state.rb b/lib/dag/adapters/memory/storage_state.rb index 2e23c6f..56ee539 100644 --- a/lib/dag/adapters/memory/storage_state.rb +++ b/lib/dag/adapters/memory/storage_state.rb @@ -21,11 +21,13 @@ def fresh_state node_states: {}, # {[workflow_id, revision] => {node_id => state}} attempts: {}, # {attempt_id => attempt_record} attempts_index: {}, # {workflow_id => [attempt_id, ...]} + attempts_by_node: {}, # {[workflow_id, revision, node_id] => [attempt_id, ...]} committed_result_projections: {}, # {[workflow_id, revision, node_id] => DAG::Success} attempt_seq: {}, # {workflow_id => Integer} monotonic, never reset effects: {}, # {effect_id => DAG::Effects::Record} effects_by_ref: {}, # {ref => effect_id} effect_order: [], # [effect_id, ...] insertion order for deterministic claims + active_effect_order: [], # [effect_id, ...] non-terminal subset of effect_order effect_seq: 0, # global effect id sequence attempt_effect_links: {}, # {attempt_id => [effect_link, ...]} node_effect_links: {}, # {[workflow_id, revision, node_id] => [effect_link, ...]} @@ -49,6 +51,47 @@ def fetch_node_states!(state, id, revision) end end + # Internal: CAS guard shared by every state transition in this module. + # @api private + def assert_state!(label, current, expected) + return if current == expected + + raise StaleStateError, "#{label} state is #{current.inspect}, expected #{expected.inspect}" + end + + # Internal: validate an optional workflow-level event's coordinates + # before any mutation happens. + # @api private + def validate_optional_workflow_event!(event, workflow_id, revision) + return unless event + + validate_event_coordinates!( + event, + workflow_id: workflow_id, + revision: revision, + node_id: nil, + attempt_id: nil + ) + end + + # Internal: append an optional event after the mutation succeeded. + # @api private + def append_optional_event(state, id, event, revision:) + event ? append_event_internal(state, id, event, revision: revision) : nil + end + + # Internal: shared preamble for lease-guarded effect operations. + # Validates inputs, fetches the record, and enforces the lease CAS. + # @api private + def leased_effect!(state, effect_id, owner_id:, now_ms:) + ensure_effect_state!(state) + DAG::Validation.string!(owner_id, "owner_id") + DAG::Validation.integer!(now_ms, "now_ms") + record = fetch_effect!(state, effect_id) + validate_effect_lease!(record, owner_id: owner_id, now_ms: now_ms) + record + end + # Internal: initialize effect-ledger keys for snapshots created before # the effect-aware storage extension existed. # @api private @@ -57,11 +100,29 @@ def ensure_effect_state!(state) state[:effects_by_ref] ||= {} state[:effect_order] ||= state[:effects].keys state[:effect_seq] ||= state[:effects].size + state[:active_effect_order] ||= state[:effect_order].reject { |id| state[:effects].fetch(id).terminal? } state[:attempt_effect_links] ||= {} state[:node_effect_links] ||= {} state[:effect_attempt_links] ||= {} end + # Internal: initialize the per-node attempt index for snapshots + # created before it existed. + # @api private + def ensure_attempt_node_index!(state) + state[:attempts_by_node] ||= state[:attempts].each_value.with_object({}) do |attempt, index| + key = [attempt[:workflow_id], attempt[:revision], attempt[:node_id]] + (index[key] ||= []) << attempt[:attempt_id] + end + end + + # Internal: attempt ids for one node in one revision, in begin order. + # @api private + def attempt_ids_for_node(state, workflow_id, revision, node_id) + ensure_attempt_node_index!(state) + state[:attempts_by_node].fetch([workflow_id, revision, node_id], []) + end + # Internal: initialize committed-result projections for snapshots # created before plan-version carry-forward existed. # @api private @@ -73,7 +134,7 @@ def ensure_committed_result_projection_state!(state) # @api private def create_workflow(state, id:, initial_definition:, initial_context:, runtime_profile:) id = DAG.frozen_copy(id) - raise ArgumentError, "workflow #{id} already exists" if state[:workflows].key?(id) + raise DuplicateWorkflowError, "workflow #{id} already exists" if state[:workflows].key?(id) DAG::Validation.instance!( initial_definition, DAG::Workflow::Definition, @@ -110,20 +171,10 @@ def load_workflow(state, id:) # @api private def transition_workflow_state(state, id:, from:, to:, event: nil) row = fetch_workflow!(state, id) - unless row[:state] == from - raise StaleStateError, "workflow #{id} state is #{row[:state].inspect}, expected #{from.inspect}" - end - if event - validate_event_coordinates!( - event, - workflow_id: row.fetch(:id), - revision: row[:current_revision], - node_id: nil, - attempt_id: nil - ) - end + assert_state!("workflow #{id}", row[:state], from) + validate_optional_workflow_event!(event, row.fetch(:id), row[:current_revision]) row[:state] = to - stamped = event ? append_event_internal(state, id, event, revision: row[:current_revision]) : nil + stamped = append_optional_event(state, id, event, revision: row[:current_revision]) {id: id, state: to, event: stamped} end @@ -140,15 +191,7 @@ def append_revision(state, id:, parent_revision:, definition:, invalidated_node_ new_revision = parent_revision + 1 stored_definition = (definition.revision == new_revision) ? definition : definition.with_revision(new_revision) - if event - validate_event_coordinates!( - event, - workflow_id: id, - revision: [parent_revision, new_revision], - node_id: nil, - attempt_id: nil - ) - end + validate_optional_workflow_event!(event, id, [parent_revision, new_revision]) state[:definitions][[id, new_revision]] = stored_definition previous_states = state[:node_states][[id, parent_revision]] || {} @@ -171,7 +214,7 @@ def append_revision(state, id:, parent_revision:, definition:, invalidated_node_ state[:node_states][[id, new_revision]] = new_states result_projections.each { |key, result| state[:committed_result_projections][key] = result } row[:current_revision] = new_revision - stamped = event ? append_event_internal(state, id, event, revision: [parent_revision, new_revision]) : nil + stamped = append_optional_event(state, id, event, revision: [parent_revision, new_revision]) {id: id, revision: new_revision, event: stamped} end @@ -215,10 +258,7 @@ def load_node_states(state, workflow_id:, revision:) # @api private def transition_node_state(state, workflow_id:, revision:, node_id:, from:, to:) states_for_rev = fetch_node_states!(state, workflow_id, revision) - current = states_for_rev[node_id] - unless current == from - raise StaleStateError, "node #{node_id} state is #{current.inspect}, expected #{from.inspect}" - end + assert_state!("node #{node_id}", states_for_rev[node_id], from) states_for_rev[node_id] = to {workflow_id: workflow_id, revision: revision, node_id: node_id, state: to} end @@ -230,10 +270,7 @@ def begin_attempt(state, workflow_id:, revision:, node_id:, expected_node_state: DAG::Validation.positive_integer!(attempt_number, "attempt_number") states_for_rev = fetch_node_states!(state, workflow_id, revision) - current = states_for_rev[node_id] - unless current == expected_node_state - raise StaleStateError, "node #{node_id} state is #{current.inspect}, expected #{expected_node_state.inspect}" - end + assert_state!("node #{node_id}", states_for_rev[node_id], expected_node_state) state[:attempt_seq][workflow_id] += 1 attempt_id = "#{workflow_id}/#{state[:attempt_seq][workflow_id]}" @@ -249,6 +286,8 @@ def begin_attempt(state, workflow_id:, revision:, node_id:, expected_node_state: result: nil } state[:attempts_index][workflow_id] << attempt_id + ensure_attempt_node_index!(state) + (state[:attempts_by_node][[workflow_id, revision, node_id]] ||= []) << attempt_id attempt_id end @@ -256,11 +295,9 @@ def begin_attempt(state, workflow_id:, revision:, node_id:, expected_node_state: # @api private def commit_attempt(state, attempt_id:, result:, node_state:, event:, effects: []) attempt = state[:attempts].fetch(attempt_id) do - raise ArgumentError, "Unknown attempt: #{attempt_id}" - end - unless attempt[:state] == :running - raise StaleStateError, "attempt #{attempt_id} state is #{attempt[:state].inspect}, expected :running" + raise UnknownAttemptError, "Unknown attempt: #{attempt_id}" end + assert_state!("attempt #{attempt_id}", attempt[:state], :running) terminal_state = attempt_terminal_state_for(result) validate_node_state_for_result!(result, node_state) validate_event_coordinates!( @@ -272,10 +309,7 @@ def commit_attempt(state, attempt_id:, result:, node_state:, event:, effects: [] ) rev_states = state[:node_states][[attempt[:workflow_id], attempt[:revision]]] - current_node_state = rev_states[attempt[:node_id]] - unless current_node_state == :running - raise StaleStateError, "node #{attempt[:node_id]} state is #{current_node_state.inspect}, expected :running" - end + assert_state!("node #{attempt[:node_id]}", rev_states[attempt[:node_id]], :running) reservations = prepare_effect_reservations(state, attempt, effects) @@ -320,7 +354,7 @@ def claim_ready_effects(state, limit:, owner_id:, lease_ms:, now_ms:, only_workf DAG::Validation.optional_string!(only_workflow_id, "only_workflow_id") claimed = [] - state[:effect_order].each do |effect_id| + state[:active_effect_order].each do |effect_id| break if claimed.size >= limit record = state[:effects].fetch(effect_id) @@ -339,6 +373,15 @@ def claim_ready_effects(state, limit:, owner_id:, lease_ms:, now_ms:, only_workf claimed end + # Internal: drop a now-terminal effect from the active claim scan. + # `effect_order` keeps full insertion history; only the claim path + # iterates this subset, so a long-lived poller does not pay + # O(total effects ever created) per tick. + # @api private + def retire_effect_from_active_order(state, effect_id) + state[:active_effect_order].delete(effect_id) + end + # @api private def effect_linked_to_workflow?(state, effect_id, workflow_id) state[:effect_attempt_links].fetch(effect_id, []).any? do |link| @@ -349,11 +392,7 @@ def effect_linked_to_workflow?(state, effect_id, workflow_id) # Implements `Ports::Storage#mark_effect_succeeded`. # @api private def mark_effect_succeeded(state, effect_id:, owner_id:, result:, external_ref:, now_ms:) - ensure_effect_state!(state) - DAG::Validation.string!(owner_id, "owner_id") - DAG::Validation.integer!(now_ms, "now_ms") - record = fetch_effect!(state, effect_id) - validate_effect_lease!(record, owner_id: owner_id, now_ms: now_ms) + record = leased_effect!(state, effect_id, owner_id: owner_id, now_ms: now_ms) updated = record.with( status: :succeeded, @@ -365,18 +404,15 @@ def mark_effect_succeeded(state, effect_id:, owner_id:, result:, external_ref:, lease_until_ms: nil, updated_at_ms: now_ms ) + retire_effect_from_active_order(state, effect_id) state[:effects][effect_id] = updated end # Implements `Ports::Storage#mark_effect_failed`. # @api private def mark_effect_failed(state, effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) - ensure_effect_state!(state) - DAG::Validation.string!(owner_id, "owner_id") - DAG::Validation.integer!(now_ms, "now_ms") DAG::Validation.boolean!(retriable, "retriable") - record = fetch_effect!(state, effect_id) - validate_effect_lease!(record, owner_id: owner_id, now_ms: now_ms) + record = leased_effect!(state, effect_id, owner_id: owner_id, now_ms: now_ms) updated = record.with( status: DAG::Effects.failure_status(retriable), @@ -388,22 +424,20 @@ def mark_effect_failed(state, effect_id:, owner_id:, error:, retriable:, not_bef lease_until_ms: nil, updated_at_ms: now_ms ) + retire_effect_from_active_order(state, effect_id) unless retriable state[:effects][effect_id] = updated end # Implements `Ports::Storage#renew_effect_lease`. # @api private def renew_effect_lease(state, effect_id:, owner_id:, until_ms:, now_ms:) - ensure_effect_state!(state) - DAG::Validation.string!(owner_id, "owner_id") DAG::Validation.integer!(until_ms, "until_ms") DAG::Validation.integer!(now_ms, "now_ms") unless until_ms > now_ms raise ArgumentError, "until_ms (#{until_ms}) must be greater than now_ms (#{now_ms})" end - record = fetch_effect!(state, effect_id) - validate_effect_lease!(record, owner_id: owner_id, now_ms: now_ms) + record = leased_effect!(state, effect_id, owner_id: owner_id, now_ms: now_ms) current_until = record.lease_until_ms if until_ms < current_until @@ -512,23 +546,10 @@ def list_attempts(state, workflow_id:, revision: nil, node_id: nil) # @api private def list_committed_results_for_predecessors(state, workflow_id:, revision:, predecessors:) ensure_committed_result_projection_state!(state) - predecessor_ids = predecessors.map(&:to_sym) - predecessor_set = predecessor_ids.to_set - best_by_node = {} - - state[:attempts_index].fetch(workflow_id, []).each do |attempt_id| - attempt = state[:attempts][attempt_id] - next unless attempt[:revision] == revision - next unless attempt[:state] == :committed - next unless predecessor_set.include?(attempt[:node_id]) - - current = best_by_node[attempt[:node_id]] - best_by_node[attempt[:node_id]] = attempt if better_committed_attempt?(attempt, current) - end - states_for_rev = state[:node_states].fetch([workflow_id, revision], {}) - predecessor_ids.each_with_object({}) do |node_id, results| - attempt = best_by_node[node_id] + + predecessors.map(&:to_sym).each_with_object({}) do |node_id, results| + attempt = best_committed_attempt_for_node(state, workflow_id, revision, node_id) if attempt results[node_id] = attempt[:result] elsif states_for_rev[node_id] == :committed @@ -587,9 +608,7 @@ def read_events(state, workflow_id:, after_seq: nil, limit: nil) # @api private def prepare_workflow_retry(state, id:, from: :failed, to: :pending, event: nil) row = fetch_workflow!(state, id) - unless row[:state] == from - raise StaleStateError, "workflow #{id} state is #{row[:state].inspect}, expected #{from.inspect}" - end + assert_state!("workflow #{id}", row[:state], from) max_retries = row[:runtime_profile].max_workflow_retries if row[:workflow_retry_count] >= max_retries @@ -600,26 +619,18 @@ def prepare_workflow_retry(state, id:, from: :failed, to: :pending, event: nil) revision = row[:current_revision] states_for_rev = fetch_node_states!(state, id, revision) failed_node_ids = states_for_rev.select { |_, s| s == :failed }.keys - if event - validate_event_coordinates!( - event, - workflow_id: row.fetch(:id), - revision: revision, - node_id: nil, - attempt_id: nil - ) - end + validate_optional_workflow_event!(event, row.fetch(:id), revision) - failed_set = failed_node_ids.to_set - state[:attempts_index].fetch(id, []).each do |aid| - attempt = state[:attempts][aid] - next unless attempt[:revision] == revision && failed_set.include?(attempt[:node_id]) - attempt[:state] = :aborted if attempt[:state] == :failed + failed_node_ids.each do |node_id| + attempt_ids_for_node(state, id, revision, node_id).each do |aid| + attempt = state[:attempts][aid] + attempt[:state] = :aborted if attempt[:state] == :failed + end end failed_node_ids.each { |node_id| states_for_rev[node_id] = :pending } row[:workflow_retry_count] += 1 row[:state] = to - stamped = event ? append_event_internal(state, id, event, revision: revision) : nil + stamped = append_optional_event(state, id, event, revision: revision) {id: id, state: to, reset: failed_node_ids, workflow_retry_count: row[:workflow_retry_count], event: stamped} end @@ -673,6 +684,7 @@ def apply_effect_reservations(state, reservations) state[:effects][record.id] = record state[:effects_by_ref][record.ref] = record.id state[:effect_order] << record.id + state[:active_effect_order] << record.id state[:effect_seq] += 1 end @@ -743,7 +755,7 @@ def validate_event_coordinates!(event, workflow_id:, revision:, node_id:, attemp unless revision.nil? || Array(revision).include?(event.revision) raise ArgumentError, "event.revision does not match revision" end - DAG::Validation.node_id!(event.node_id) unless event.node_id.nil? + DAG::Validation.optional_node_id!(event.node_id) unless node_id.nil? || event.node_id.nil? || event.node_id.to_sym == node_id.to_sym raise ArgumentError, "event.node_id does not match node_id" end @@ -834,43 +846,34 @@ def validate_workflow_state_for_revision_append!(id, state, allowed_states) raise DAG::StaleStateError, "workflow #{id} cannot append revision from #{state.inspect}" end - # Internal: find the canonical committed result already scoped to a - # revision, either from a real attempt or from an explicit projection. + # Internal: canonical committed attempt for one node in one revision. # @api private - def canonical_committed_result_for_node(state, workflow_id, revision, node_id) + def best_committed_attempt_for_node(state, workflow_id, revision, node_id) best = nil - state[:attempts_index].fetch(workflow_id, []).each do |attempt_id| + attempt_ids_for_node(state, workflow_id, revision, node_id).each do |attempt_id| attempt = state[:attempts][attempt_id] - next unless attempt[:revision] == revision - next unless attempt[:node_id] == node_id next unless attempt[:state] == :committed - best = attempt if better_committed_attempt?(attempt, best) + best = attempt if DAG::AttemptOrder.better?(attempt, best) end - return best[:result] if best - - state[:committed_result_projections][[workflow_id, revision, node_id]] + best end - # Internal: canonical committed-attempt ordering. + # Internal: find the canonical committed result already scoped to a + # revision, either from a real attempt or from an explicit projection. # @api private - def better_committed_attempt?(candidate, current) - return true if current.nil? - - candidate_number = candidate.fetch(:attempt_number) - current_number = current.fetch(:attempt_number) - return true if candidate_number > current_number - return false unless candidate_number == current_number + def canonical_committed_result_for_node(state, workflow_id, revision, node_id) + best = best_committed_attempt_for_node(state, workflow_id, revision, node_id) + return best[:result] if best - candidate.fetch(:attempt_id).to_s > current.fetch(:attempt_id).to_s + state[:committed_result_projections][[workflow_id, revision, node_id]] end # Internal helper used by `count_attempts` and friends. # @api private def count_attempts_internal(state, id, revision, node_id, exclude: []) - state[:attempts_index].fetch(id, []).count do |aid| - a = state[:attempts][aid] - a[:revision] == revision && a[:node_id] == node_id && !exclude.include?(a[:state]) + attempt_ids_for_node(state, id, revision, node_id).count do |aid| + !exclude.include?(state[:attempts][aid][:state]) end end diff --git a/lib/dag/attempt_order.rb b/lib/dag/attempt_order.rb new file mode 100644 index 0000000..2ae315f --- /dev/null +++ b/lib/dag/attempt_order.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module DAG + # Canonical committed-attempt ordering: the attempt with the highest + # `attempt_number` wins, with `attempt_id.to_s` ASCII as a defensive + # tie-break. This is a determinism-critical rule — the Runner's effective + # context depends on which committed attempt is canonical — so it has + # exactly one executable definition, shared by the storage port default, + # the Memory adapter, and diagnostics. + # @api private + module AttemptOrder + module_function + + # True when `candidate` outranks `current` (or `current` is nil). + # @param candidate [Hash] attempt record with :attempt_number, :attempt_id + # @param current [Hash, nil] + # @return [Boolean] + def better?(candidate, current) + return true if current.nil? + + candidate_number = candidate.fetch(:attempt_number) + current_number = current.fetch(:attempt_number) + return true if candidate_number > current_number + return false unless candidate_number == current_number + + candidate.fetch(:attempt_id).to_s > current.fetch(:attempt_id).to_s + end + + # Sort key under the canonical ordering (ascending: last element is the + # canonical attempt). + # @param attempt [Hash] attempt record with :attempt_number, :attempt_id + # @return [Array(Integer, String)] + def key(attempt) + [attempt.fetch(:attempt_number), attempt.fetch(:attempt_id).to_s] + end + end +end diff --git a/lib/dag/effects.rb b/lib/dag/effects.rb index 856b109..a6589b2 100644 --- a/lib/dag/effects.rb +++ b/lib/dag/effects.rb @@ -50,16 +50,6 @@ def ref_for(type, key) "#{type}:#{key}".freeze end - # JSON-safe defensive copy that preserves an already-frozen value as-is - # and passes `nil` through. - # @api private - def frozen_copy_or_nil(value) - return nil if value.nil? - return value if value.frozen? - - DAG.frozen_copy(value) - end - # Map a retriable boolean to its terminal effect status. # @param retriable [Boolean] # @return [Symbol] :failed_retriable or :failed_terminal @@ -97,6 +87,7 @@ def fetch_required_snapshot_value(snapshot, key) require_relative "effects/stale_lease_error" require_relative "effects/unknown_effect_error" require_relative "effects/unknown_handler_error" +require_relative "effects/dispatch_aborted_error" require_relative "effects/intent" require_relative "effects/prepared_intent" require_relative "effects/record" diff --git a/lib/dag/effects/dispatch_aborted_error.rb b/lib/dag/effects/dispatch_aborted_error.rb new file mode 100644 index 0000000..c7bdb3b --- /dev/null +++ b/lib/dag/effects/dispatch_aborted_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module DAG + module Effects + # Raised by `Dispatcher#tick` when dispatching aborts on an unexpected + # exception (a dispatcher-side storage failure, or an unknown effect + # type under `unknown_handler_policy: :raise`). Sibling workers may have + # already durably marked effects and released nodes before the abort; + # {#report} carries those partial outcomes so the caller does not lose + # them. The original exception is available via `Exception#cause`. + # Records that were claimed but never marked stay `:dispatching` until + # their lease expires and a future tick re-claims them. + # @api public + class DispatchAbortedError < DAG::Error + # @return [DAG::Effects::DispatchReport] outcomes completed before the abort + attr_reader :report + + # @param message [String] + # @param report [DAG::Effects::DispatchReport] + def initialize(message, report:) + @report = report + super(message) + end + end + end +end diff --git a/lib/dag/effects/dispatcher.rb b/lib/dag/effects/dispatcher.rb index 38dedfe..0563e6c 100644 --- a/lib/dag/effects/dispatcher.rb +++ b/lib/dag/effects/dispatcher.rb @@ -32,7 +32,7 @@ def initialize(result:, error:) DAG::Validation.optional_hash!(error, "error") DAG.json_safe!(error, "$root.error") - super(result: result, error: DAG::Effects.frozen_copy_or_nil(error)) + super(result: result, error: DAG.frozen_copy(error)) end end private_constant :HandlerOutcome @@ -84,7 +84,7 @@ def initialize(succeeded_record:, failed_record:, released:, error:) succeeded_record: succeeded_record, failed_record: failed_record, released: DAG.frozen_copy(released), - error: DAG::Effects.frozen_copy_or_nil(error) + error: DAG.frozen_copy(error) ) end @@ -129,27 +129,45 @@ def initialize(storage:, handlers:, clock:, owner_id:, lease_ms:, end # Claim and dispatch up to `limit` ready effects. + # + # When a worker raises an unexpected exception (dispatcher-side storage + # failure, or `unknown_handler_policy: :raise`), the tick aborts but + # the outcomes that completed before the abort are not lost: they are + # wrapped in the {DispatchAbortedError#report} of the raised error, + # with the original exception as `#cause`. + # # @param limit [Integer] + # @param only_workflow_id [String, nil] when non-nil, claim only + # effects linked to the given workflow (per-workflow dispatch, V1.4) # @return [DAG::Effects::DispatchReport] - def tick(limit:) + # @raise [DAG::Effects::DispatchAbortedError] + def tick(limit:, only_workflow_id: nil) DAG::Validation.nonnegative_integer!(limit, "limit") + DAG::Validation.optional_string!(only_workflow_id, "only_workflow_id") now_ms = @clock.now_ms claimed = @storage.claim_ready_effects( limit: limit, owner_id: @owner_id, lease_ms: @lease_ms, - now_ms: now_ms + now_ms: now_ms, + only_workflow_id: only_workflow_id ) - outcomes = parallel_map(claimed) { |record| dispatch_record(record) } + outcomes, abort_error = parallel_map(claimed) { |record| dispatch_record(record) } - DispatchReport[ + completed = outcomes.compact + report = DispatchReport[ claimed: claimed, - succeeded: outcomes.map(&:succeeded_record).compact, - failed: outcomes.map(&:failed_record).compact, - released: outcomes.flat_map(&:released), - errors: outcomes.map(&:error).compact + succeeded: completed.map(&:succeeded_record).compact, + failed: completed.map(&:failed_record).compact, + released: completed.flat_map(&:released), + errors: completed.map(&:error).compact ] + return report if abort_error.nil? + raise abort_error unless abort_error.is_a?(StandardError) + + raise DispatchAbortedError.new("dispatch tick aborted: #{abort_error.message}", report: report), + cause: abort_error end private @@ -157,10 +175,11 @@ def tick(limit:) # Bounded-concurrency parallel map. At most `@parallelism` worker # threads in flight regardless of `items.length`. Result order # matches input order (slot-indexed writes; no shared mutation - # otherwise). Unexpected exceptions raised inside a worker thread - # re-emerge from `#tick` only after every worker has joined, so the - # caller is guaranteed that no worker is still mutating storage - # when `tick` raises. + # otherwise). Returns `[results, first_error_or_nil]`; `#tick` turns a + # captured error into a `DispatchAbortedError` carrying the partial + # report, only after every worker has joined, so the caller is + # guaranteed that no worker is still mutating storage when `tick` + # raises. # # The exception path is *captured*, not *raised*, inside each # worker: `Thread#join` re-raises any exception that escaped a @@ -170,13 +189,12 @@ def tick(limit:) # `worker_errors` array (no contention: each worker writes a # different index), *drains the work queue* (so peer workers see # `ThreadError` on their next `pop` and exit instead of pulling - # more records), and breaks out of the loop normally. Once every - # worker has joined we raise the first captured exception. + # more records), and breaks out of the loop normally. # Draining uses the queue itself as the abort signal, so # synchronization rides on `Queue`'s built-in thread-safety rather # than on Ruby's array-mutation visibility across threads. - def parallel_map(items) - return items.map { |item| yield item } if @parallelism <= 1 || items.length <= 1 + def parallel_map(items, &block) + return serial_map(items, &block) if @parallelism <= 1 || items.length <= 1 pool_size = (@parallelism < items.length) ? @parallelism : items.length results = Array.new(items.length) @@ -203,10 +221,21 @@ def parallel_map(items) end end workers.each(&:join) - first_error = worker_errors.compact.first - raise first_error if first_error + [results, worker_errors.compact.first] + end - results + # Serial counterpart of `parallel_map` with the same + # `[results, first_error_or_nil]` contract: on error, the items + # processed so far keep their outcomes and the remainder stays + # unprocessed (their leases expire and a future tick re-claims them). + def serial_map(items) + results = Array.new(items.length) + items.each_with_index do |item, idx| + results[idx] = yield item + rescue Exception => exception # standard:disable Lint/RescueException + return [results, exception] + end + [results, nil] end # Empties `queue` non-blockingly. Used to signal peer workers to @@ -281,7 +310,7 @@ def unknown_handler_outcome(record) end def bad_return_outcome(record, result) - error = effect_error(record, code: :handler_bad_return).merge(class: result.class.name) + error = effect_error(record, code: :handler_bad_return).merge(returned_class: result.class.name) HandlerOutcome[ result: DAG::Effects::HandlerResult.failed(error: error, retriable: true), error: error @@ -290,7 +319,7 @@ def bad_return_outcome(record, result) def raised_handler_outcome(record, caught) error = effect_error(record, code: :handler_raised) - .merge(class: caught.class.name, message: caught.message) + .merge(error_class: caught.class.name, message: caught.message) HandlerOutcome[ result: DAG::Effects::HandlerResult.failed(error: error, retriable: true), error: error @@ -318,39 +347,17 @@ def apply_handler_result(record, result, now_ms, error) end def complete_effect_succeeded(record, result, now_ms) - if DAG::Ports::Storage.method_overridden?(@storage, :complete_effect_succeeded) - return @storage.complete_effect_succeeded( - effect_id: record.id, - owner_id: @owner_id, - result: result.result, - external_ref: result.external_ref, - now_ms: now_ms - ) - end - - updated = @storage.mark_effect_succeeded( + @storage.complete_effect_succeeded( effect_id: record.id, owner_id: @owner_id, result: result.result, external_ref: result.external_ref, now_ms: now_ms ) - {record: updated, released: release_if_terminal(updated, now_ms)} end def complete_effect_failed(record, result, now_ms) - if DAG::Ports::Storage.method_overridden?(@storage, :complete_effect_failed) - return @storage.complete_effect_failed( - effect_id: record.id, - owner_id: @owner_id, - error: result.error, - retriable: result.retriable?, - not_before_ms: result.not_before_ms, - now_ms: now_ms - ) - end - - updated = @storage.mark_effect_failed( + @storage.complete_effect_failed( effect_id: record.id, owner_id: @owner_id, error: result.error, @@ -358,13 +365,6 @@ def complete_effect_failed(record, result, now_ms) not_before_ms: result.not_before_ms, now_ms: now_ms ) - {record: updated, released: release_if_terminal(updated, now_ms)} - end - - def release_if_terminal(updated, now_ms) - return [] unless updated.terminal? - - @storage.release_nodes_satisfied_by_effect(effect_id: updated.id, now_ms: now_ms) end def stale_lease_error(record, error) @@ -395,12 +395,15 @@ def normalize_handlers(handlers) handlers.to_h { |type, handler| [type.to_s, handler] }.freeze end + # The dispatcher's storage dependency is the `Ports::EffectLedger` + # completion surface plus the durable event log. Adapters that only + # implement the mark/release primitives get `complete_effect_*` for + # free by including `Ports::EffectLedger`. def validate_storage!(value) %i[ claim_ready_effects - mark_effect_succeeded - mark_effect_failed - release_nodes_satisfied_by_effect + complete_effect_succeeded + complete_effect_failed append_event ].each do |method_name| DAG::Validation.dependency!(value, method_name, "storage") diff --git a/lib/dag/effects/intent.rb b/lib/dag/effects/intent.rb index eb20577..24a8b69 100644 --- a/lib/dag/effects/intent.rb +++ b/lib/dag/effects/intent.rb @@ -36,6 +36,23 @@ def initialize(type:, key:, payload: {}, metadata: {}) # @return [String] deterministic effect reference def ref = @ref + + # Full-fidelity JSON-safe projection; round-trips via {Intent.from_h}. + # @return [Hash] + def to_h = {type: type, key: key, payload: payload, metadata: metadata} + + # Rebuild an Intent from a {#to_h} projection (Symbol or String keys). + # @param hash [Hash] + # @return [Intent] + def self.from_h(hash) + DAG::Validation.hash!(hash, "intent hash") + new( + type: DAG::Snapshot.fetch!(hash, :type), + key: DAG::Snapshot.fetch!(hash, :key), + payload: DAG::Snapshot.fetch(hash, :payload, {}), + metadata: DAG::Snapshot.fetch(hash, :metadata, {}) + ) + end end end end diff --git a/lib/dag/errors.rb b/lib/dag/errors.rb index 3c0c598..3fd3fbf 100644 --- a/lib/dag/errors.rb +++ b/lib/dag/errors.rb @@ -54,6 +54,16 @@ class UnknownStepTypeError < Error; end # @api public class UnknownWorkflowError < Error; end + # Raised by `Storage#create_workflow` when the workflow id already exists. + # Duplicate creation is a routine consumer race (idempotent enqueue), so + # it lives under `DAG::Error` like every other storage state error. + # @api public + class DuplicateWorkflowError < Error; end + + # Raised when an attempt id is referenced in storage but does not exist. + # @api public + class UnknownAttemptError < Error; end + # Raised by `Runner#retry_workflow` when the workflow has already been # retried `runtime_profile.max_workflow_retries` times. # @api public diff --git a/lib/dag/event.rb b/lib/dag/event.rb index 20f82be..1dcae2e 100644 --- a/lib/dag/event.rb +++ b/lib/dag/event.rb @@ -68,6 +68,7 @@ def initialize(type:, workflow_id:, revision:, at_ms:, seq: nil, node_id: nil, a workflow_waiting workflow_completed workflow_failed + workflow_retrying mutation_applied effect_dispatch_stale_lease ].freeze diff --git a/lib/dag/event_publishing.rb b/lib/dag/event_publishing.rb new file mode 100644 index 0000000..f344595 --- /dev/null +++ b/lib/dag/event_publishing.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DAG + # Best-effort event publication. The event bus is a non-durable observer: + # a publish failure must never fail the storage transaction it follows, + # so every publisher swallows all StandardErrors through this single + # helper instead of re-deriving the swallow semantics locally. + # @api private + module EventPublishing + module_function + + # @param event_bus [Object] adapter implementing `Ports::EventBus` + # @param event [DAG::Event] + # @return [nil] + def publish_quietly(event_bus, event) + event_bus.publish(event) + nil + rescue + nil + end + end +end diff --git a/lib/dag/execution_context.rb b/lib/dag/execution_context.rb index d894110..32231a5 100644 --- a/lib/dag/execution_context.rb +++ b/lib/dag/execution_context.rb @@ -13,20 +13,41 @@ def self.from(hash) new(hash || {}) end + # Internal fast path used by `merge`: `data` is already deep-frozen and + # JSON-safe, so re-walking the whole context per merge (which the Runner + # does once per predecessor per attempt) would prove nothing new. + # @api private + def self.trusted(data, canonical_keys) + context = allocate + context.instance_variable_set(:@data, data) + context.instance_variable_set(:@canonical_keys, canonical_keys) + context.freeze + end + private_class_method :trusted + # @param hash [Hash] JSON-safe payload def initialize(hash) DAG.json_safe!(hash) @data = DAG.frozen_copy(hash) + @canonical_keys = Set.new(@data.keys.map(&:to_s)).freeze freeze end # Returns a new ExecutionContext with `patch` keys merged on top. - # `nil` or empty patch returns `self` unchanged. + # `nil` or empty patch returns `self` unchanged. Only the patch is + # validated and copied; the existing data is already deep-frozen. # @param patch [Hash, nil] # @return [ExecutionContext] + # @raise [ArgumentError] when a patch key collides canonically (Symbol + # vs String spelling of the same key) with an existing key def merge(patch) return self if patch.nil? || patch.empty? - ExecutionContext.new(@data.merge(patch)) + + DAG.json_safe!(patch) + copied = DAG.frozen_copy(patch) + added_keys = validate_patch_keys!(copied) + merged_keys = added_keys.empty? ? @canonical_keys : (@canonical_keys | added_keys).freeze + ExecutionContext.send(:trusted, @data.merge(copied).freeze, merged_keys) end # @return [Object] underlying value, or default per Hash#fetch @@ -73,5 +94,23 @@ def hash = @data.hash # @return [String] def inspect = "#" alias_method :to_s, :inspect + + private + + # A patch key that equals an existing key (same object class) is a plain + # overwrite; a patch key whose canonical String form matches an existing + # key under a different spelling would silently fork the value, so it is + # rejected exactly like `DAG.json_safe!` rejects it within one hash. + def validate_patch_keys!(copied) + copied.keys.filter_map { |key| + next if @data.key?(key) + + canonical = key.to_s + if @canonical_keys.include?(canonical) + raise ArgumentError, "canonical key collision at $root: #{canonical.inspect}" + end + canonical + } + end end end diff --git a/lib/dag/failure.rb b/lib/dag/failure.rb index 0a6b26c..2e2abb8 100644 --- a/lib/dag/failure.rb +++ b/lib/dag/failure.rb @@ -4,6 +4,14 @@ module DAG # Step result indicating the step failed. `error` is a JSON-safe value # describing the failure; `retriable: true` lets the Runner retry the # node within the per-node attempt budget. + # + # Retry timing is deliberately immediate: a retriable Failure makes the + # node `:pending` again and the Runner re-executes it in the same + # `#call`, so the attempt budget can be consumed back-to-back. There is + # no backoff field here by design — a step that wants to retry *later* + # must return `Waiting` (optionally with `not_before_ms` and a blocking + # effect) so the delay is owned by the scheduler/dispatcher boundary, + # not by the kernel's failure semantics. # @api public Failure = Data.define(:error, :retriable, :metadata) do include Result @@ -62,8 +70,23 @@ def recover # @raise [RuntimeError] def unwrap! = raise("Unwrap called on Failure: #{error}") - # @return [Hash] {status: :failure, error:} - def to_h = {status: :failure, error: error} + # Full-fidelity JSON-safe projection; round-trips via {Result.from_h}. + # `retriable` is load-bearing for retry semantics and must survive + # persistence. + # @return [Hash] + def to_h = {status: :failure, error: error, retriable: retriable, metadata: metadata} + + # Rebuild a Failure from a {#to_h} projection (Symbol or String keys). + # @param hash [Hash] + # @return [Failure] + def self.from_h(hash) + DAG::Validation.hash!(hash, "failure hash") + new( + error: DAG::Snapshot.fetch(hash, :error), + retriable: DAG::Snapshot.fetch(hash, :retriable, false), + metadata: DAG::Snapshot.fetch(hash, :metadata, {}) + ) + end # @return [String] def inspect = "Failure(#{error.inspect})" diff --git a/lib/dag/graph.rb b/lib/dag/graph.rb index 134d860..cce717c 100644 --- a/lib/dag/graph.rb +++ b/lib/dag/graph.rb @@ -433,6 +433,26 @@ def to_dot(name: "dag") lines.join("\n") end + # Rebuild a frozen Graph from a {#to_h} projection. Accepts Symbol or + # String keys (a serializer round-trip turns Symbols into Strings); + # edge metadata keys are restored to Symbols. + # @param hash [Hash] + # @return [DAG::Graph] frozen + def self.from_h(hash) + DAG::Validation.hash!(hash, "graph hash") + graph = new + DAG::Snapshot.fetch(hash, :nodes, []).each { |id| graph.add_node(id) } + DAG::Snapshot.fetch(hash, :edges, []).each do |edge| + metadata = DAG::Snapshot.fetch(edge, :metadata, {}) + graph.add_edge( + DAG::Snapshot.fetch!(edge, :from), + DAG::Snapshot.fetch!(edge, :to), + **metadata.transform_keys(&:to_sym) + ) + end + graph.freeze + end + # Canonical, ASCII-sorted hash representation suitable for fingerprinting. # @return [Hash] def to_h diff --git a/lib/dag/immutability.rb b/lib/dag/immutability.rb index 965a6c0..fd600fd 100644 --- a/lib/dag/immutability.rb +++ b/lib/dag/immutability.rb @@ -18,6 +18,10 @@ def frozen_copy(value) deep_freeze(deep_dup(value)) end + # Recursively freeze `value` in place (Hashes, Arrays, and their nested + # keys/values). Cycle-safe via `seen`. Returns the same object, frozen. + # @param value [Object] + # @return [Object] `value`, deep-frozen def deep_freeze(value, seen = {}) return value if immutable_scalar?(value) return seen[value.object_id] if seen.key?(value.object_id) @@ -37,6 +41,10 @@ def deep_freeze(value, seen = {}) value.freeze end + # Recursively duplicate `value` (Hashes, Arrays, unfrozen Strings). + # Immutable scalars and frozen Strings are returned as-is. Cycle-safe. + # @param value [Object] + # @return [Object] a structurally fresh copy def deep_dup(value, seen = {}) return value if immutable_scalar?(value) return value if value.is_a?(String) && value.frozen? @@ -64,6 +72,12 @@ def deep_dup(value, seen = {}) end end + # Assert `value` is JSON-safe: String/Symbol keys without canonical + # collisions, scalar leaves from {JSON_SCALAR_CLASSES}, finite Floats. + # @param value [Object] + # @param path [String, Array] root label used in error messages + # @return [Object] `value` + # @raise [ArgumentError] naming the offending path on the first violation def json_safe!(value, path = "$root") json_safe_walk!(value, path.is_a?(Array) ? path : [path]) value @@ -74,7 +88,7 @@ def json_safe_walk!(value, path) when Hash seen = {} value.each do |key, nested| - unless JSON_KEY_CLASSES.any? { |klass| key.is_a?(klass) } + unless key.is_a?(String) || key.is_a?(Symbol) raise ArgumentError, "non JSON-safe key at #{format_json_path(path)}: #{key.class}" end diff --git a/lib/dag/mutation_service.rb b/lib/dag/mutation_service.rb index 0f52a25..951daeb 100644 --- a/lib/dag/mutation_service.rb +++ b/lib/dag/mutation_service.rb @@ -62,6 +62,10 @@ def append_revision_with_state_guard(id:, allowed_states:, parent_revision:, def ) end + # Pre-checks only: the authoritative, race-safe guards live inside + # `storage.append_revision_if_workflow_state`. These fail fast before + # `DefinitionEditor.plan` runs and must keep raising the same error + # classes and messages as the storage-layer guards. def guard_workflow_state!(workflow_id, state) case state when *MUTABLE_STATES then nil @@ -96,9 +100,7 @@ def mutation_event(workflow_id, mutation, parent_revision, new_revision, plan) end def publish_event(event) - @event_bus.publish(event) - rescue - nil + DAG::EventPublishing.publish_quietly(@event_bus, event) end end end diff --git a/lib/dag/node_diagnostic.rb b/lib/dag/node_diagnostic.rb index 4b82657..740112d 100644 --- a/lib/dag/node_diagnostic.rb +++ b/lib/dag/node_diagnostic.rb @@ -2,8 +2,8 @@ module DAG # Public, immutable node diagnostic derived from storage-owned node state, - # attempts, and abstract effect records. It intentionally excludes any UI, - # model, prompt, channel, or runtime-specific object. + # attempts, and abstract effect records. It intentionally excludes any + # presentation-, channel-, or consumer-runtime-specific object. # @api public NodeDiagnostic = Data.define( :workflow_id, @@ -32,7 +32,7 @@ class << self # @param effects [Array] # @return [DAG::NodeDiagnostic] def from_records(workflow_id:, revision:, node_id:, state:, attempts:, effects: []) - sorted_attempts = attempts.sort_by { |attempt| [attempt.fetch(:attempt_number), attempt.fetch(:attempt_id).to_s] } + sorted_attempts = attempts.sort_by { |attempt| DAG::AttemptOrder.key(attempt) } sorted_effects = effects.sort_by(&:ref) failure_attempt = sorted_attempts.reverse_each.find { |attempt| attempt.fetch(:result).is_a?(DAG::Failure) } waiting_attempt = sorted_attempts.reverse_each.find { |attempt| attempt.fetch(:result).is_a?(DAG::Waiting) } @@ -128,8 +128,8 @@ def initialize( DAG::Validation.symbol!(state, "state") DAG::Validation.boolean!(terminal, "terminal") DAG::Validation.nonnegative_integer!(attempt_count, "attempt_count") - DAG::Validation.string!(last_attempt_id, "last_attempt_id") unless last_attempt_id.nil? - DAG::Validation.string!(last_error_attempt_id, "last_error_attempt_id") unless last_error_attempt_id.nil? + DAG::Validation.optional_string!(last_attempt_id, "last_attempt_id") + DAG::Validation.optional_string!(last_error_attempt_id, "last_error_attempt_id") validate_error_code!(last_error_code) DAG::Validation.string_or_symbol!(waiting_reason, "waiting_reason") unless waiting_reason.nil? DAG::Validation.array!(effect_refs, "effect_refs") diff --git a/lib/dag/ports/effect_ledger.rb b/lib/dag/ports/effect_ledger.rb new file mode 100644 index 0000000..a7e5d20 --- /dev/null +++ b/lib/dag/ports/effect_ledger.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +module DAG + module Ports + # Durable abstract effect ledger port. Split out of `Ports::Storage` + # so the two disjoint consumers stay honest: the Runner never touches + # the ledger beyond `commit_attempt(effects:)` and the listing reads, + # and `DAG::Effects::Dispatcher` needs only this module's surface plus + # `append_event`. Adapters that persist effects include this module + # (directly or via `Ports::Storage`, which includes it). + # + # Adapters implement the primitives (`claim_ready_effects`, + # `mark_effect_*`, `renew_effect_lease`, + # `release_nodes_satisfied_by_effect`). The composed + # `complete_effect_*` methods have default implementations built from + # those primitives; durable adapters should override them with a single + # atomic transaction, because the default composition has a crash + # window between the terminal mark and the node release. + # + # @api public + module EffectLedger + # Whether the adapter may be driven by `Dispatcher` worker threads + # (`parallelism > 1`). Defaults to `false`; single-process adapters + # such as `Memory::Storage` must keep it `false`. + # @return [Boolean] + def thread_safe_for_dispatch? + false + end + + # List durable effect snapshots linked to a workflow node in a revision. + # + # @param workflow_id [String] + # @param revision [Integer] + # @param node_id [Symbol] + # @return [Array] + def list_effects_for_node(workflow_id:, revision:, node_id:) + raise PortNotImplementedError + end + + # List durable effect snapshots linked to an attempt. + # + # @param attempt_id [String] + # @return [Array] + def list_effects_for_attempt(attempt_id:) + raise PortNotImplementedError + end + + # Atomically claim ready effect records by assigning a lease. + # + # @param limit [Integer] maximum number of records to claim + # @param owner_id [String] dispatcher owner id + # @param lease_ms [Integer] lease duration in milliseconds + # @param now_ms [Integer] current wall-clock milliseconds + # @param only_workflow_id [String, nil] when non-nil, restrict the claim to + # effects that have at least one attempt-effect link belonging to the given + # workflow. This matches the kernel's idempotency model: a single effect + # record can be shared across workflows via attempt links, so the filter + # resolves "effects this workflow is waiting on", not "effects this + # workflow created first". Default `nil` claims globally across all + # workflows (V1.3 behaviour). A workflow with no linked effects yields an + # empty array (no raise). V1.4. + # @return [Array] claimed records + def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:, only_workflow_id: nil) + raise PortNotImplementedError + end + + # Mark a claimed effect as succeeded. + # + # @param effect_id [String] + # @param owner_id [String] current lease owner + # @param result [Object] JSON-safe result + # @param external_ref [Object, nil] JSON-safe external reference + # @param now_ms [Integer] + # @return [DAG::Effects::Record] updated terminal record + # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown + # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher + def mark_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms:) + raise PortNotImplementedError + end + + # Mark a claimed effect as failed, either retriable or terminal. + # + # @param effect_id [String] + # @param owner_id [String] current lease owner + # @param error [Object] JSON-safe error + # @param retriable [Boolean] + # @param not_before_ms [Integer, nil] retry delay hint for retriable failures + # @param now_ms [Integer] + # @return [DAG::Effects::Record] updated failed record + # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown + # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher + def mark_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) + raise PortNotImplementedError + end + + # Cooperatively extend the lease of an effect currently held by + # `owner_id`. This separates admission control (worker-death detection + # via expired lease) from handler execution time, so the dispatcher's + # default `lease_ms` can stay tight without forcing legitimately + # long-running handlers to lose their claim mid-run. + # + # The CAS guard is identical to `mark_effect_*`: status `:dispatching`, + # `lease_owner == owner_id`, and `lease_until_ms >= now_ms`. Adapters + # update `lease_until_ms` and `updated_at_ms` atomically. Renewal is + # monotonic: `until_ms` must be strictly greater than `now_ms` and not + # less than the current `lease_until_ms`. `until_ms == lease_until_ms` + # is a no-op success that returns the unchanged record. + # + # @param effect_id [String] + # @param owner_id [String] current lease owner + # @param until_ms [Integer] new lease deadline in wall-clock milliseconds + # @param now_ms [Integer] + # @return [DAG::Effects::Record] updated record with the extended lease + # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown + # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher + # @raise [ArgumentError] when `until_ms` is not greater than `now_ms`, + # or would shrink the existing `lease_until_ms` + def renew_effect_lease(effect_id:, owner_id:, until_ms:, now_ms:) + raise PortNotImplementedError + end + + # Canonical completion path: mark a claimed effect as succeeded and + # release any waiting nodes that become satisfied by that terminal + # effect state. + # + # The default implementation composes `mark_effect_succeeded` and + # `release_nodes_satisfied_by_effect` and is therefore NOT crash-atomic: + # a crash between the two calls durably leaves a terminal effect whose + # waiting nodes were never released. Durable adapters must override + # this with one atomic transaction. + # + # @param effect_id [String] + # @param owner_id [String] current lease owner + # @param result [Object] JSON-safe result + # @param external_ref [Object, nil] JSON-safe external reference + # @param now_ms [Integer] + # @return [Hash] {record: DAG::Effects::Record, released: Array} + # Each release receipt is shaped as + # {workflow_id:, revision:, node_id:, attempt_id:, released_at_ms:}. + # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown + # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher + def complete_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms:) + updated = mark_effect_succeeded( + effect_id: effect_id, + owner_id: owner_id, + result: result, + external_ref: external_ref, + now_ms: now_ms + ) + released = release_nodes_satisfied_by_effect(effect_id: effect_id, now_ms: now_ms) + {record: updated, released: released} + end + + # Canonical completion path: mark a claimed effect as failed and + # release waiting nodes when the resulting failure is terminal. + # + # Same crash-window caveat as {#complete_effect_succeeded}: the default + # composition is not atomic; durable adapters must override it. + # + # @param effect_id [String] + # @param owner_id [String] current lease owner + # @param error [Object] JSON-safe error + # @param retriable [Boolean] + # @param not_before_ms [Integer, nil] retry delay hint for retriable failures + # @param now_ms [Integer] + # @return [Hash] {record: DAG::Effects::Record, released: Array} + # Each release receipt is shaped as + # {workflow_id:, revision:, node_id:, attempt_id:, released_at_ms:}. + # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown + # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher + def complete_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) + updated = mark_effect_failed( + effect_id: effect_id, + owner_id: owner_id, + error: error, + retriable: retriable, + not_before_ms: not_before_ms, + now_ms: now_ms + ) + released = updated.terminal? ? release_nodes_satisfied_by_effect(effect_id: effect_id, now_ms: now_ms) : [] + {record: updated, released: released} + end + + # Reset waiting nodes linked to `effect_id` once all blocking effects for + # the waiting attempt are terminal. The node is reset to :pending; the + # waiting attempt remains waiting as durable history. + # + # @param effect_id [String] + # @param now_ms [Integer] + # @return [Array] release receipts shaped as + # {workflow_id:, revision:, node_id:, attempt_id:, released_at_ms:} + # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown + def release_nodes_satisfied_by_effect(effect_id:, now_ms:) + raise PortNotImplementedError + end + end + end +end diff --git a/lib/dag/ports/storage.rb b/lib/dag/ports/storage.rb index 76807bc..27b21d7 100644 --- a/lib/dag/ports/storage.rb +++ b/lib/dag/ports/storage.rb @@ -4,6 +4,8 @@ module DAG module Ports # Storage port. `CONTRACT.md` and this module document the current # effect-aware shape, including the R1/R2 retry/resume extensions. + # The effect-ledger surface lives in {Ports::EffectLedger}, which this + # module includes; the Dispatcher depends only on that subset. # # Adapters persist workflow rows, definition revisions, node states, # attempts, and the durable event log. Every method that returns @@ -13,20 +15,17 @@ module Ports # persisting them so caller-owned buffers cannot mutate storage-owned # identity fields after the call returns. # + # Single-runner invariant: `Runner#resume` of a workflow whose row is + # already `:running` (crash recovery) performs no storage transition, so + # storage provides no mutual exclusion on that path — two hosts resuming + # the same workflow would both proceed. Deployments must ensure at most + # one runner drives a given workflow at a time; a workflow-level + # owner/lease claim is a planned extension and must be designed before + # multi-host consumers resume concurrently. + # # @api public module Storage - # True when `adapter` defines `method_name` outside of this base port - # module. The Runner and the effect dispatcher use this to fall back to - # primitive storage methods when an adapter has not specialized the - # extension method. - # @param adapter [Object] - # @param method_name [Symbol] - # @return [Boolean] - def self.method_overridden?(adapter, method_name) - return false unless adapter.respond_to?(method_name) - - adapter.method(method_name).owner != self - end + include EffectLedger # Persist a fresh workflow in `:pending` with the supplied initial # definition (revision 1) and runtime profile. @@ -36,6 +35,7 @@ def self.method_overridden?(adapter, method_name) # @param initial_context [Hash] JSON-safe context seed # @param runtime_profile [DAG::RuntimeProfile] frozen profile # @return [Hash] {id:, current_revision:} + # @raise [DAG::DuplicateWorkflowError] when the id already exists def create_workflow(id:, initial_definition:, initial_context:, runtime_profile:) raise PortNotImplementedError end @@ -161,152 +161,11 @@ def begin_attempt(workflow_id:, revision:, node_id:, expected_node_state:, attem # @param event [DAG::Event] durable event to append in the same step # @param effects [Array] prepared effect intents to reserve/link # @return [DAG::Event] stamped event + # @raise [DAG::UnknownAttemptError] when `attempt_id` is unknown def commit_attempt(attempt_id:, result:, node_state:, event:, effects: []) raise PortNotImplementedError end - # List durable effect snapshots linked to a workflow node in a revision. - # - # @param workflow_id [String] - # @param revision [Integer] - # @param node_id [Symbol] - # @return [Array] - def list_effects_for_node(workflow_id:, revision:, node_id:) - raise PortNotImplementedError - end - - # List durable effect snapshots linked to an attempt. - # - # @param attempt_id [String] - # @return [Array] - def list_effects_for_attempt(attempt_id:) - raise PortNotImplementedError - end - - # Atomically claim ready effect records by assigning a lease. - # - # @param limit [Integer] maximum number of records to claim - # @param owner_id [String] dispatcher owner id - # @param lease_ms [Integer] lease duration in milliseconds - # @param now_ms [Integer] current wall-clock milliseconds - # @param only_workflow_id [String, nil] when non-nil, restrict the claim to - # effects that have at least one attempt-effect link belonging to the given - # workflow. This matches the kernel's idempotency model: a single effect - # record can be shared across workflows via attempt links, so the filter - # resolves "effects this workflow is waiting on", not "effects this - # workflow created first". Default `nil` claims globally across all - # workflows (V1.3 behaviour). A workflow with no linked effects yields an - # empty array (no raise). V1.4. - # @return [Array] claimed records - def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:, only_workflow_id: nil) - raise PortNotImplementedError - end - - # Mark a claimed effect as succeeded. - # - # @param effect_id [String] - # @param owner_id [String] current lease owner - # @param result [Object] JSON-safe result - # @param external_ref [Object, nil] JSON-safe external reference - # @param now_ms [Integer] - # @return [DAG::Effects::Record] updated terminal record - # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown - # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher - def mark_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms:) - raise PortNotImplementedError - end - - # Mark a claimed effect as failed, either retriable or terminal. - # - # @param effect_id [String] - # @param owner_id [String] current lease owner - # @param error [Object] JSON-safe error - # @param retriable [Boolean] - # @param not_before_ms [Integer, nil] retry delay hint for retriable failures - # @param now_ms [Integer] - # @return [DAG::Effects::Record] updated failed record - # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown - # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher - def mark_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) - raise PortNotImplementedError - end - - # Port extension: cooperatively extend the lease of an effect currently - # held by `owner_id`. This separates admission control (worker-death - # detection via expired lease) from handler execution time, so the - # dispatcher's default `lease_ms` can stay tight without forcing - # legitimately long-running handlers to lose their claim mid-run. - # - # The CAS guard is identical to `mark_effect_*`: status `:dispatching`, - # `lease_owner == owner_id`, and `lease_until_ms >= now_ms`. Adapters - # update `lease_until_ms` and `updated_at_ms` atomically. Renewal is - # monotonic: `until_ms` must be strictly greater than `now_ms` and not - # less than the current `lease_until_ms`. `until_ms == lease_until_ms` - # is a no-op success that returns the unchanged record. - # - # @param effect_id [String] - # @param owner_id [String] current lease owner - # @param until_ms [Integer] new lease deadline in wall-clock milliseconds - # @param now_ms [Integer] - # @return [DAG::Effects::Record] updated record with the extended lease - # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown - # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher - # @raise [ArgumentError] when `until_ms` is not greater than `now_ms`, - # or would shrink the existing `lease_until_ms` - def renew_effect_lease(effect_id:, owner_id:, until_ms:, now_ms:) - raise PortNotImplementedError - end - - # Port extension: atomically mark a claimed effect as succeeded and - # release any waiting nodes that become satisfied by that terminal - # effect state. This closes the crash window between a terminal mark and - # a separate release call in durable adapters. - # - # @param effect_id [String] - # @param owner_id [String] current lease owner - # @param result [Object] JSON-safe result - # @param external_ref [Object, nil] JSON-safe external reference - # @param now_ms [Integer] - # @return [Hash] {record: DAG::Effects::Record, released: Array} - # Each release receipt is shaped as - # {workflow_id:, revision:, node_id:, attempt_id:, released_at_ms:}. - # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown - # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher - def complete_effect_succeeded(effect_id:, owner_id:, result:, external_ref:, now_ms:) - raise PortNotImplementedError - end - - # Port extension: atomically mark a claimed effect as failed and release - # waiting nodes when the resulting failure is terminal. - # - # @param effect_id [String] - # @param owner_id [String] current lease owner - # @param error [Object] JSON-safe error - # @param retriable [Boolean] - # @param not_before_ms [Integer, nil] retry delay hint for retriable failures - # @param now_ms [Integer] - # @return [Hash] {record: DAG::Effects::Record, released: Array} - # Each release receipt is shaped as - # {workflow_id:, revision:, node_id:, attempt_id:, released_at_ms:}. - # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown - # @raise [DAG::Effects::StaleLeaseError] when the lease is missing, expired, or owned by another dispatcher - def complete_effect_failed(effect_id:, owner_id:, error:, retriable:, not_before_ms:, now_ms:) - raise PortNotImplementedError - end - - # Reset waiting nodes linked to `effect_id` once all blocking effects for - # the waiting attempt are terminal. The node is reset to :pending; the - # waiting attempt remains waiting as durable history. - # - # @param effect_id [String] - # @param now_ms [Integer] - # @return [Array] release receipts shaped as - # {workflow_id:, revision:, node_id:, attempt_id:, released_at_ms:} - # @raise [DAG::Effects::UnknownEffectError] when `effect_id` is unknown - def release_nodes_satisfied_by_effect(effect_id:, now_ms:) - raise PortNotImplementedError - end - # Abort in-flight attempts for a workflow before resume. Adapters must # also reset any corresponding current-revision node still in :running # back to :pending so eligibility can recompute from committed state. @@ -327,20 +186,45 @@ def list_attempts(workflow_id:, revision: nil, node_id: nil) raise PortNotImplementedError end - # Port extension: return the canonical committed result for each - # predecessor node in one storage call. The canonical result is either - # the committed attempt with the highest `attempt_number` in the - # requested revision, using `attempt_id.to_s` ASCII as a defensive - # tie-break, or an explicit committed-result projection carried forward - # when a preserved node remains `:committed` across a revision append. - # Projections are not attempts and must not affect attempt counts. + # Return the canonical committed result for each predecessor node in + # one storage call. The canonical result is either the committed + # attempt with the highest `attempt_number` in the requested revision, + # using `attempt_id.to_s` ASCII as a defensive tie-break + # ({DAG::AttemptOrder}), or an explicit committed-result projection + # carried forward when a preserved node remains `:committed` across a + # revision append. Projections are not attempts and must not affect + # attempt counts. + # + # The default implementation composes `load_node_states` and + # `list_attempts`. It cannot see carry-forward projections, so when a + # predecessor's node state is `:committed` but no committed attempt + # exists in the revision it raises {DAG::StaleStateError} instead of + # silently dropping the predecessor's `context_patch` — adapters that + # materialize projections must override this method. # # @param workflow_id [String] # @param revision [Integer] # @param predecessors [Array] # @return [Hash{Symbol => DAG::Success}] + # @raise [DAG::StaleStateError] when a committed predecessor has no + # committed attempt and no projection is reachable def list_committed_results_for_predecessors(workflow_id:, revision:, predecessors:) - raise PortNotImplementedError + states = load_node_states(workflow_id: workflow_id, revision: revision) + predecessors.filter_map { |pred| + node_id = pred.to_sym + best = list_attempts(workflow_id: workflow_id, revision: revision, node_id: node_id) + .select { |attempt| attempt[:state] == :committed } + .reduce(nil) { |current, attempt| DAG::AttemptOrder.better?(attempt, current) ? attempt : current } + + if best + [node_id, best[:result]] + elsif states[node_id] == :committed + raise StaleStateError, + "node #{node_id} is :committed in revision #{revision} but has no committed attempt; " \ + "the adapter must override list_committed_results_for_predecessors to expose its " \ + "committed-result projection" + end + }.to_h end # Count attempts for a node within a revision, excluding `:aborted`. diff --git a/lib/dag/proposed_mutation.rb b/lib/dag/proposed_mutation.rb index 23445ec..04060c7 100644 --- a/lib/dag/proposed_mutation.rb +++ b/lib/dag/proposed_mutation.rb @@ -63,6 +63,37 @@ def initialize(kind:, target_node_id:, replacement_graph: nil, rationale: nil, c metadata: DAG.frozen_copy(metadata) ) end + + # Full-fidelity JSON-safe projection; round-trips via + # {ProposedMutation.from_h}. + # @return [Hash] + def to_h + { + kind: kind, + target_node_id: target_node_id, + replacement_graph: replacement_graph&.to_h, + rationale: rationale, + confidence: confidence, + metadata: metadata + } + end + + # Rebuild a ProposedMutation from a {#to_h} projection (Symbol or + # String keys). + # @param hash [Hash] + # @return [ProposedMutation] + def self.from_h(hash) + DAG::Validation.hash!(hash, "proposed_mutation hash") + replacement = DAG::Snapshot.fetch(hash, :replacement_graph) + new( + kind: DAG::Snapshot.fetch!(hash, :kind).to_sym, + target_node_id: DAG::Snapshot.fetch!(hash, :target_node_id), + replacement_graph: replacement && DAG::ReplacementGraph.from_h(replacement), + rationale: DAG::Snapshot.fetch(hash, :rationale), + confidence: DAG::Snapshot.fetch(hash, :confidence, 1.0), + metadata: DAG::Snapshot.fetch(hash, :metadata, {}) + ) + end end # Closed set of mutation kinds. diff --git a/lib/dag/replacement_graph.rb b/lib/dag/replacement_graph.rb index f0b3f1b..c213a2d 100644 --- a/lib/dag/replacement_graph.rb +++ b/lib/dag/replacement_graph.rb @@ -41,6 +41,30 @@ def initialize(graph:, entry_node_ids:, exit_node_ids:) ) end + # Full-fidelity JSON-safe projection; round-trips via + # {ReplacementGraph.from_h}. + # @return [Hash] + def to_h + { + graph: graph.to_h, + entry_node_ids: entry_node_ids, + exit_node_ids: exit_node_ids + } + end + + # Rebuild a ReplacementGraph from a {#to_h} projection (Symbol or + # String keys). + # @param hash [Hash] + # @return [ReplacementGraph] + def self.from_h(hash) + DAG::Validation.hash!(hash, "replacement_graph hash") + new( + graph: DAG::Graph.from_h(DAG::Snapshot.fetch!(hash, :graph)), + entry_node_ids: DAG::Snapshot.fetch!(hash, :entry_node_ids), + exit_node_ids: DAG::Snapshot.fetch!(hash, :exit_node_ids) + ) + end + private def validate_node_ids_shape!(ids, label) diff --git a/lib/dag/result.rb b/lib/dag/result.rb index 3730b34..e188884 100644 --- a/lib/dag/result.rb +++ b/lib/dag/result.rb @@ -12,7 +12,7 @@ module DAG # map { |v| ... } -- transform value on success; passes through on failure # recover { |e| ... } -- failure → result (lets you turn failure back into success) # unwrap! -- value on success, raises on failure - # to_h -- {status:, value:|error:} + # to_h -- full-fidelity JSON-safe projection (see Result.from_h) # # `and_then` and `recover` MUST return a Result. The block result is checked # and a clean error is raised if not — this catches the most common monad @@ -55,5 +55,27 @@ def self.assert_result!(value, source) return value if value.is_a?(Result) raise TypeError, "#{source} block must return a DAG::Result, got #{value.class}" end + + # Deserialization entry point for every step-outcome `to_h` projection. + # Dispatches on `:status` and returns `Success`, `Failure`, or `Waiting` + # (Waiting is a valid step outcome even though it does not include the + # monadic `Result` API). Accepts Symbol or String keys/status, so a + # round-trip through a JSON serializer reconstructs the original value. + # + # @param hash [Hash] a `Success#to_h`, `Failure#to_h`, or `Waiting#to_h` + # @return [DAG::Success, DAG::Failure, DAG::Waiting] + # @raise [ArgumentError] when `:status` is missing or unknown + def self.from_h(hash) + DAG::Validation.hash!(hash, "result hash") + status = DAG::Snapshot.fetch(hash, :status) + + case status&.to_sym + when :success then Success.from_h(hash) + when :failure then Failure.from_h(hash) + when :waiting then Waiting.from_h(hash) + else + raise ArgumentError, "unknown result status: #{status.inspect}" + end + end end end diff --git a/lib/dag/runner.rb b/lib/dag/runner.rb index 9961896..3eb05c2 100644 --- a/lib/dag/runner.rb +++ b/lib/dag/runner.rb @@ -96,12 +96,25 @@ def resume(workflow_id) # Reset `:failed` nodes for the workflow's current revision and run # the workflow again; subject to `runtime_profile.max_workflow_retries`. + # Appends a durable `:workflow_retrying` event in the same atomic step + # as the retry transition, so the event log explains the + # `workflow_failed -> node_started` sequence a retry produces. # @param workflow_id [String] # @return [DAG::RunResult] # @raise [DAG::StaleStateError] when the workflow is not `:failed` # @raise [DAG::WorkflowRetryExhaustedError] when the budget is spent def retry_workflow(workflow_id) - @storage.prepare_workflow_retry(id: workflow_id, from: :failed, to: :pending) + workflow = @storage.load_workflow(id: workflow_id) + event = DAG::Event[ + type: :workflow_retrying, + workflow_id: workflow_id, + revision: workflow[:current_revision], + at_ms: @clock.now_ms, + payload: {} + ] + result = @storage.prepare_workflow_retry(id: workflow_id, from: :failed, to: :pending, event: event) + stamped = result.is_a?(Hash) ? result[:event] : nil + publish_event(stamped) if stamped call(workflow_id) end @@ -307,12 +320,8 @@ def commit_and_emit(run, node_id, attempt_id, attempt_number, result, node_state end def commit_idempotency_conflict(run, node_id, attempt_id, attempt_number, conflict) - error = { - code: :effect_idempotency_conflict, - class: conflict.class.name, - message: conflict.message - } - failure = DAG::Failure[error: error, retriable: false] + failure = DAG::Result.exception_failure(:effect_idempotency_conflict, conflict) + error = failure.error event = build_event(run, type: :node_failed, node_id: node_id, @@ -419,45 +428,11 @@ def predecessor_snapshots(predecessors, committed_results) end def committed_results_for_predecessors(run, predecessors) - if DAG::Ports::Storage.method_overridden?(@storage, :list_committed_results_for_predecessors) - return @storage.list_committed_results_for_predecessors( - workflow_id: run.workflow_id, - revision: run.revision, - predecessors: predecessors - ) - end - - predecessors.each_with_object({}) do |pred, results| - attempts = @storage.list_attempts(workflow_id: run.workflow_id, revision: run.revision, node_id: pred) - committed = canonical_committed_attempt(attempts) - results[pred] = committed[:result] if committed - end - end - - # Pick the canonical committed attempt independent of `list_attempts` - # ordering: highest `attempt_number`, with `attempt_id.to_s` ASCII as - # a defensive tie-break. Single-pass; avoids the intermediate Array - # and per-element key Array that `select`/`max_by` would allocate on - # this hot path. - def canonical_committed_attempt(attempts) - best = nil - best_id = nil - attempts.each do |a| - next unless a[:state] == :committed - n = a.fetch(:attempt_number) - if best.nil? || n > best.fetch(:attempt_number) - best = a - best_id = nil - elsif n == best.fetch(:attempt_number) - best_id ||= best.fetch(:attempt_id).to_s - candidate_id = a.fetch(:attempt_id).to_s - if candidate_id > best_id - best = a - best_id = candidate_id - end - end - end - best + @storage.list_committed_results_for_predecessors( + workflow_id: run.workflow_id, + revision: run.revision, + predecessors: predecessors + ) end def safe_call_step(run, node_id, input) @@ -502,25 +477,27 @@ def finalize(run, paused:, failed:) end def transition_and_emit_terminal(run, state, event_type, payload) - atomic_transition_with_event(run, from: :running, to: state, event_type: event_type, payload: payload) - build_run_result(run, state) + stamped = atomic_transition_with_event(run, from: :running, to: state, event_type: event_type, payload: payload) + build_run_result(run, state, last_event_seq: stamped&.seq) end # Atomic at the storage layer: the row transition and the event append # cannot diverge under crash. The event_bus publish happens after the - # storage call returns and is best-effort (non-durable). + # storage call returns and is best-effort (non-durable). Returns the + # stamped event (or nil for adapters that do not return it). def atomic_transition_with_event(run, from:, to:, event_type:, payload:) event = build_event(run, type: event_type, payload: payload) result = @storage.transition_workflow_state(id: run.workflow_id, from: from, to: to, event: event) stamped = result.is_a?(Hash) ? result[:event] : nil publish_event(stamped) if stamped + stamped end - def build_run_result(run, state) - events = @storage.read_events(workflow_id: run.workflow_id) + def build_run_result(run, state, last_event_seq: nil) + last_event_seq ||= @storage.read_events(workflow_id: run.workflow_id).last&.seq DAG::RunResult.new( state: state, - last_event_seq: events.last&.seq, + last_event_seq: last_event_seq, outcome: {workflow_id: run.workflow_id, revision: run.revision}, metadata: {} ) @@ -544,9 +521,7 @@ def append_event(run, **kwargs) end def publish_event(event) - @event_bus.publish(event) - rescue - nil + DAG::EventPublishing.publish_quietly(@event_bus, event) end end end diff --git a/lib/dag/snapshot.rb b/lib/dag/snapshot.rb new file mode 100644 index 0000000..ba93bc2 --- /dev/null +++ b/lib/dag/snapshot.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module DAG + # Indifferent-key fetch helpers for deserializing JSON-safe snapshots. + # `to_h` projections in this library use Symbol keys, but a round-trip + # through a serializer turns them into Strings; `from_h` constructors + # accept both spellings through these helpers. + # @api private + module Snapshot + module_function + + # @param hash [Hash] + # @param key [Symbol] + # @param default [Object] + # @return [Object] + def fetch(hash, key, default = nil) + hash.fetch(key) { hash.fetch(key.to_s, default) } + end + + # @param hash [Hash] + # @param key [Symbol] + # @return [Object] + # @raise [KeyError] when neither spelling of `key` is present + def fetch!(hash, key) + hash.fetch(key) do + hash.fetch(key.to_s) { raise KeyError, "missing snapshot key: #{key}" } + end + end + end +end diff --git a/lib/dag/success.rb b/lib/dag/success.rb index e1319fb..6cc6e6e 100644 --- a/lib/dag/success.rb +++ b/lib/dag/success.rb @@ -81,8 +81,34 @@ def recover = self # @return [Object] `value` def unwrap! = value - # @return [Hash] {status: :success, value:} - def to_h = {status: :success, value: value} + # Full-fidelity JSON-safe projection; round-trips via {Result.from_h}. + # Durable adapters persisting committed results must use this shape — + # `context_patch` in particular is load-bearing for replay. + # @return [Hash] + def to_h + { + status: :success, + value: value, + context_patch: context_patch, + proposed_mutations: proposed_mutations.map(&:to_h), + proposed_effects: proposed_effects.map(&:to_h), + metadata: metadata + } + end + + # Rebuild a Success from a {#to_h} projection (Symbol or String keys). + # @param hash [Hash] + # @return [Success] + def self.from_h(hash) + DAG::Validation.hash!(hash, "success hash") + new( + value: DAG::Snapshot.fetch(hash, :value), + context_patch: DAG::Snapshot.fetch(hash, :context_patch, {}), + proposed_mutations: DAG::Snapshot.fetch(hash, :proposed_mutations, []).map { |m| DAG::ProposedMutation.from_h(m) }, + proposed_effects: DAG::Snapshot.fetch(hash, :proposed_effects, []).map { |i| DAG::Effects::Intent.from_h(i) }, + metadata: DAG::Snapshot.fetch(hash, :metadata, {}) + ) + end # @return [String] def inspect = "Success(#{value.inspect})" diff --git a/lib/dag/testing/storage_contract/consumer_boundary.rb b/lib/dag/testing/storage_contract/consumer_boundary.rb index c7541d6..a49fbc3 100644 --- a/lib/dag/testing/storage_contract/consumer_boundary.rb +++ b/lib/dag/testing/storage_contract/consumer_boundary.rb @@ -14,11 +14,9 @@ def test_contract_declares_all_behavior_groups def test_contract_sources_do_not_encode_consumer_runtime_names contract_files = [File.expand_path("../storage_contract.rb", __dir__), *Dir[File.join(__dir__, "*.rb")]] forbidden_words = ["Del" + "phi", "Del" + "phic", "Run" + "Context", "SQL" + "ite", "Sql" + "ite"] - offenders = contract_files.filter_map do |path| + offenders = contract_files.select do |path| content = File.read(path) - next unless forbidden_words.any? { |word| content.include?(word) } - - path + forbidden_words.any? { |word| content.include?(word) } end assert_empty offenders, "storage contract suite must stay adapter- and consumer-neutral" diff --git a/lib/dag/trace_record.rb b/lib/dag/trace_record.rb index b1c5a7b..0d2acc4 100644 --- a/lib/dag/trace_record.rb +++ b/lib/dag/trace_record.rb @@ -76,8 +76,8 @@ def initialize( ) DAG::Validation.string!(workflow_id, "workflow_id") DAG::Validation.revision!(revision) - DAG::Validation.node_id!(node_id) unless node_id.nil? - DAG::Validation.string!(attempt_id, "attempt_id") unless attempt_id.nil? + DAG::Validation.optional_node_id!(node_id) + DAG::Validation.optional_string!(attempt_id, "attempt_id") DAG::Validation.integer!(at_ms, "at_ms") DAG::Validation.member!(status, DAG::TraceRecord::STATUSES, "status", message: "invalid trace status: #{status.inspect}") DAG::Validation.member!(event_type, DAG::Event::TYPES, "event_type", message: "invalid event type: #{event_type.inspect}") @@ -114,7 +114,7 @@ def to_h end # Closed normalized status vocabulary used by TraceRecord. - TraceRecord::STATUSES = %i[started success waiting failed paused completed mutation_applied effect_dispatch_stale_lease].freeze + TraceRecord::STATUSES = %i[started success waiting failed paused completed retrying mutation_applied effect_dispatch_stale_lease].freeze # Mapping from durable event types to normalized trace statuses. TraceRecord::EVENT_STATUS = { @@ -127,6 +127,7 @@ def to_h workflow_waiting: :waiting, workflow_completed: :completed, workflow_failed: :failed, + workflow_retrying: :retrying, mutation_applied: :mutation_applied, effect_dispatch_stale_lease: :effect_dispatch_stale_lease }.freeze diff --git a/lib/dag/validation.rb b/lib/dag/validation.rb index b3de170..3414723 100644 --- a/lib/dag/validation.rb +++ b/lib/dag/validation.rb @@ -179,6 +179,14 @@ def node_id!(value) raise ArgumentError, "node_id must be Symbol or String" end + # @param value [Object] + # @return [String, Symbol, nil] + def optional_node_id!(value) + return value if value.nil? + + node_id!(value) + end + # @param value [Object] # @param method_name [Symbol] # @param label [String] diff --git a/lib/dag/waiting.rb b/lib/dag/waiting.rb index 87ccef3..f0f22bf 100644 --- a/lib/dag/waiting.rb +++ b/lib/dag/waiting.rb @@ -65,5 +65,32 @@ def initialize(reason:, resume_token: nil, not_before_ms: nil, proposed_effects: metadata: DAG.frozen_copy(metadata) ) end + + # Full-fidelity JSON-safe projection; round-trips via {Result.from_h}. + # @return [Hash] + def to_h + { + status: :waiting, + reason: reason, + resume_token: resume_token, + not_before_ms: not_before_ms, + proposed_effects: proposed_effects.map(&:to_h), + metadata: metadata + } + end + + # Rebuild a Waiting from a {#to_h} projection (Symbol or String keys). + # @param hash [Hash] + # @return [Waiting] + def self.from_h(hash) + DAG::Validation.hash!(hash, "waiting hash") + new( + reason: DAG::Snapshot.fetch!(hash, :reason).to_sym, + resume_token: DAG::Snapshot.fetch(hash, :resume_token), + not_before_ms: DAG::Snapshot.fetch(hash, :not_before_ms), + proposed_effects: DAG::Snapshot.fetch(hash, :proposed_effects, []).map { |i| DAG::Effects::Intent.from_h(i) }, + metadata: DAG::Snapshot.fetch(hash, :metadata, {}) + ) + end end end diff --git a/rubocop/cop/dag/no_external_requires.rb b/rubocop/cop/dag/no_external_requires.rb index dc0fa9b..7b1896a 100644 --- a/rubocop/cop/dag/no_external_requires.rb +++ b/rubocop/cop/dag/no_external_requires.rb @@ -7,10 +7,11 @@ module Cop module DAG class NoExternalRequires < Base MSG = "Runtime requires in ruby-dag must stay within the Ruby standard library." + # The frozen-decision stdlib allowlist from Roadmap v3.4 §3. + # Anything else (including other stdlib gems such as etc, singleton, + # or yaml) needs a documented contract extension first. STDLIB = %w[ digest - digest/sha2 - etc fileutils forwardable json @@ -18,9 +19,7 @@ class NoExternalRequires < Base pathname securerandom set - singleton time - yaml ].freeze RESTRICT_ON_SEND = %i[require].freeze diff --git a/rubocop/cop/dag/no_in_place_mutation.rb b/rubocop/cop/dag/no_in_place_mutation.rb index 460a123..afdc86a 100644 --- a/rubocop/cop/dag/no_in_place_mutation.rb +++ b/rubocop/cop/dag/no_in_place_mutation.rb @@ -19,8 +19,20 @@ class NoInPlaceMutation < Base # code. `immutability.rb` is intentionally NOT in this list — it is # the implementation of `deep_dup` / `deep_freeze` / `json_safe!` and # necessarily mutates the local hashes it is constructing. + # + # Roadmap-pure files intentionally NOT in this list (the cop flags + # every mutating send, with no receiver analysis, so local-variable + # builder mutation would be a false positive): + # - `runner.rb` — builds local frozen lookup tables in place before + # freezing them (`preds << p`, `predecessors_by_node[id] = ...`, + # `snapshots[pred] = ...`, `results[pred] = ...`). + # - `definition_editor.rb` — builds a local `step_types` hash in + # place while assembling the replacement definition. + # - `graph.rb` — mutable-until-frozen builder by design; mutation + # before `freeze` is its documented contract. PURE_KERNEL_FILES = %w[ event.rb + mutation_service.rb proposed_mutation.rb replacement_graph.rb run_result.rb diff --git a/rubocop/cop/dag/no_thread_or_ractor.rb b/rubocop/cop/dag/no_thread_or_ractor.rb index 17ef1ba..170ffa3 100644 --- a/rubocop/cop/dag/no_thread_or_ractor.rb +++ b/rubocop/cop/dag/no_thread_or_ractor.rb @@ -7,7 +7,9 @@ module Cop module DAG class NoThreadOrRactor < Base MSG = "Do not use thread/ractor primitives or process spawning in ruby-dag kernel code." - FORBIDDEN_CONS = %w[Thread Ractor Mutex Monitor Queue SizedQueue ConditionVariable].freeze + # `Fiber` is banned everywhere, including the dispatcher carve-out + # (it is deliberately absent from DISPATCHER_RELAXED_CONS). + FORBIDDEN_CONS = %w[Thread Ractor Mutex Monitor Queue SizedQueue ConditionVariable Fiber].freeze DISPATCHER_RELAXED_CONS = %w[Thread Queue].freeze FORBIDDEN_THREAD_SENDS = %i[new start fork].freeze # The carve-out documented in Roadmap §2.4 / §9.1 is "Thread for the @@ -17,6 +19,10 @@ class NoThreadOrRactor < Base # the gap between the documented exception and the cop allow-list. DISPATCHER_RELAXED_THREAD_SENDS = %i[new].freeze FORBIDDEN_PROCESS_SENDS = %i[fork spawn daemon].freeze + # `Kernel.system` / `Kernel.spawn` / `Kernel.fork` are equivalent to + # the bare and `Process.*` forms and must not slip through via the + # explicit `Kernel` receiver. + FORBIDDEN_KERNEL_SENDS = %i[system spawn fork].freeze def on_const(node) return unless FORBIDDEN_CONS.include?(node.const_name) @@ -34,6 +40,11 @@ def on_send(node) return end + if receiver&.const_type? && receiver.const_name == "Kernel" + add_offense(node) if runtime_file? && FORBIDDEN_KERNEL_SENDS.include?(method_name) + return + end + if receiver&.const_type? && %w[Thread Ractor].include?(receiver.const_name) if dispatcher_relaxed_file? && receiver.const_name == "Thread" && DISPATCHER_RELAXED_THREAD_SENDS.include?(method_name) diff --git a/spec/r0/kernel_ai_terms_test.rb b/spec/r0/kernel_ai_terms_test.rb new file mode 100644 index 0000000..53ee381 --- /dev/null +++ b/spec/r0/kernel_ai_terms_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +# Roadmap v3.4 §9.1: no AI/LLM vocabulary in the kernel. The kernel is a +# deterministic workflow runtime; consumer-domain words must not leak into +# `lib/` source, comments, or docs. This is the executable grep gate the +# roadmap's mitigation column calls for. +class R0KernelAiTermsTest < Minitest::Test + FORBIDDEN_TERMS = /\b(llm|openai|anthropic|gpt|chatbot|prompt|copilot)\b/i + + def test_lib_sources_contain_no_ai_terms + root = File.expand_path("../..", __dir__) + offenders = Dir[File.join(root, "lib", "**", "*.rb")].filter_map do |path| + relative = path.delete_prefix("#{root}/") + matches = File.readlines(path).each_with_index.filter_map do |line, index| + "#{relative}:#{index + 1}" if line.match?(FORBIDDEN_TERMS) + end + matches unless matches.empty? + end.flatten + + assert_empty offenders, "AI/LLM terms are banned in the kernel (Roadmap §9.1)" + end +end diff --git a/spec/r0/ports_test.rb b/spec/r0/ports_test.rb index 9023c0e..6e7b2af 100644 --- a/spec/r0/ports_test.rb +++ b/spec/r0/ports_test.rb @@ -3,7 +3,7 @@ require_relative "../test_helper" class R0PortsTest < Minitest::Test - STORAGE_METHODS = { + STORAGE_OWN_METHODS = { create_workflow: {id: "x", initial_definition: nil, initial_context: {}, runtime_profile: nil}, load_workflow: {id: "x"}, transition_workflow_state: {id: "x", from: :pending, to: :running}, @@ -22,6 +22,16 @@ class R0PortsTest < Minitest::Test transition_node_state: {workflow_id: "x", revision: 1, node_id: :a, from: :pending, to: :running}, begin_attempt: {workflow_id: "x", revision: 1, node_id: :a, expected_node_state: :pending, attempt_number: 1}, commit_attempt: {attempt_id: "x", result: nil, node_state: :committed, event: nil}, + abort_running_attempts: {workflow_id: "x"}, + list_attempts: {workflow_id: "x"}, + list_committed_results_for_predecessors: {workflow_id: "x", revision: 1, predecessors: [:a]}, + count_attempts: {workflow_id: "x", revision: 1, node_id: :a}, + append_event: {workflow_id: "x", event: nil}, + read_events: {workflow_id: "x"}, + prepare_workflow_retry: {id: "x"} + }.freeze + + EFFECT_LEDGER_METHODS = { list_effects_for_node: {workflow_id: "x", revision: 1, node_id: :a}, list_effects_for_attempt: {attempt_id: "x"}, claim_ready_effects: {limit: 1, owner_id: "worker", lease_ms: 1, now_ms: 1}, @@ -30,30 +40,129 @@ class R0PortsTest < Minitest::Test renew_effect_lease: {effect_id: "x", owner_id: "worker", until_ms: 2, now_ms: 1}, complete_effect_succeeded: {effect_id: "x", owner_id: "worker", result: {}, external_ref: nil, now_ms: 1}, complete_effect_failed: {effect_id: "x", owner_id: "worker", error: {}, retriable: false, not_before_ms: nil, now_ms: 1}, - release_nodes_satisfied_by_effect: {effect_id: "x", now_ms: 1}, - abort_running_attempts: {workflow_id: "x"}, - list_attempts: {workflow_id: "x"}, - list_committed_results_for_predecessors: {workflow_id: "x", revision: 1, predecessors: []}, - count_attempts: {workflow_id: "x", revision: 1, node_id: :a}, - append_event: {workflow_id: "x", event: nil}, - read_events: {workflow_id: "x"}, - prepare_workflow_retry: {id: "x"} + release_nodes_satisfied_by_effect: {effect_id: "x", now_ms: 1} }.freeze + STORAGE_METHODS = STORAGE_OWN_METHODS.merge(EFFECT_LEDGER_METHODS).freeze + ROOT = File.expand_path("../..", __dir__) def test_storage_port_method_list_matches_documented_contract - assert_equal STORAGE_METHODS.keys.sort, DAG::Ports::Storage.public_instance_methods(false).sort + assert_equal STORAGE_OWN_METHODS.keys.sort, DAG::Ports::Storage.public_instance_methods(false).sort + assert_equal (EFFECT_LEDGER_METHODS.keys + [:thread_safe_for_dispatch?]).sort, + DAG::Ports::EffectLedger.public_instance_methods(false).sort + assert_includes DAG::Ports::Storage.ancestors, DAG::Ports::EffectLedger end def test_storage_port_public_methods_document_return_shapes - source = File.read(File.join(ROOT, "lib/dag/ports/storage.rb")) + storage_source = File.read(File.join(ROOT, "lib/dag/ports/storage.rb")) + ledger_source = File.read(File.join(ROOT, "lib/dag/ports/effect_ledger.rb")) + + {STORAGE_OWN_METHODS => storage_source, EFFECT_LEDGER_METHODS => ledger_source}.each do |methods, source| + methods.each_key do |method_name| + method_documentation = source.match(/((?:\s*#.*\n)+)\s*def #{method_name}\(/) + refute_nil method_documentation, "#{method_name} should have a documentation block" + assert_includes method_documentation[1], "@return", "#{method_name} should document its return shape" + end + end + end + + FakeTerminalRecord = Struct.new(:id, :terminal) do + def terminal? = terminal + end + + def test_effect_ledger_complete_defaults_compose_mark_and_release + adapter = Class.new { + include DAG::Ports::EffectLedger + + attr_reader :calls + + def initialize(terminal:) + @terminal = terminal + @calls = [] + end + + def mark_effect_succeeded(**kwargs) + @calls << :mark_succeeded + R0PortsTest::FakeTerminalRecord.new(kwargs.fetch(:effect_id), true) + end + + def mark_effect_failed(**kwargs) + @calls << :mark_failed + R0PortsTest::FakeTerminalRecord.new(kwargs.fetch(:effect_id), @terminal) + end + + def release_nodes_satisfied_by_effect(effect_id:, now_ms:) + @calls << :release + [{node_id: :a, released_at_ms: now_ms}] + end + } + + succeeded = adapter.new(terminal: true) + completion = succeeded.complete_effect_succeeded( + effect_id: "e1", owner_id: "w", result: {}, external_ref: nil, now_ms: 5 + ) + assert_equal %i[mark_succeeded release], succeeded.calls + assert_equal "e1", completion.fetch(:record).id + assert_equal [{node_id: :a, released_at_ms: 5}], completion.fetch(:released) + + terminal_failure = adapter.new(terminal: true) + completion = terminal_failure.complete_effect_failed( + effect_id: "e2", owner_id: "w", error: {}, retriable: false, not_before_ms: nil, now_ms: 6 + ) + assert_equal %i[mark_failed release], terminal_failure.calls + assert_equal [{node_id: :a, released_at_ms: 6}], completion.fetch(:released) + + retriable_failure = adapter.new(terminal: false) + completion = retriable_failure.complete_effect_failed( + effect_id: "e3", owner_id: "w", error: {}, retriable: true, not_before_ms: nil, now_ms: 7 + ) + assert_equal %i[mark_failed], retriable_failure.calls + assert_equal [], completion.fetch(:released) + end + + def test_effect_ledger_thread_safe_for_dispatch_defaults_to_false + adapter = Class.new { include DAG::Ports::EffectLedger }.new + refute adapter.thread_safe_for_dispatch? + refute DAG::Adapters::Memory::Storage.new.thread_safe_for_dispatch? + end + + def test_storage_default_committed_results_uses_attempts_and_raises_on_missing_projection + adapter_class = Class.new { + include DAG::Ports::Storage + + def initialize(states:, attempts_by_node:) + @states = states + @attempts_by_node = attempts_by_node + end + + def load_node_states(workflow_id:, revision:) + @states + end + + def list_attempts(workflow_id:, revision: nil, node_id: nil) + @attempts_by_node.fetch(node_id, []) + end + } + + success = DAG::Success[value: 1, context_patch: {"k" => 1}] + adapter = adapter_class.new( + states: {a: :committed, b: :pending}, + attempts_by_node: {a: [ + {state: :committed, attempt_number: 1, attempt_id: "w/1", result: DAG::Success[value: 0]}, + {state: :committed, attempt_number: 2, attempt_id: "w/2", result: success}, + {state: :failed, attempt_number: 3, attempt_id: "w/3", result: nil} + ]} + ) + + results = adapter.list_committed_results_for_predecessors(workflow_id: "w", revision: 1, predecessors: %i[a b]) + assert_equal({a: success}, results) - STORAGE_METHODS.each_key do |method_name| - method_documentation = source.match(/((?:\s*#.*\n)+)\s*def #{method_name}\(/) - refute_nil method_documentation, "#{method_name} should have a documentation block" - assert_includes method_documentation[1], "@return", "#{method_name} should document its return shape" + projected = adapter_class.new(states: {a: :committed}, attempts_by_node: {}) + error = assert_raises(DAG::StaleStateError) do + projected.list_committed_results_for_predecessors(workflow_id: "w", revision: 1, predecessors: [:a]) end + assert_match(/no committed attempt/, error.message) end def test_runner_does_not_parse_storage_error_messages diff --git a/spec/r0/rubocop_cops_test.rb b/spec/r0/rubocop_cops_test.rb index 9ca7f2c..8888aca 100644 --- a/spec/r0/rubocop_cops_test.rb +++ b/spec/r0/rubocop_cops_test.rb @@ -118,6 +118,28 @@ def test_no_thread_or_ractor_flags_bare_system_in_runtime refute_empty offenses end + def test_no_thread_or_ractor_flags_kernel_receiver_process_sends + %w[system spawn fork].each do |method_name| + offenses = inspect_source( + RuboCop::Cop::DAG::NoThreadOrRactor, + "Kernel.#{method_name}('echo ok')\n", + path: runtime_path("kernel_#{method_name}_example.rb") + ) + + refute_empty offenses, "Kernel.#{method_name} must be flagged" + end + end + + def test_no_thread_or_ractor_flags_fiber_even_in_dispatcher + offenses = inspect_source( + RuboCop::Cop::DAG::NoThreadOrRactor, + "Fiber.new { :work }\n", + path: runtime_path("effects/dispatcher.rb") + ) + + refute_empty offenses + end + def test_no_thread_or_ractor_flags_backticks_in_runtime offenses = inspect_source( RuboCop::Cop::DAG::NoThreadOrRactor, diff --git a/spec/r0/v1_2_release_gate_test.rb b/spec/r0/v1_2_release_gate_test.rb index 03cb867..f9a51ab 100644 --- a/spec/r0/v1_2_release_gate_test.rb +++ b/spec/r0/v1_2_release_gate_test.rb @@ -23,10 +23,10 @@ def test_roadmap_marks_v1_2_release_done end def test_storage_port_documents_renew_effect_lease - port = File.read(File.join(ROOT, "lib/dag/ports/storage.rb")) + port = File.read(File.join(ROOT, "lib/dag/ports/effect_ledger.rb")) assert_includes port, "def renew_effect_lease(effect_id:, owner_id:, until_ms:, now_ms:)" - assert_includes port, "cooperatively extend the lease" + assert_includes port, "Cooperatively extend the lease" end def test_contract_documents_renew_effect_lease diff --git a/spec/r0/v1_3_release_gate_test.rb b/spec/r0/v1_3_release_gate_test.rb index d12976f..75b19e7 100644 --- a/spec/r0/v1_3_release_gate_test.rb +++ b/spec/r0/v1_3_release_gate_test.rb @@ -25,7 +25,7 @@ def test_dispatcher_exposes_parallelism_kwarg dispatcher = File.read(File.join(ROOT, "lib/dag/effects/dispatcher.rb")) assert_includes dispatcher, "parallelism: 1" - assert_includes dispatcher, "def parallel_map(items)" + assert_includes dispatcher, "def parallel_map(items, &block)" assert_includes dispatcher, "def validate_parallelism_storage!" assert_includes dispatcher, "thread_safe_for_dispatch?" end diff --git a/spec/r0/v1_4_release_gate_test.rb b/spec/r0/v1_4_release_gate_test.rb index 199bc56..8ca8abd 100644 --- a/spec/r0/v1_4_release_gate_test.rb +++ b/spec/r0/v1_4_release_gate_test.rb @@ -26,7 +26,7 @@ def test_roadmap_marks_v1_4_release end def test_port_exposes_only_workflow_id_kwarg - port = File.read(File.join(ROOT, "lib/dag/ports/storage.rb")) + port = File.read(File.join(ROOT, "lib/dag/ports/effect_ledger.rb")) assert_includes port, "def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:, only_workflow_id: nil)" assert_includes port, "only_workflow_id [String, nil]" diff --git a/spec/r1/retry_workflow_test.rb b/spec/r1/retry_workflow_test.rb index d7d76c3..1f1badc 100644 --- a/spec/r1/retry_workflow_test.rb +++ b/spec/r1/retry_workflow_test.rb @@ -92,6 +92,28 @@ def test_crash_after_prepare_workflow_retry_leaves_no_split_retry_state assert_equal :pending, node_state(healthy_storage, workflow_id, :flaky) end + def test_retry_workflow_appends_durable_workflow_retrying_event + storage = DAG::Adapters::Memory::Storage.new + registry, _counter = registry_with_failing_step(failures_before_success: 1) + runner = build_runner(storage: storage, registry: registry) + + definition = DAG::Workflow::Definition.new.add_node(:flaky, type: :flaky) + workflow_id = create_workflow(storage, definition, + runtime_profile: profile(max_attempts_per_node: 1, max_workflow_retries: 1)) + + assert_equal :failed, runner.call(workflow_id).state + assert_equal :completed, runner.retry_workflow(workflow_id).state + + events = storage.read_events(workflow_id: workflow_id) + retrying = events.select { |e| e.type == :workflow_retrying } + assert_equal 1, retrying.size + failed_seq = events.find { |e| e.type == :workflow_failed }.seq + assert_operator retrying.first.seq, :>, failed_seq, + "workflow_retrying must follow the workflow_failed it explains" + assert_equal :workflow_started, events.first.type, + "workflow_started stays once-per-lifetime; retry must not re-emit it" + end + def test_retry_does_not_overwrite_aborted_attempt_records storage = DAG::Adapters::Memory::Storage.new registry, _counter = registry_with_failing_step(failures_before_success: 4) diff --git a/spec/r1/storage_state_extras_test.rb b/spec/r1/storage_state_extras_test.rb index 246f70e..c5119ef 100644 --- a/spec/r1/storage_state_extras_test.rb +++ b/spec/r1/storage_state_extras_test.rb @@ -13,7 +13,8 @@ def setup def test_create_workflow_rejects_duplicate_id workflow_id = create_workflow(@storage, @definition) - assert_raises(ArgumentError) { create_workflow_with_id(workflow_id) } + error = assert_raises(DAG::DuplicateWorkflowError) { create_workflow_with_id(workflow_id) } + assert_kind_of DAG::Error, error end def test_create_workflow_rejects_non_definition @@ -169,9 +170,10 @@ def test_begin_attempt_state_mismatch_raises end def test_commit_attempt_unknown_attempt_raises - assert_raises(ArgumentError) do + error = assert_raises(DAG::UnknownAttemptError) do @storage.commit_attempt(attempt_id: "missing", result: DAG::Success[value: 1, context_patch: {}], node_state: :committed, event: build_event) end + assert_kind_of DAG::Error, error end def test_commit_attempt_unexpected_result_type_raises diff --git a/spec/r2/effects_dispatcher_test.rb b/spec/r2/effects_dispatcher_test.rb index d300c71..14caa7b 100644 --- a/spec/r2/effects_dispatcher_test.rb +++ b/spec/r2/effects_dispatcher_test.rb @@ -94,12 +94,50 @@ def test_unknown_handler_defaults_to_terminal_failure assert_equal effect.id, report.errors.first[:effect_id] end - def test_unknown_handler_raise_policy_raises + def test_unknown_handler_raise_policy_aborts_tick_with_cause storage = DAG::Adapters::Memory::Storage.new commit_waiting_effect(storage, node_id: :a, effect_type: "missing") dispatcher = build_dispatcher(storage, handlers: {}, unknown_handler_policy: :raise) - assert_raises(DAG::Effects::UnknownHandlerError) { dispatcher.tick(limit: 1) } + error = assert_raises(DAG::Effects::DispatchAbortedError) { dispatcher.tick(limit: 1) } + assert_instance_of DAG::Effects::UnknownHandlerError, error.cause + assert_kind_of DAG::Error, error + assert_kind_of DAG::Effects::DispatchReport, error.report + assert_equal 1, error.report.claimed.size + assert_empty error.report.succeeded + end + + def test_aborted_tick_report_keeps_outcomes_completed_before_the_abort + storage = DAG::Adapters::Memory::Storage.new + ok_effect = commit_waiting_effect(storage, node_id: :a, effect_type: "ok", effect_key: "k-ok") + commit_waiting_effect(storage, node_id: :b, effect_type: "missing", effect_key: "k-missing") + dispatcher = build_dispatcher(storage, + handlers: {"ok" => ->(_record) { DAG::Effects::HandlerResult.succeeded(result: {done: true}) }}, + unknown_handler_policy: :raise) + + error = assert_raises(DAG::Effects::DispatchAbortedError) { dispatcher.tick(limit: 2) } + + assert_instance_of DAG::Effects::UnknownHandlerError, error.cause + assert_equal 2, error.report.claimed.size + assert_equal [ok_effect.id], error.report.succeeded.map(&:id) + assert_equal 1, error.report.released.size + # The aborted record stays :dispatching until its lease expires. + statuses = storage.list_effects_for_node(workflow_id: error.report.claimed.last.workflow_id, revision: 1, node_id: :b) + assert_equal [:dispatching], statuses.map(&:status) + end + + def test_tick_forwards_only_workflow_id_to_claim + storage = DAG::Adapters::Memory::Storage.new + target = commit_waiting_effect(storage, node_id: :a, effect_type: "scoped", effect_key: "k-a") + other = commit_waiting_effect(storage, node_id: :b, effect_type: "scoped", effect_key: "k-b") + refute_equal target.workflow_id, other.workflow_id + dispatcher = build_dispatcher(storage, + handlers: {"scoped" => ->(_record) { DAG::Effects::HandlerResult.succeeded(result: {}) }}) + + report = dispatcher.tick(limit: 10, only_workflow_id: target.workflow_id) + + assert_equal [target.id], report.claimed.map(&:id) + assert_equal [target.id], report.succeeded.map(&:id) end def test_handler_exception_becomes_retriable_failure @@ -113,7 +151,7 @@ def test_handler_exception_becomes_retriable_failure failed = report.failed.first assert_equal :failed_retriable, failed.status assert_equal :handler_raised, failed.error[:code] - assert_equal "RuntimeError", failed.error[:class] + assert_equal "RuntimeError", failed.error[:error_class] assert_equal "boom", failed.error[:message] assert_equal :handler_raised, report.errors.first[:code] assert_equal effect.id, report.errors.first[:effect_id] @@ -130,7 +168,7 @@ def test_handler_bad_return_becomes_retriable_failure failed = report.failed.first assert_equal :failed_retriable, failed.status assert_equal :handler_bad_return, failed.error[:code] - assert_equal "String", failed.error[:class] + assert_equal "String", failed.error[:returned_class] assert_equal :handler_bad_return, report.errors.first[:code] assert_equal effect.id, report.errors.first[:effect_id] end @@ -410,6 +448,8 @@ def commit_waiting_effect(storage, node_id:, effect_type:, effect_key: "effect") end class StaleSuccessStorage + include DAG::Ports::EffectLedger + attr_writer :claimed def initialize @@ -417,7 +457,7 @@ def initialize @succeeded = false end - def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:) + def claim_ready_effects(limit:, owner_id:, lease_ms:, now_ms:, only_workflow_id: nil) @claimed.first(limit) end diff --git a/spec/r3/result_serialization_test.rb b/spec/r3/result_serialization_test.rb new file mode 100644 index 0000000..5b09edb --- /dev/null +++ b/spec/r3/result_serialization_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +# Full-fidelity serialization seam for step outcomes: every sibling type +# (Success / Failure / Waiting) projects through `to_h` and reconstructs +# through `Result.from_h`, including after a JSON round-trip through the +# stdlib serializer (Symbol keys become Strings). +class R3ResultSerializationTest < Minitest::Test + def test_success_round_trips_with_mutations_and_effects + graph = DAG::Graph.new + graph.add_node(:x) + graph.add_node(:y) + graph.add_edge(:x, :y, weight: 2) + replacement = DAG::ReplacementGraph[ + graph: graph, + entry_node_ids: [:x], + exit_node_ids: [:y] + ] + success = DAG::Success[ + value: {"answer" => 42}, + context_patch: {"k" => "v"}, + proposed_mutations: [ + DAG::ProposedMutation[ + kind: :replace_subtree, + target_node_id: :a, + replacement_graph: replacement, + rationale: "because", + confidence: 0.5, + metadata: {"m" => 1} + ] + ], + proposed_effects: [ + DAG::Effects::Intent[type: "mail", key: "m-1", payload: {"to" => "ops"}, metadata: {"x" => 1}] + ], + metadata: {"meta" => true} + ] + + restored = DAG::Result.from_h(success.to_h) + assert_equal success, restored + + json_restored = DAG::Result.from_h(json_round_trip(success.to_h)) + assert_equal success.value, json_restored.value + assert_equal success.context_patch, json_restored.context_patch + assert_equal success.metadata, json_restored.metadata + assert_equal success.proposed_effects, json_restored.proposed_effects + + restored_mutation = json_restored.proposed_mutations.first + assert_equal :replace_subtree, restored_mutation.kind + assert_equal :a, restored_mutation.target_node_id + assert_equal graph.to_h, restored_mutation.replacement_graph.graph.to_h + assert_equal %w[x y], restored_mutation.replacement_graph.entry_node_ids.map(&:to_s) + + restored_mutation.replacement_graph.exit_node_ids.map(&:to_s) + end + + def test_failure_round_trips_retriable_flag + failure = DAG::Failure[error: {"code" => "step_raised"}, retriable: true, metadata: {"m" => 1}] + + restored = DAG::Result.from_h(json_round_trip(failure.to_h)) + assert_equal failure, restored + assert restored.retriable + end + + def test_waiting_round_trips_through_result_from_h + waiting = DAG::Waiting[ + reason: :external_call, + resume_token: "tok-1", + not_before_ms: 5_000, + proposed_effects: [DAG::Effects::Intent[type: "call", key: "c-1", payload: {"n" => 1}]], + metadata: {"m" => 2} + ] + + restored = DAG::Result.from_h(json_round_trip(waiting.to_h)) + assert_equal waiting, restored + end + + def test_from_h_rejects_unknown_or_missing_status + assert_raises(ArgumentError) { DAG::Result.from_h({status: "nope"}) } + assert_raises(ArgumentError) { DAG::Result.from_h({}) } + assert_raises(ArgumentError) { DAG::Result.from_h(:not_a_hash) } + end + + def test_graph_from_h_restores_nodes_edges_and_metadata + graph = DAG::Graph.new + graph.add_node(:a) + graph.add_node(:b) + graph.add_node(:c) + graph.add_edge(:a, :b, kind: "data") + graph.add_edge(:b, :c) + graph.freeze + + restored = DAG::Graph.from_h(json_round_trip(graph.to_h)) + assert restored.frozen? + assert_equal graph.to_h, restored.to_h + end + + private + + def json_round_trip(hash) + JSON.parse(JSON.generate(hash)) + end +end diff --git a/spec/r3/review_hardening_test.rb b/spec/r3/review_hardening_test.rb new file mode 100644 index 0000000..cbb03a9 --- /dev/null +++ b/spec/r3/review_hardening_test.rb @@ -0,0 +1,386 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +# Pins the helpers and guard branches introduced (or made canonical) by the +# project-review hardening pass: AttemptOrder, EventPublishing, the optional +# Validation helpers, the trusted ExecutionContext#merge path, and the +# defensive branches in StorageState and the dispatcher carriers. +class R3ReviewHardeningTest < Minitest::Test + FixedClock = Data.define(:now_ms) + + # --- DAG::AttemptOrder ------------------------------------------------- + + def test_attempt_order_prefers_higher_attempt_number + low = {attempt_number: 1, attempt_id: "w/9"} + high = {attempt_number: 2, attempt_id: "w/1"} + + assert DAG::AttemptOrder.better?(high, low) + refute DAG::AttemptOrder.better?(low, high) + assert DAG::AttemptOrder.better?(low, nil) + end + + def test_attempt_order_breaks_number_ties_by_attempt_id_ascii + first = {attempt_number: 1, attempt_id: "w/1"} + second = {attempt_number: 1, attempt_id: "w/2"} + + assert DAG::AttemptOrder.better?(second, first) + refute DAG::AttemptOrder.better?(first, second) + end + + def test_attempt_order_key_sorts_ascending_to_the_canonical_attempt + attempts = [ + {attempt_number: 2, attempt_id: "w/3"}, + {attempt_number: 1, attempt_id: "w/9"}, + {attempt_number: 2, attempt_id: "w/4"} + ] + + sorted = attempts.sort_by { |a| DAG::AttemptOrder.key(a) } + assert_equal "w/4", sorted.last.fetch(:attempt_id) + end + + # --- DAG::EventPublishing ---------------------------------------------- + + def test_publish_quietly_forwards_to_the_bus_and_returns_nil + seen = [] + bus = Object.new + bus.define_singleton_method(:publish) { |event| seen << event } + + assert_nil DAG::EventPublishing.publish_quietly(bus, :event) + assert_equal [:event], seen + end + + def test_publish_quietly_swallows_bus_errors + bus = Object.new + bus.define_singleton_method(:publish) { |_event| raise "bus down" } + + assert_nil DAG::EventPublishing.publish_quietly(bus, :event) + end + + # --- DAG::Validation optional helpers ---------------------------------- + + def test_validation_optional_helpers_raise_on_wrong_type + assert_raises(ArgumentError) { DAG::Validation.optional_hash!(1, "x") } + assert_raises(ArgumentError) { DAG::Validation.optional_instance!(1, String, "x") } + assert_raises(ArgumentError) { DAG::Validation.optional_node_id!(1) } + assert_raises(ArgumentError) { DAG::Validation.dependency!(Object.new, :call, "handler") } + end + + def test_validation_optional_node_id_accepts_nil_and_node_ids + assert_nil DAG::Validation.optional_node_id!(nil) + assert_equal :a, DAG::Validation.optional_node_id!(:a) + assert_equal "a", DAG::Validation.optional_node_id!("a") + end + + # --- DAG::Effects snapshot helpers -------------------------------------- + + def test_fetch_snapshot_value_falls_back_to_default_for_opaque_snapshots + assert_equal :fallback, DAG::Effects.fetch_snapshot_value(Object.new, :ref, :fallback) + end + + def test_fetch_required_snapshot_value_reads_object_attributes_and_raises_when_missing + snapshot = Struct.new(:ref).new("type:key") + + assert_equal "type:key", DAG::Effects.fetch_required_snapshot_value(snapshot, :ref) + assert_raises(KeyError) { DAG::Effects.fetch_required_snapshot_value(Object.new, :ref) } + end + + # --- Dispatcher value carriers ------------------------------------------ + + def test_handler_result_generic_factory_builds_failed_results + result = DAG::Effects::HandlerResult[status: :failed_terminal, error: {code: :nope}] + + assert result.failure? + refute result.retriable? + end + + def test_dispatch_outcome_rejects_both_succeeded_and_failed_records + outcome_class = DAG::Effects::Dispatcher.const_get(:DispatchOutcome) + record = nil + error = assert_raises(ArgumentError) do + outcome_class.new( + succeeded_record: fake_record, failed_record: fake_record, + released: [], error: record + ) + end + assert_match(/cannot contain both/, error.message) + end + + def test_dispatcher_rejects_handlers_that_collide_after_string_coercion + storage = DAG::Adapters::Memory::Storage.new + handler = ->(_record) { DAG::Effects::HandlerResult.succeeded(result: {}) } + + error = assert_raises(ArgumentError) do + DAG::Effects::Dispatcher.new( + storage: storage, + handlers: {"dup" => handler, :dup => handler}, + clock: FixedClock[now_ms: 1], + owner_id: "worker", + lease_ms: 10 + ) + end + assert_match(/duplicate effect types/, error.message) + end + + # --- TraceRecord / NodeDiagnostic factories ------------------------------ + + def test_trace_record_keyword_factory_builds_records + record = DAG::TraceRecord[ + workflow_id: "w", + revision: 1, + at_ms: 5, + status: :retrying, + event_type: :workflow_retrying + ] + + assert_equal :retrying, record.status + assert_nil record.node_id + end + + def test_node_diagnostic_keyword_factory_and_error_code_guard + diagnostic = DAG::NodeDiagnostic[ + workflow_id: "w", + revision: 1, + node_id: :a, + state: :pending, + terminal: false, + attempt_count: 0 + ] + assert_equal :a, diagnostic.node_id + + assert_raises(ArgumentError) do + DAG::NodeDiagnostic[ + workflow_id: "w", + revision: 1, + node_id: :a, + state: :pending, + terminal: false, + attempt_count: 0, + last_error_code: {bad: true} + ] + end + end + + # --- ExecutionContext trusted merge -------------------------------------- + + def test_merge_rejects_canonical_key_collisions_across_spellings + context = DAG::ExecutionContext.from({"a" => 1}) + + error = assert_raises(ArgumentError) { context.merge({a: 2}) } + assert_match(/canonical key collision/, error.message) + end + + def test_merge_still_validates_and_freezes_the_patch + context = DAG::ExecutionContext.from({"a" => 1}) + + assert_raises(ArgumentError) { context.merge({"bad" => Object.new}) } + + merged = context.merge({"b" => {"nested" => [1, 2]}}) + assert_equal 1, merged["a"] + assert merged["b"].frozen? + assert merged["b"]["nested"].frozen? + assert_equal({"a" => 1}, context.to_h, "merge must not mutate the receiver") + end + + def test_merge_overwrites_same_spelling_keys_and_chains + context = DAG::ExecutionContext.from({"a" => 1}) + merged = context.merge({"a" => 2}).merge({"c" => 3}).merge({"a" => 4}) + + assert_equal 4, merged["a"] + assert_equal 3, merged["c"] + assert_raises(ArgumentError) { merged.merge({c: 0}) } + end + + # --- StorageState defensive branches -------------------------------------- + + def test_legacy_snapshots_rebuild_attempt_and_active_effect_indexes + storage = DAG::Adapters::Memory::Storage.new + workflow_id = create_workflow(storage, simple_definition) + attempt_id = begin_waiting_attempt_with_effect(storage, workflow_id, :a) + + legacy = storage.instance_variable_get(:@state).dup + legacy.delete(:attempts_by_node) + legacy.delete(:active_effect_order) + revived = DAG::Adapters::Memory::Storage.new(initial_state: legacy) + + assert_equal 1, revived.count_attempts(workflow_id: workflow_id, revision: 1, node_id: :a) + claimed = revived.claim_ready_effects(limit: 10, owner_id: "w", lease_ms: 100, now_ms: 1_000) + assert_equal 1, claimed.size + assert_equal attempt_id, claimed.first.attempt_id + end + + def test_claim_skips_terminal_effects_left_in_a_corrupted_active_order + storage = DAG::Adapters::Memory::Storage.new + workflow_id = create_workflow(storage, simple_definition) + begin_waiting_attempt_with_effect(storage, workflow_id, :a) + + record = storage.claim_ready_effects(limit: 1, owner_id: "w", lease_ms: 100, now_ms: 1_000).first + storage.mark_effect_succeeded(effect_id: record.id, owner_id: "w", result: {}, external_ref: nil, now_ms: 1_001) + + state = storage.instance_variable_get(:@state) + state[:active_effect_order] << record.id + + assert_empty storage.claim_ready_effects(limit: 10, owner_id: "w", lease_ms: 100, now_ms: 2_000) + end + + def test_commit_attempt_rejects_effect_intents_with_foreign_coordinates + storage = DAG::Adapters::Memory::Storage.new + workflow_id = create_workflow(storage, simple_definition) + attempt_id = storage.begin_attempt( + workflow_id: workflow_id, revision: 1, node_id: :a, + expected_node_state: :pending, attempt_number: 1 + ) + + mismatches = { + workflow_id: {workflow_id: "other"}, + revision: {revision: 9}, + node_id: {node_id: :zzz}, + attempt_id: {attempt_id: "other/1"} + } + mismatches.each do |field, override| + intent = prepared_intent(workflow_id, attempt_id, **override) + error = assert_raises(ArgumentError) do + storage.commit_attempt( + attempt_id: attempt_id, + result: DAG::Waiting[reason: :effect_pending], + node_state: :waiting, + event: node_waiting_event(workflow_id, attempt_id), + effects: [intent] + ) + end + assert_match(/effects\[0\]\.#{field} does not match attempt/, error.message) + end + end + + def test_commit_attempt_rejects_event_with_foreign_attempt_id + storage = DAG::Adapters::Memory::Storage.new + workflow_id = create_workflow(storage, simple_definition) + attempt_id = storage.begin_attempt( + workflow_id: workflow_id, revision: 1, node_id: :a, + expected_node_state: :pending, attempt_number: 1 + ) + + error = assert_raises(ArgumentError) do + storage.commit_attempt( + attempt_id: attempt_id, + result: DAG::Success[value: 1], + node_state: :committed, + event: DAG::Event[ + type: :node_committed, workflow_id: workflow_id, revision: 1, + node_id: :a, attempt_id: "#{attempt_id}-other", at_ms: 1, payload: {} + ] + ) + end + assert_match(/event\.attempt_id does not match/, error.message) + end + + def test_append_revision_if_workflow_state_rejects_non_running_disallowed_states + storage = DAG::Adapters::Memory::Storage.new + workflow_id = create_workflow(storage, simple_definition) + + error = assert_raises(DAG::StaleStateError) do + storage.append_revision_if_workflow_state( + id: workflow_id, + allowed_states: %i[paused waiting], + parent_revision: 1, + definition: simple_definition.with_revision(2), + invalidated_node_ids: [], + event: nil + ) + end + assert_match(/cannot append revision from :pending/, error.message) + end + + def test_committed_result_projection_carries_forward_across_two_revisions + storage = DAG::Adapters::Memory::Storage.new + definition = simple_definition + workflow_id = create_workflow(storage, definition) + commit_node(storage, workflow_id, 1, :a) + + storage.append_revision( + id: workflow_id, parent_revision: 1, definition: definition.with_revision(2), + invalidated_node_ids: [:b], event: nil + ) + storage.append_revision( + id: workflow_id, parent_revision: 2, definition: definition.with_revision(3), + invalidated_node_ids: [:b], event: nil + ) + + results = storage.list_committed_results_for_predecessors( + workflow_id: workflow_id, revision: 3, predecessors: [:a] + ) + assert_equal({a: true}, results[:a].context_patch) + assert_equal 0, storage.count_attempts(workflow_id: workflow_id, revision: 3, node_id: :a), + "projections must not count as attempts" + end + + # --- CrashableStorage pass-through ---------------------------------------- + + def test_crashable_storage_prepare_retry_passes_through_when_trigger_does_not_match + storage = DAG::Adapters::Memory::CrashableStorage.new( + crash_on: {method: :prepare_workflow_retry, after: true, to: :never} + ) + workflow_id = create_workflow(storage, simple_definition, + runtime_profile: DAG::RuntimeProfile[ + durability: :ephemeral, max_attempts_per_node: 1, + max_workflow_retries: 1, event_bus_kind: :null + ]) + storage.transition_workflow_state(id: workflow_id, from: :pending, to: :running) + storage.transition_workflow_state(id: workflow_id, from: :running, to: :failed) + + row = storage.prepare_workflow_retry(id: workflow_id) + assert_equal :pending, row[:state] + end + + private + + def fake_record + storage = DAG::Adapters::Memory::Storage.new + workflow_id = create_workflow(storage, simple_definition) + attempt_id = begin_waiting_attempt_with_effect(storage, workflow_id, :a) + storage.list_effects_for_attempt(attempt_id: attempt_id).first + end + + def begin_waiting_attempt_with_effect(storage, workflow_id, node_id) + attempt_id = storage.begin_attempt( + workflow_id: workflow_id, revision: 1, node_id: node_id, + expected_node_state: :pending, attempt_number: 1 + ) + storage.commit_attempt( + attempt_id: attempt_id, + result: DAG::Waiting[reason: :effect_pending], + node_state: :waiting, + event: node_waiting_event(workflow_id, attempt_id, node_id: node_id), + effects: [prepared_intent(workflow_id, attempt_id, node_id: node_id)] + ) + attempt_id + end + + def prepared_intent(workflow_id, attempt_id, **overrides) + DAG::Effects::PreparedIntent[ + workflow_id: workflow_id, + revision: 1, + node_id: :a, + attempt_id: attempt_id, + type: "external.call", + key: "k-#{attempt_id}", + payload: {n: 1}, + payload_fingerprint: "fp-1", + blocking: true, + created_at_ms: 1_000, + **overrides + ] + end + + def node_waiting_event(workflow_id, attempt_id, node_id: :a) + DAG::Event[ + type: :node_waiting, + workflow_id: workflow_id, + revision: 1, + node_id: node_id, + attempt_id: attempt_id, + at_ms: 1_000, + payload: {} + ] + end +end diff --git a/spec/result_test.rb b/spec/result_test.rb index 65cfe0e..e0486be 100644 --- a/spec/result_test.rb +++ b/spec/result_test.rb @@ -39,7 +39,10 @@ def test_success_unwraps end def test_success_to_h - assert_equal({status: :success, value: 42}, DAG::Success.new(value: 42).to_h) + assert_equal( + {status: :success, value: 42, context_patch: {}, proposed_mutations: [], proposed_effects: [], metadata: {}}, + DAG::Success.new(value: 42).to_h + ) end def test_success_inspect @@ -84,7 +87,10 @@ def test_failure_unwrap_raises end def test_failure_to_h - assert_equal({status: :failure, error: "boom"}, DAG::Failure.new(error: "boom").to_h) + assert_equal( + {status: :failure, error: "boom", retriable: false, metadata: {}}, + DAG::Failure.new(error: "boom").to_h + ) end def test_failure_inspect