App-karr

 view release on metacpan or  search on metacpan

lib/App/karr/Role/TaskMutation.pm  view on Meta::CPAN


# One spelling of "this id names no card" for every command on the mutation
# path (ticket k264). update_task_guarded and delete_task_guarded raise it when
# the ref is gone; archive and delete raise the same off the unguarded pre-read
# they take before the guard (Cmd::Archive, Cmd::Delete), so move, edit, delete,
# archive and handoff can never disagree on the wording -- the drift k263 warned
# about when it unified "No karr board found" across five commands. The id is
# real and only `karr list --compact` follows it, carrying no placeholder, so
# the suggestion is always printed (the shape `karr needs` got in k263). The
# trailing newline is here so a `die` honours it and Carp appends no call site.
sub task_not_found {
    my ($self, $id) = @_;
    return "Task $id not found on this board:\n"
        . App::karr::Error::command_hint('list', '--compact') . "\n";
}


sub update_task_guarded {
    my ($self, $id, $mutate) = @_;
    my $git = $self->git;
    my $ref = $self->_task_data_ref($id);

    return $git->retry_contended( "task $id", sub {
        my ( $oid, $content ) = $git->read_ref_with_oid($ref);
        die $self->task_not_found($id) unless defined $oid && length $content;

        my $task = App::karr::Task->from_string( $content,
            repair_frontmatter => $git->board_is_legacy_encoded );

        my $verdict = $mutate->($task);

        # A callback that found nothing to change gets no write: `updated` is
        # stamped by the write (App::karr::BoardStore/save_task_cas) and the
        # activity log hangs off it, so a command that changed nothing and
        # wrote anyway moved the one field the foundation drain reads to tell a
        # stuck card from a worked one, and put an entry in the log for an
        # event that did not happen (#231). Returning the task rather than ()
        # ends the retry loop: () means "another agent got in first, read again",
        # and nothing here lost a race.
        return $task
          if ref $verdict
          && Scalar::Util::refaddr($verdict) == Scalar::Util::refaddr($NO_CHANGE);

        # Through the role's own door rather than straight at write_ref_cas:
        # BoardAccess::save_task is where the `updated` bump and the activity
        # log entry live for every command write, guarded or not, and reaching
        # past it is what dropped move and edit out of `karr log` (#64).
        return () unless $self->save_task( $task, $oid );
        return $task;
    } );
}


# The same shape as update_task_guarded, and for the same reason: the claim rule
# is applied to the revision the delete is guarded against, so the two can never
# be about different bytes.
#
# This used to re-read the task and delete by name, because karr had no guarded
# delete to reach for -- App::karr::Git::delete_ref goes through libgit2's
# git_reference_remove(repo, name), which takes no expected-old OID. Re-reading
# closed the window that can stay open for minutes behind a confirmation prompt
# and left the microseconds between the read and the remove, in which a claim
# landing on the card was deleted along with it. App::karr::Git::delete_ref_cas
# closes that one too (#94).
sub delete_task_guarded {
    my ($self, $id, $claimant) = @_;
    my $git = $self->git;
    my $ref = $self->_task_data_ref($id);

    my $task = $git->retry_contended( "task $id", sub {
        my ( $oid, $content ) = $git->read_ref_with_oid($ref);
        die $self->task_not_found($id) unless defined $oid && length $content;

        my $found = App::karr::Task->from_string( $content,
            repair_frontmatter => $git->board_is_legacy_encoded );
        $self->check_claim( $found, $claimant );

        return () unless $git->delete_ref_cas( $ref, $oid );
        return $found;
    } );

    # L<App::karr::Role::BoardAccess/delete_task> is the activity-log funnel for
    # the unguarded path; this one writes the ref itself, so it records the same
    # entry rather than going without one (#64).
    $self->log_task_write($id);
    return $task;
}


# One status-change path, because there used to be two: `karr move` enforced
# require_claim and stamped the lifecycle dates, while `karr edit --status` just
# assigned the field. So `edit --status in-progress` quietly bought what `move
# 1 in-progress` refused to sell, and require_claim -- the guarantee karr's
# whole multi-agent coordination rests on -- was one flag away from being
# optional (ticket #55).
#
# The require_claim condition is move's, unchanged: a claim passed on the
# command line satisfies it, and so does a claim the task already carries.
#
# Being the one status-change path, this is also where the status *name* is
# checked (ticket #54) and where the lifecycle stamps are maintained (ticket
# #68) -- both for `move` and for `edit --status`.
sub apply_status_change {
    my ($self, $task, $new_status, $claimant) = @_;

    # First, so a batch dies on its first id having written nothing: the check
    # runs inside update_task_guarded's callback, and a die there means the
    # compare-and-swap write is never reached. `move 1 ZZZ` and `edit 1
    # --status ZZZ` used to exit 0 and park the task in a column that does not
    # exist -- invisible on `karr board`, still in the total, and fatal to the
    # next `karr move --next`.
    my $config = App::karr::Config->from_merged( $self->store->effective_config );
    $config->validate_status($new_status);

    my $old_status = $task->status;

    # A status change to the status the card already has changes nothing, so
    # nothing below it runs and nothing is written. kanban-md answers the same
    # way (internal/board/mutate.go:101-104), `karr archive` already answers it
    # one command over for an archived card, and ADR 0002 files that shape --
    # "no-ops like re-archiving an archived task" -- under exit 0. What made it



( run in 0.266 second using v1.01-cache-2.11-cpan-aadc1410aed )