@plot-pm/board 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +111 -111
- package/package.json +1 -1
- package/plot-approve.sh +59 -27
- package/plot-deliver.sh +43 -25
- package/plot-dispatch.sh +46 -5
- package/plot-host.sh +139 -13
- package/plot-worker-state.sh +94 -0
package/plot-host.sh
CHANGED
|
@@ -8,6 +8,17 @@
|
|
|
8
8
|
#
|
|
9
9
|
# Ops (the ~6 operations plot actually needs):
|
|
10
10
|
# backend print the resolved backend: github|bitbucket
|
|
11
|
+
# REPORTS WHAT THE REPO DECLARED. An
|
|
12
|
+
# unrecognised `Git host` word is NOT silently
|
|
13
|
+
# defaulted to github — that drove a repo
|
|
14
|
+
# declaring `gitlab` through `gh` until
|
|
15
|
+
# 2026-09-08. A backend this script has no arm
|
|
16
|
+
# for exits 4 and names the word on stderr:
|
|
17
|
+
# exit 4 is "this backend has no answer at
|
|
18
|
+
# all", a configuration a person fixes rather
|
|
19
|
+
# than a transient to retry. Adding a host is
|
|
20
|
+
# an edit to `HOST_DRIVES` and the arms below,
|
|
21
|
+
# and to nothing in `packages/domain`.
|
|
11
22
|
# default-branch print the repo's default branch name
|
|
12
23
|
# pr-state <number|branch> [--repo <owner/repo>] one JSON object:
|
|
13
24
|
# {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
|
|
@@ -366,6 +377,52 @@ host_failure_kind() { # $1=stderr text → throttled|secondary|failed
|
|
|
366
377
|
fi
|
|
367
378
|
}
|
|
368
379
|
|
|
380
|
+
# --- the connector names its own repair -------------------------------------
|
|
381
|
+
#
|
|
382
|
+
# THE TEXT COMES FROM THE CONNECTOR THAT FAILED, never from a caller branching
|
|
383
|
+
# on the stack. A refusal saying `gh auth login` is wrong advice on a Bitbucket
|
|
384
|
+
# team, and it is wrong in the direction that costs most: a teammate who has not
|
|
385
|
+
# read this repository runs the command they were given, it does nothing, and
|
|
386
|
+
# the next step is to find a person. This connector already resolved its vendor
|
|
387
|
+
# — `backend` above — so it is the one place that can name a CLI that exists.
|
|
388
|
+
#
|
|
389
|
+
# ONE FUNCTION RATHER THAN A TABLE AT EACH CALL SITE, and it is the connector
|
|
390
|
+
# contract CLAUDE.md already states applied to words instead of budgets: only a
|
|
391
|
+
# connector knows its account, its transport and its login command, so only a
|
|
392
|
+
# connector may name them.
|
|
393
|
+
#
|
|
394
|
+
# A BACKEND THIS DOES NOT KNOW GETS NO ADVICE. Inventing a login command for an
|
|
395
|
+
# unrecognised vendor is the same failure as naming the wrong one, so it says
|
|
396
|
+
# what it does not know rather than guessing — the direction `host_failure_kind`
|
|
397
|
+
# refuses in too.
|
|
398
|
+
#
|
|
399
|
+
# $1 = the backend word, as `backend` reported it.
|
|
400
|
+
host_cli() { # → the CLI this backend drives, or '' where none is known
|
|
401
|
+
case "$1" in
|
|
402
|
+
github) echo "gh" ;;
|
|
403
|
+
bitbucket) echo "bb" ;;
|
|
404
|
+
*) echo "" ;;
|
|
405
|
+
esac
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# The repair line for a host call that failed, on stderr, in this connector's
|
|
409
|
+
# own words. Prints nothing where the vendor is unknown — see above.
|
|
410
|
+
host_repair() { # $1=backend
|
|
411
|
+
local cli; cli="$(host_cli "$1")"
|
|
412
|
+
if [ -z "$cli" ]; then
|
|
413
|
+
echo " Plot does not know which CLI drives '$1', so it cannot name the fix." >&2
|
|
414
|
+
echo " Set 'Git host' in CLAUDE.md to a host Plot drives (github, bitbucket)." >&2
|
|
415
|
+
return
|
|
416
|
+
fi
|
|
417
|
+
if ! command -v "$cli" >/dev/null 2>&1; then
|
|
418
|
+
echo " '$cli' is not on PATH, and this backend is driven through it." >&2
|
|
419
|
+
echo " Install it, then: $cli auth login" >&2
|
|
420
|
+
return
|
|
421
|
+
fi
|
|
422
|
+
echo " Check the CLI can answer: $cli auth status" >&2
|
|
423
|
+
echo " If it is not logged in: $cli auth login" >&2
|
|
424
|
+
}
|
|
425
|
+
|
|
369
426
|
# A failed `pr-list`, reported and never swallowed.
|
|
370
427
|
#
|
|
371
428
|
# THREE OUTCOMES, KEPT APART — the rule `issue-list` states in full and this
|
|
@@ -380,13 +437,28 @@ pr_list_failed() { # $1=stderr text
|
|
|
380
437
|
local err="$1"
|
|
381
438
|
case "$(host_failure_kind "$err")" in
|
|
382
439
|
secondary)
|
|
383
|
-
|
|
440
|
+
# NO REPAIR NAMED, AND THAT IS THE ANSWER. A burst refusal clears in
|
|
441
|
+
# seconds and nothing is broken, so `$cli auth login` here would send a
|
|
442
|
+
# reader to fix a login that is already fine. The decision is the wait.
|
|
443
|
+
echo "plot-host: pr-list: host refused a burst — ${err:-the host refused the request and said nothing}" >&2
|
|
444
|
+
echo " Nothing is wrong and nothing needs fixing: this limit bounds calls at" >&2
|
|
445
|
+
echo " once, not per hour. Retry shortly, with fewer at a time." >&2
|
|
446
|
+
exit 6
|
|
384
447
|
;;
|
|
385
448
|
throttled)
|
|
386
|
-
|
|
449
|
+
echo "plot-host: pr-list: host throttled — ${err:-the host refused the request and said nothing}" >&2
|
|
450
|
+
echo " The window's quota is spent. Wait for the reset the message names," >&2
|
|
451
|
+
echo " or run against an account with quota left. No login will help." >&2
|
|
452
|
+
exit 5
|
|
387
453
|
;;
|
|
388
454
|
esac
|
|
389
|
-
|
|
455
|
+
# THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
|
|
456
|
+
# the repair for the first is a login this connector can name — see
|
|
457
|
+
# `host_repair`, which asks the CLI this backend drives rather than assuming
|
|
458
|
+
# one. A failure that is neither costs the reader one `auth status`.
|
|
459
|
+
echo "plot-host: pr-list: ${err:-the host failed the request and said nothing}" >&2
|
|
460
|
+
host_repair "$(backend)"
|
|
461
|
+
exit 3
|
|
390
462
|
}
|
|
391
463
|
|
|
392
464
|
# Run one `pr-list` host call, or die reporting which failure it was.
|
|
@@ -1225,7 +1297,10 @@ jira_require_config() {
|
|
|
1225
1297
|
die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
|
|
1226
1298
|
fi
|
|
1227
1299
|
if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
|
|
1228
|
-
|
|
1300
|
+
echo "plot-host: Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox" >&2
|
|
1301
|
+
echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2
|
|
1302
|
+
echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>." >&2
|
|
1303
|
+
exit 3
|
|
1229
1304
|
fi
|
|
1230
1305
|
}
|
|
1231
1306
|
|
|
@@ -1348,21 +1423,64 @@ pr_list_report_truncation() {
|
|
|
1348
1423
|
echo "plot-host: $be pr-list state=$state possibly truncated ($count rows, requested limit $limit unprovable) — a join against this page may read older branches as 'no PR' (#333)" >&2
|
|
1349
1424
|
}
|
|
1350
1425
|
|
|
1351
|
-
|
|
1426
|
+
# The backends this script has an arm for, which is what "drivable" means here.
|
|
1427
|
+
#
|
|
1428
|
+
# THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
|
|
1429
|
+
# host means teaching the ops below its CLI; this array is the record of which
|
|
1430
|
+
# have been taught, and it sits beside the arms it describes. `packages/domain`
|
|
1431
|
+
# holds no copy — `HostBackend` is any string, and `host-shell.ts` passes
|
|
1432
|
+
# through whatever this prints — so a third host is an edit here and nowhere in
|
|
1433
|
+
# the domain.
|
|
1434
|
+
HOST_DRIVES="github bitbucket"
|
|
1435
|
+
|
|
1436
|
+
# Is this a backend the ops below can actually drive?
|
|
1437
|
+
host_drivable() { # $1=backend word
|
|
1438
|
+
case " $HOST_DRIVES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
# The backend this repository declared, in the word it used.
|
|
1442
|
+
#
|
|
1443
|
+
# REPORTS WHAT IT WAS TOLD, AND DEFAULTS ONLY WHERE NOTHING WAS SAID. A repo
|
|
1444
|
+
# declaring `Git host: gitlab` was answered `github` until 2026-09-08 — the
|
|
1445
|
+
# unrecognised word was discarded and the default returned in its place, so a
|
|
1446
|
+
# GitLab team was driven through `gh` and every refusal named a host they had
|
|
1447
|
+
# not configured. An absent key still defaults to github, which is the same
|
|
1448
|
+
# answer for the same reason: nothing was said, so nothing was discarded.
|
|
1449
|
+
#
|
|
1450
|
+
# `bb` is an ALIAS rather than an unknown word, and it normalises to bitbucket.
|
|
1451
|
+
backend_declared() {
|
|
1352
1452
|
if [ -n "${PLOT_HOST:-}" ]; then
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
*) die "unknown PLOT_HOST '$PLOT_HOST' (github|bitbucket)" ;;
|
|
1356
|
-
esac
|
|
1453
|
+
printf '%s\n' "$PLOT_HOST" | tr '[:upper:]' '[:lower:]'
|
|
1454
|
+
return
|
|
1357
1455
|
fi
|
|
1358
1456
|
local v
|
|
1359
1457
|
v="$(bash "$here/plot-config.sh" get "Git host" "github" | tr '[:upper:]' '[:lower:]')"
|
|
1360
1458
|
case "$v" in
|
|
1361
|
-
|
|
1362
|
-
|
|
1459
|
+
bb) echo "bitbucket" ;;
|
|
1460
|
+
"") echo "github" ;;
|
|
1461
|
+
*) printf '%s\n' "$v" ;;
|
|
1363
1462
|
esac
|
|
1364
1463
|
}
|
|
1365
1464
|
|
|
1465
|
+
# The resolved backend, refused where this script has no arm for it.
|
|
1466
|
+
#
|
|
1467
|
+
# THE REFUSAL IS THIS LAYER'S, AND IT NAMES THE WORD. Every op below dispatches
|
|
1468
|
+
# on this answer, so a word with no arm must stop here rather than fall into
|
|
1469
|
+
# whichever branch happens to be last — that is how `gitlab` became a Bitbucket
|
|
1470
|
+
# call. Exit 4 says the question cannot be asked of this backend AT ALL, which
|
|
1471
|
+
# is what an unknown host is: not a broken call to retry, but a configuration a
|
|
1472
|
+
# person must fix. `host-shell.ts` reads that code as `unaskable` and reads the
|
|
1473
|
+
# sentence below for the name.
|
|
1474
|
+
backend() {
|
|
1475
|
+
local v
|
|
1476
|
+
v="$(backend_declared)" || return 1
|
|
1477
|
+
if ! host_drivable "$v"; then
|
|
1478
|
+
echo "plot-host: cannot drive '$v' — this script drives ${HOST_DRIVES// /, }; set the 'Git host' key in CLAUDE.md (or \$PLOT_HOST) to one of them" >&2
|
|
1479
|
+
return 4
|
|
1480
|
+
fi
|
|
1481
|
+
printf '%s\n' "$v"
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1366
1484
|
|
|
1367
1485
|
# --- the connector counts what it spends -----------------------------------
|
|
1368
1486
|
#
|
|
@@ -1802,7 +1920,11 @@ jen() {
|
|
|
1802
1920
|
|
|
1803
1921
|
op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
|
|
1804
1922
|
shift
|
|
1805
|
-
|
|
1923
|
+
# THE BACKEND'S REFUSAL IS PASSED THROUGH, NOT FLATTENED. `backend` exits 4 for
|
|
1924
|
+
# a host it has no arm for, and exit 4 is the contract's "this backend has no
|
|
1925
|
+
# answer at all" — the one code every caller reads as permanent rather than
|
|
1926
|
+
# transient. Collapsing it to 1 here would tell a GitLab repo to retry forever.
|
|
1927
|
+
be="$(backend)" || exit $?
|
|
1806
1928
|
|
|
1807
1929
|
# EVERY GITHUB OP CONSULTS THE ROUTER, ONCE, HERE. `gh_route` is asked before
|
|
1808
1930
|
# the op runs and its answer is read from `$route` by whichever arm needs it —
|
|
@@ -2161,7 +2283,11 @@ case "$op" in
|
|
|
2161
2283
|
jen_instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
|
|
2162
2284
|
[ -n "$jen_instance" ] || jen_instance="${JENKINS_INSTANCE:-}"
|
|
2163
2285
|
if [ -z "$jen_instance" ]; then
|
|
2164
|
-
|
|
2286
|
+
echo "plot-host: CI is jenkins but no Jenkins instance is configured" >&2
|
|
2287
|
+
echo " Add a 'Jenkins instance' key to the ## Plot Config section of CLAUDE.md," >&2
|
|
2288
|
+
echo " naming the instance \`jen\` knows, or set JENKINS_INSTANCE." >&2
|
|
2289
|
+
echo " Or drop the 'CI: jenkins' key to read build status from the git host." >&2
|
|
2290
|
+
exit 3
|
|
2165
2291
|
fi
|
|
2166
2292
|
jen_payload=$(jenkins_build_map "$jen_instance")
|
|
2167
2293
|
jen_status=$(printf '%s' "$jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
|
package/plot-worker-state.sh
CHANGED
|
@@ -754,3 +754,97 @@ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
|
|
|
754
754
|
# mistake in the other direction.
|
|
755
755
|
printf 'ended\t%s\t' "$pid"
|
|
756
756
|
}
|
|
757
|
+
|
|
758
|
+
# ---------------------------------------------------------------------------
|
|
759
|
+
# THE SAME READINGS, HANDED OUT RATHER THAN DECIDED
|
|
760
|
+
# ---------------------------------------------------------------------------
|
|
761
|
+
#
|
|
762
|
+
# `plot_worker_state` above gathers six facts and turns them into a word. So
|
|
763
|
+
# does `rules/agent-state.ts`, and `docs/shell-and-domain.md` says why both
|
|
764
|
+
# exist: this function is sourced inside per-branch loops and by the agent's own
|
|
765
|
+
# loop, where a `node` hop is 39 ms every caller pays on every pass, while the
|
|
766
|
+
# board and the supervisor are already in node and pay nothing.
|
|
767
|
+
#
|
|
768
|
+
# WHAT THE PAIR NEEDS IS THE READINGS, NOT THE WORD. A caller handed `finished`
|
|
769
|
+
# can only compare two strings; a caller handed the six facts can ask the domain
|
|
770
|
+
# rule and compare its answer to this file's. That is what the corpus test does,
|
|
771
|
+
# and it is why this function exists at all.
|
|
772
|
+
#
|
|
773
|
+
# ONE GATHERING, TWO CONSUMERS. Every fact below is read the way
|
|
774
|
+
# `plot_worker_state` reads it — the manifest first and the worktree file as the
|
|
775
|
+
# fallback, `plot_pid_is_current` for staleness, the same `PLOT-BLOCKED*` glob
|
|
776
|
+
# and the same dirty filter. A second gathering that drifted would make the
|
|
777
|
+
# corpus test compare this file against itself and pass while production broke.
|
|
778
|
+
#
|
|
779
|
+
# Prints one TAB-separated line, six fields:
|
|
780
|
+
#
|
|
781
|
+
# worktree_here pid_recorded liveness exit blocked dirty unpushed
|
|
782
|
+
#
|
|
783
|
+
# `liveness` is `live`, `stale` or `dead`; `exit` is the code as read, empty for
|
|
784
|
+
# an unreadable record and the literal `-` for an absent one, because an empty
|
|
785
|
+
# field cannot say which of the two it is and the rule answers them alike only
|
|
786
|
+
# because it was told they differ. The PR fact is NOT here: it comes from the
|
|
787
|
+
# caller, exactly as it does for `plot_worker_state`.
|
|
788
|
+
plot_worker_readings() { # $1=worktree → "here\tpid\tliveness\texit\tblocked\tdirty\tunpushed"
|
|
789
|
+
local wt="$1" pid="" started_at="" manifest_data="" manifest=""
|
|
790
|
+
local here=1 pid_recorded=0 liveness=dead exit_field='-' blocked=0 dirty=0 ahead=""
|
|
791
|
+
|
|
792
|
+
# NO WORKTREE IS ANSWERED FIRST, and it is a question about the worktree LIST
|
|
793
|
+
# rather than about anything inside one — the same split `worker_of` makes in
|
|
794
|
+
# `plot-fleet-scan.sh`, where `elsewhere` is decided before this file is
|
|
795
|
+
# reached. A caller iterating worktrees it found never sees this arm.
|
|
796
|
+
if [ -z "$wt" ] || [ ! -d "$wt" ]; then
|
|
797
|
+
printf '0\t0\tdead\t-\t0\t0\t'
|
|
798
|
+
return
|
|
799
|
+
fi
|
|
800
|
+
|
|
801
|
+
# THE MANIFEST IS PRIMARY, as above: it carries `pid` and `startedAt`
|
|
802
|
+
# together, and `startedAt` is what tells a reused pid from the real worker.
|
|
803
|
+
if manifest=$(plot_manifest_for_worktree "$wt" 2>/dev/null) && [ -n "$manifest" ]; then
|
|
804
|
+
if manifest_data=$(plot_read_manifest_pid "$manifest") && [ -n "$manifest_data" ]; then
|
|
805
|
+
pid=$(printf '%s' "$manifest_data" | cut -f1)
|
|
806
|
+
started_at=$(printf '%s' "$manifest_data" | cut -f2)
|
|
807
|
+
fi
|
|
808
|
+
fi
|
|
809
|
+
if [ -z "$pid" ] && [ -f "$wt/.plot-worker.pid" ]; then
|
|
810
|
+
pid=$(cat "$wt/.plot-worker.pid" 2>/dev/null | tr -d ' \n')
|
|
811
|
+
fi
|
|
812
|
+
|
|
813
|
+
# A pid of 0 and any non-numeric junk are NOT pids. `kill -0 0` signals the
|
|
814
|
+
# whole process group and succeeds, so a zero read as live reports `running`
|
|
815
|
+
# forever — rejected here exactly as `plot_worker_state` rejects it.
|
|
816
|
+
case "$pid" in
|
|
817
|
+
''|0|*[!0-9]*) pid_recorded=0 ;;
|
|
818
|
+
*) pid_recorded=1 ;;
|
|
819
|
+
esac
|
|
820
|
+
|
|
821
|
+
if [ "$pid_recorded" = 1 ]; then
|
|
822
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
823
|
+
# A recorded start time closes the pid-reuse window. Without one the old
|
|
824
|
+
# behaviour applies and `kill -0` alone decides, which keeps an
|
|
825
|
+
# uncheckable pid honest rather than pessimistic.
|
|
826
|
+
if [ -n "$started_at" ] && ! plot_pid_is_current "$pid" "$started_at"; then
|
|
827
|
+
liveness=stale
|
|
828
|
+
else
|
|
829
|
+
liveness=live
|
|
830
|
+
fi
|
|
831
|
+
fi
|
|
832
|
+
fi
|
|
833
|
+
|
|
834
|
+
# THE EXIT RECORD, distinguishing absent from unreadable. `plot_worker_state`
|
|
835
|
+
# reaches `ended` for both, but by different routes, and a reading that
|
|
836
|
+
# collapsed them would hide which one a desk is in from anybody comparing.
|
|
837
|
+
if [ -f "$wt/.plot-worker.exit" ]; then
|
|
838
|
+
exit_field=$(cat "$wt/.plot-worker.exit" 2>/dev/null | tr -d ' \n')
|
|
839
|
+
fi
|
|
840
|
+
|
|
841
|
+
plot_worker_blocked "$wt" && blocked=1 || blocked=0
|
|
842
|
+
[ -n "$(plot_worker_dirty "$wt")" ] && dirty=1 || dirty=0
|
|
843
|
+
# UNPUSHED IS A REF QUESTION asked THROUGH the worktree, and an unreadable
|
|
844
|
+
# count stays EMPTY — `null` is not `false`, and a branch with no upstream
|
|
845
|
+
# cannot be asked at all.
|
|
846
|
+
ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null) || ahead=""
|
|
847
|
+
|
|
848
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s' \
|
|
849
|
+
"$here" "$pid_recorded" "$liveness" "$exit_field" "$blocked" "$dirty" "$ahead"
|
|
850
|
+
}
|