Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,8 @@ jobs:
snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh)
printf '%s\n' "$snapshot_output"
snapshot_count=$(printf '%s\n' "$snapshot_output" | grep -c '^ok - ')
[ "$snapshot_count" -eq 15 ] || {
echo "::error::expected 15 snapshot/fleet-view tests, got $snapshot_count"
[ "$snapshot_count" -eq 17 ] || {
echo "::error::expected 17 snapshot/fleet-view tests, got $snapshot_count"
exit 1
}

Expand Down
7 changes: 7 additions & 0 deletions bin/fm-bearings-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
# backlog roles, unresolved blockers, and captain actionability. It never infers
# decisions from report or visual-review prose or reimplements snapshot semantics.
#
# Because this wrapper only ever reads the canonical snapshot's .backlog.records[]
# and never re-parses data/backlog.md itself, it inherits beads-backend awareness
# for free the moment fm-fleet-snapshot.sh's backlog_json gains a beads branch
# (beads-authority migration Stage 1): no code change is needed here to surface
# beads-sourced in-flight/queued records. See fm-fleet-snapshot.sh's backlog_json
# and backlog_json_beads, and docs/configuration.md "Backlog backend".
#
# Main-home inventory validity comes from the canonical snapshot's main_inventory
# object (orphan structured in-flight without meta, unstructured current rows).
# Bearings never invents Underway rows from backlog-only ids; it discloses those
Expand Down
125 changes: 124 additions & 1 deletion bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
# requires_child_metadata, blocked_by_ids, unresolved_blocker_ids, and
# captain_actionable fields. Repeated blocker tokens remain ordered; a blocker
# resolves only when its structured record is Done, and missing ids stay open.
# When config/backlog-backend=beads, backlog gains source:"beads",
# fleet_label (the queried fleet label), records_truncated, and
# records_limit, and records[] holds this fleet's open/in_progress/blocked
# beads (state queued/in_flight only, always structured:true with empty
# blocked_by_ids and requires_child_metadata/captain_actionable false); a
# failed or unavailable beads read falls back to the data/backlog.md shape
# above with no beads-only fields. Default-backend output is unaffected.
# tasks[]: one row per state/<id>.meta, sorted by id.
# current_state is parsed from bin/fm-crew-state.sh <id> and preserves
# state, source, detail, and raw line separately.
Expand Down Expand Up @@ -94,6 +101,8 @@ FM_SNAPSHOT_REGISTRY_LINES=${FM_SNAPSHOT_REGISTRY_LINES:-256}
FM_SNAPSHOT_REGISTRY_BYTES=${FM_SNAPSHOT_REGISTRY_BYTES:-65536}
FM_SNAPSHOT_REGISTRY_RECORDS=${FM_SNAPSHOT_REGISTRY_RECORDS:-40}
FM_SNAPSHOT_REGISTRY_TIMEOUT=${FM_SNAPSHOT_REGISTRY_TIMEOUT:-2}
FM_SNAPSHOT_BEADS_LIMIT=${FM_SNAPSHOT_BEADS_LIMIT:-200}
FM_SNAPSHOT_BEADS_TIMEOUT=${FM_SNAPSHOT_BEADS_TIMEOUT:-4}
validate_positive_bound() { # <name> <value>
case "$2" in
''|*[!0-9]*|0)
Expand Down Expand Up @@ -124,6 +133,8 @@ validate_positive_bound FM_SNAPSHOT_REGISTRY_LINES "$FM_SNAPSHOT_REGISTRY_LINES"
validate_positive_bound FM_SNAPSHOT_REGISTRY_BYTES "$FM_SNAPSHOT_REGISTRY_BYTES"
validate_positive_bound FM_SNAPSHOT_REGISTRY_RECORDS "$FM_SNAPSHOT_REGISTRY_RECORDS"
validate_positive_bound FM_SNAPSHOT_REGISTRY_TIMEOUT "$FM_SNAPSHOT_REGISTRY_TIMEOUT"
validate_positive_bound FM_SNAPSHOT_BEADS_LIMIT "$FM_SNAPSHOT_BEADS_LIMIT"
validate_positive_bound FM_SNAPSHOT_BEADS_TIMEOUT "$FM_SNAPSHOT_BEADS_TIMEOUT"

# shellcheck source=bin/fm-backend.sh
# shellcheck disable=SC1091
Expand All @@ -134,6 +145,9 @@ validate_positive_bound FM_SNAPSHOT_REGISTRY_TIMEOUT "$FM_SNAPSHOT_REGISTRY_TIME
# shellcheck source=bin/fm-ff-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-ff-lib.sh" # validate_secondmate_home: shared seeded-home boundary checks
# shellcheck source=bin/fm-tasks-axi-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # fm_beads_backend_available, fm_beads_fleet_label

usage() {
cat <<'EOF'
Expand Down Expand Up @@ -161,6 +175,9 @@ FM_SNAPSHOT_PARENT_ACTIVITY_TIMEOUT, with truncation disclosed in the result.
The registered secondmate table uses FM_SNAPSHOT_REGISTRY_LINES,
FM_SNAPSHOT_REGISTRY_BYTES, FM_SNAPSHOT_REGISTRY_RECORDS, and
FM_SNAPSHOT_REGISTRY_TIMEOUT, with unavailability and truncation disclosed.
When config/backlog-backend=beads, the beads-backed backlog read uses
FM_SNAPSHOT_BEADS_LIMIT (default 200) and FM_SNAPSHOT_BEADS_TIMEOUT (default
4), with truncation disclosed via records_truncated/records_limit.
EOF
}

Expand Down Expand Up @@ -247,7 +264,11 @@ first_pr_url_in_file() { # <file>
grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1
}

backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG
# backlog_json_markdown [<backlog-path>] - parses data/backlog.md into the
# fm-fleet-snapshot.v1 records[] schema. This is the default-backend body;
# its output must stay byte-identical, so beads-backend logic lives in the
# sibling backlog_json_beads instead of branching inline here.
backlog_json_markdown() { # [<backlog-path>] - defaults to this home's $BACKLOG
local backlog=${1:-$BACKLOG}
if [ ! -f "$backlog" ]; then
jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}'
Expand Down Expand Up @@ -397,6 +418,108 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG
' < "$backlog"
}

