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 )