# backlog_json_beads [<backlog-path>] - beads-authority migration Stage 1
# (data/beads-authority-migration-scout/report.md section 4). Reads this
# fleet's in-flight/queued beads, scoped by fm_beads_fleet_label, into the
# same fm-fleet-snapshot.v1 records[] shape backlog_json_markdown produces.
# Only status open/in_progress/blocked beads are fetched (closed beads are
# out of scope for an in-flight/queued view). Dependency-graph fields
# (blocked_by_ids, hold_reason/hold_kind) and local state/*.meta correlation
# are later-stage work per the report and are left empty/null here; a
# beads-sourced record is always structured:true with
# requires_child_metadata:false and captain_actionable:false so it can never
# be mistaken for an orphaned or unstructured row by main_inventory_json.
# <backlog-path> is accepted for signature parity with backlog_json_markdown
# but only used as the markdown-fallback path when the beads read fails.
backlog_json_beads() { # [<backlog-path>] - defaults to this home's $BACKLOG
local backlog=${1:-$BACKLOG} label out truncated
label=$(fm_beads_fleet_label)
if ! out=$(run_timed "$FM_SNAPSHOT_BEADS_TIMEOUT" task list --label "$label" \
--status open,in_progress,blocked --limit "$FM_SNAPSHOT_BEADS_LIMIT" \
--json 2>/dev/null) \
|| ! printf '%s' "$out" | jq -e 'type == "array"' >/dev/null 2>&1; then
backlog_json_markdown "$backlog"
return 0
fi

truncated=false
if [ "$(printf '%s' "$out" | jq 'length')" -ge "$FM_SNAPSHOT_BEADS_LIMIT" ]; then
truncated=true
fi

printf '%s' "$out" | jq --arg path "$backlog" --arg label "$label" \
--argjson truncated "$truncated" --argjson limit "$FM_SNAPSHOT_BEADS_LIMIT" '
def state_of:
if .status == "in_progress" then "in_flight" else "queued" end;
def body_excerpt:
(.description // "") as $d
| if ($d | length) == 0 then null else ($d[:240]) end;
{path:$path,
present:true,
source:"beads",
fleet_label:$label,
records_truncated:$truncated,
records_limit:$limit,
records:[
to_entries[] | (.key + 1) as $order | .value | ((.id // "?") as $id | (.title // "(untitled)") as $title | {
order:$order,
state:state_of,
structured:true,
id:$id,
checked:false,
title:$title,
repo:null,
kind:null,
priority:(.priority | tostring),
hold_reason:null,
hold_kind:null,
blocked_by:null,
blocked_by_ids:[],
blocked_reason:null,
since:null,
merged:null,
reported:null,
done:null,
completion:{verb:null,date:null},
links:[],
pr_url:null,
report_path:null,
local_note:null,
raw:($id + " - " + $title),
body_lines:[],
body_excerpt:body_excerpt,
unresolved_blocker_ids:[],
current_role:(if state_of == "in_flight" then "worker" else "queued" end),
requires_child_metadata:false,
captain_actionable:false
})
]}
'
}

# fm_snapshot_beads_backend_available <config-dir> - same check as
# fm_beads_backend_available (fm-tasks-axi-lib.sh), but bounds the probe's
# `task list` subprocess with FM_SNAPSHOT_BEADS_TIMEOUT so a hung or
# lock-contended federated store cannot stall the snapshot indefinitely.
fm_snapshot_beads_backend_available() { # <config-dir>
local config_dir=$1
[ "$(fm_backlog_backend_value "$config_dir")" = beads ] || return 1
command -v task >/dev/null 2>&1 || return 1
run_timed "$FM_SNAPSHOT_BEADS_TIMEOUT" task list --limit 1 >/dev/null 2>&1
}

# backlog_json [<backlog-path>] - dispatcher. Reads from the beads store when
# config/backlog-backend=beads (mirroring fm-session-start.sh's
# print_backlog_compact gate), otherwise parses data/backlog.md exactly as
# before so default-backend output stays byte-identical.
backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG
if fm_snapshot_beads_backend_available "$CONFIG"; then
backlog_json_beads "${1:-$BACKLOG}"
else
backlog_json_markdown "${1:-$BACKLOG}"
fi
}

task_json_lines() {
local meta id kind harness mode yolo project worktree home projects backend target status_log report_path
local pr pr_source event_json current_json endpoint_exists agent_alive meta_json status_json report_json worktree_json home_json
Expand Down
13 changes: 13 additions & 0 deletions bin/fm-tasks-axi-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,16 @@ fm_beads_backend_available() {
command -v task >/dev/null 2>&1 || return 1
task list --limit 1 >/dev/null 2>&1
}

# fm_beads_fleet_label - the label firstmate's own dispatched-work beads are
# meant to carry once bead creation is wired to it (beads-authority migration
# Stage 0; see data/beads-authority-migration-scout/report.md section 4 and
# docs/configuration.md "Backlog backend"). A `task list --label <this>` call
# scopes to firstmate's fleet instead of the shared federated store's full
# cross-project set. As of Stage 1, only reads (fm-fleet-snapshot.sh) use this
# label; no code in bin/ creates a bead with it yet. FM_BEADS_FLEET_LABEL is
# an override for test fixtures; production code should call this function
# rather than hardcoding the label.
fm_beads_fleet_label() {
printf '%s\n' "${FM_BEADS_FLEET_LABEL:-fleet:firstmate}"
}
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Set the local, gitignored `config/backlog-backend` file to `manual` to force man
Absent or `tasks-axi` selects the default tasks-axi backend.
The file format is unchanged in tasks-axi and manual modes; both produce the same `## In flight`, `## Queued`, and `## Done` sections in `data/backlog.md`.
The beads backend does not use `data/backlog.md`; all backlog state lives in the beads store and is queried dynamically at session start.
Under the beads backend, firstmate's own dispatched-work beads are meant to carry a firstmate-fleet label, `fleet:firstmate` by default (overridable only for test fixtures via `FM_BEADS_FLEET_LABEL`), once bead creation is wired to it, so a `task list --label <that label>` call scopes to firstmate's fleet instead of surfacing the shared federated store's full cross-project set; as of Stage 1 no `bin/` code creates a bead with that label yet, only the reads below query by it.
`bin/fm-tasks-axi-lib.sh`'s `fm_beads_fleet_label` is the single owner of that label; read it from there rather than hardcoding it.
The structured fleet snapshot (`bin/fm-fleet-snapshot.sh --json`) and Bearings (`bin/fm-bearings-snapshot.sh`) also read this fleet's in-flight/queued beads, scoped by that label, when the beads backend is selected; with any other backend their output is unchanged.
That beads-sourced view currently covers only status open/in_progress/blocked beads mapped to `records[]` state `queued`/`in_flight`; per-bead dependency graphs and correlation with local `state/*.meta` are not yet wired through, so `blocked_by_ids` is always empty and `requires_child_metadata`/`captain_actionable` are always false for a beads-sourced record.

## Runtime backend (config/backend / FM_BACKEND)

Expand Down
12 changes: 12 additions & 0 deletions tests/fm-beads-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,22 @@ test_backend_value_whitespace() {
[ "$value" = "beads" ] || fail "backend value should strip whitespace, got: $value"
}

# Test: fm_beads_fleet_label() defaults to fleet:firstmate and honors the
# test-only FM_BEADS_FLEET_LABEL override (beads-authority migration Stage 0).
test_beads_fleet_label() {
local value
value=$(unset FM_BEADS_FLEET_LABEL; fm_beads_fleet_label)
[ "$value" = "fleet:firstmate" ] || fail "default fleet label should be fleet:firstmate, got: $value"

value=$(FM_BEADS_FLEET_LABEL="fleet:example" fm_beads_fleet_label)
[ "$value" = "fleet:example" ] || fail "fleet label override should win, got: $value"
}

# Run all tests
test_beads_backend_value
test_beads_backend_available
test_tasks_axi_backend_false_for_beads
test_backend_value_whitespace
test_beads_fleet_label

echo "ok - all beads backend tests passed"
65 changes: 65 additions & 0 deletions tests/fm-fleet-snapshot-view.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,69 @@ test_parked_scout_decision_stays_pending() {
pass "a scout still parked at a decision stays pending (terminal clear does not over-fire)"
}

# beads-authority migration Stage 1
# (data/beads-authority-migration-scout/report.md section 4): the snapshot
# reads this fleet's in-flight/queued state from the beads store, scoped by
# fm_beads_fleet_label, when config/backlog-backend=beads.
test_beads_backend_snapshot_scopes_by_fleet_label() {
local home fakebin out
home=$(make_home beads-backend)
printf 'beads\n' > "$home/config/backlog-backend"
fakebin=$(make_fakebin "$home")
cat > "$fakebin/task" <<'SH'
#!/usr/bin/env bash
set -u
case "$*" in
'list --limit 1')
printf '[]\n'
exit 0
;;
'list --label fleet:firstmate --status open,in_progress,blocked --limit 200 --json')
cat <<'JSON'
[
{"id":"task-aaa","title":"Queued bead","description":"queued body","status":"open","priority":2},
{"id":"task-bbb","title":"In flight bead","description":"","status":"in_progress","priority":0}
]
JSON
exit 0
;;
*)
printf 'unexpected task invocation: %s\n' "$*" >&2
exit 1
;;
esac
SH
chmod +x "$fakebin/task"
out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json)
printf '%s' "$out" | jq -e . >/dev/null || fail "beads-backed snapshot must be valid JSON: $out"
printf '%s' "$out" | jq -e '
.backlog.source == "beads"
and .backlog.fleet_label == "fleet:firstmate"
and .backlog.present == true
' >/dev/null || fail "beads-backed snapshot missing beads-source markers: $out"
printf '%s' "$out" | jq -e '
(.backlog.records[] | select(.id == "task-aaa") | .state) == "queued"
and (.backlog.records[] | select(.id == "task-bbb") | .state) == "in_flight"
' >/dev/null || fail "beads status open/in_progress was not mapped to record state queued/in_flight: $out"
printf '%s' "$out" | jq -e '.main_inventory.valid == true' >/dev/null \
|| fail "beads-sourced records must never trip the orphan/unstructured inventory checks: $out"
pass "beads backend snapshot reads fleet-scoped in-flight/queued beads"
}

# Regression guard for the byte-identical default-backend requirement: adding
# the beads branch must not add fields to the markdown backlog_json path.
test_default_backend_backlog_json_has_no_beads_fields() {
local home fakebin out
home=$(make_home default-backend-beads-regression)
write_fixture "$home"
fakebin=$(make_fakebin "$home")
out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json)
printf '%s' "$out" | jq -e '
(.backlog | has("source") | not) and (.backlog | has("fleet_label") | not)
' >/dev/null || fail "default-backend backlog_json must stay byte-identical: unexpected beads-only field: $out"
pass "default backend backlog_json carries no beads-only fields"
}

test_empty_fleet_json
test_fixture_snapshot_json
test_main_inventory_orphan_and_unstructured_disclosure
Expand All @@ -794,3 +857,5 @@ test_scout_reports_include_teardown_reports
test_backlog_tasks_axi_forms_and_overrides
test_view_renders_snapshot
test_view_renders_dead_secondmate_agent_status
test_beads_backend_snapshot_scopes_by_fleet_label
test_default_backend_backlog_json_has_no_beads_fields
Loading