autonomous-sdlc-harness 0.1.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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- package/dist/cli.js +194 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/config.js +561 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/daemon.js +791 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/doctor.js +336 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.js +2023 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/registry.js +42 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/config/check.js +505 -0
- package/dist/config/check.js.map +1 -0
- package/dist/config/io.js +177 -0
- package/dist/config/io.js.map +1 -0
- package/dist/config/model.js +406 -0
- package/dist/config/model.js.map +1 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +537 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/json.js +125 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/layerCoverage.js +141 -0
- package/dist/core/layerCoverage.js.map +1 -0
- package/dist/core/layerGapRemedy.js +62 -0
- package/dist/core/layerGapRemedy.js.map +1 -0
- package/dist/core/nameList.js +23 -0
- package/dist/core/nameList.js.map +1 -0
- package/dist/core/paths.js +153 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/prompt.js +206 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/repoPaths.js +55 -0
- package/dist/core/repoPaths.js.map +1 -0
- package/dist/core/report.js +150 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/templating.js +88 -0
- package/dist/core/templating.js.map +1 -0
- package/dist/core/writer.js +479 -0
- package/dist/core/writer.js.map +1 -0
- package/dist/daemon/backend.js +180 -0
- package/dist/daemon/backend.js.map +1 -0
- package/dist/daemon/units.js +380 -0
- package/dist/daemon/units.js.map +1 -0
- package/dist/detect/nestedApplication.js +79 -0
- package/dist/detect/nestedApplication.js.map +1 -0
- package/dist/detect/presets.js +2033 -0
- package/dist/detect/presets.js.map +1 -0
- package/dist/detect/signals.js +1368 -0
- package/dist/detect/signals.js.map +1 -0
- package/dist/doctor/checks.js +3530 -0
- package/dist/doctor/checks.js.map +1 -0
- package/dist/generators/claudeContext.js +588 -0
- package/dist/generators/claudeContext.js.map +1 -0
- package/dist/generators/githooks.js +446 -0
- package/dist/generators/githooks.js.map +1 -0
- package/dist/generators/harnessConfig.js +632 -0
- package/dist/generators/harnessConfig.js.map +1 -0
- package/dist/generators/notifications.js +191 -0
- package/dist/generators/notifications.js.map +1 -0
- package/dist/generators/outerLoopScripts.js +165 -0
- package/dist/generators/outerLoopScripts.js.map +1 -0
- package/dist/generators/permissionProfile.js +1172 -0
- package/dist/generators/permissionProfile.js.map +1 -0
- package/dist/generators/projectSettings.js +322 -0
- package/dist/generators/projectSettings.js.map +1 -0
- package/dist/generators/repoRoot.js +417 -0
- package/dist/generators/repoRoot.js.map +1 -0
- package/dist/generators/scripts.js +557 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/generators/stateDir.js +221 -0
- package/dist/generators/stateDir.js.map +1 -0
- package/dist/machine/paths.js +111 -0
- package/dist/machine/paths.js.map +1 -0
- package/dist/machine/plugins.js +224 -0
- package/dist/machine/plugins.js.map +1 -0
- package/dist/machine/registry.js +330 -0
- package/dist/machine/registry.js.map +1 -0
- package/package.json +23 -0
- package/scripts/README.md +13 -0
- package/scripts/daemon/launchd.plist.template +59 -0
- package/scripts/daemon/systemd.service.template +58 -0
- package/templates/README.md +15 -0
- package/templates/claude/CLAUDE.md +54 -0
- package/templates/claude/README.md +5 -0
- package/templates/claude/context/api.md +29 -0
- package/templates/claude/context/conventions.md +23 -0
- package/templates/claude/context/data-layer.md +28 -0
- package/templates/claude/context/data-storage.md +29 -0
- package/templates/claude/context/docs-catalog.md +29 -0
- package/templates/claude/context/domain.md +28 -0
- package/templates/claude/context/layer.md +20 -0
- package/templates/claude/context/module.md +30 -0
- package/templates/claude/context/package.md +29 -0
- package/templates/claude/context/presentation.md +32 -0
- package/templates/claude/context/state-slices.md +28 -0
- package/templates/claude/context/tests.md +28 -0
- package/templates/claude/harness-task-offer.md +58 -0
- package/templates/claude/push-notify.env.example +21 -0
- package/templates/claude/qa-accounts.env.example +38 -0
- package/templates/claude/qa_test_scenarios.md +110 -0
- package/templates/claude/settings.autonomous.json +93 -0
- package/templates/claude/settings.autonomous.qa.json +36 -0
- package/templates/githooks/README.md +3 -0
- package/templates/githooks/pre-push +72 -0
- package/templates/repo/README.md +3 -0
- package/templates/repo/gitattributes +16 -0
- package/templates/repo/gitignore +61 -0
- package/templates/repo/gitignore.qa +25 -0
- package/templates/repo/mcp.json +17 -0
- package/templates/scripts/README.md +5 -0
- package/templates/scripts/autonomous-format-stream.sh +95 -0
- package/templates/scripts/autonomous-notify.sh +337 -0
- package/templates/scripts/autonomous-watcher.sh +3087 -0
- package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
- package/templates/scripts/commit-on-branch.sh +288 -0
- package/templates/scripts/create-worktree.sh +360 -0
- package/templates/scripts/deploy.sh +47 -0
- package/templates/scripts/lib/harness-run-lib.sh +1481 -0
- package/templates/scripts/push-branch.sh +140 -0
- package/templates/scripts/refresh-branch.sh +244 -0
- package/templates/scripts/restart-watcher.sh +401 -0
- package/templates/scripts/scratch-run.sh +302 -0
- package/templates/scripts/setup-worktree.sh +262 -0
- package/templates/scripts/start-dev-server.sh +99 -0
- package/templates/scripts/test.sh +50 -0
- package/templates/scripts/typecheck.sh +50 -0
- package/templates/state-dir/README-root.md +13 -0
- package/templates/state-dir/README.md +9 -0
- package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
- package/templates/state-dir/architecture_reviews/README.md +9 -0
- package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
- package/templates/state-dir/autonomous_inbox/README.md +9 -0
- package/templates/state-dir/autonomous_logs/README.md +9 -0
- package/templates/state-dir/branch_statistics/README.md +9 -0
- package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
- package/templates/state-dir/clarification_digests/README.md +9 -0
- package/templates/state-dir/clarifications/README.md +9 -0
- package/templates/state-dir/code_reviews/README.md +9 -0
- package/templates/state-dir/dispatch_additions/README.md +19 -0
- package/templates/state-dir/docs_catalog/README.md +9 -0
- package/templates/state-dir/flow_progress/README.md +9 -0
- package/templates/state-dir/improvement_observations/README.md +19 -0
- package/templates/state-dir/improvement_suggestions.md +29 -0
- package/templates/state-dir/lessons.md +23 -0
- package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
- package/templates/state-dir/qa_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_reviews/README.md +9 -0
- package/templates/state-dir/scratch/README.md +11 -0
- package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_reviews/README.md +9 -0
- package/templates/state-dir/story_plans/README.md +9 -0
- package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/task_plan_reviews/README.md +9 -0
- package/templates/state-dir/task_plans/README.md +9 -0
- package/templates/state-dir/task_prompts/README.md +9 -0
- package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
- package/templates/state-dir/ui_test_plans/README.md +9 -0
- package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/user_reviews/README.md +9 -0
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# harness-run-lib.sh — the one place every generated outer-loop script resolves
|
|
3
|
+
# the repository it is operating on, reads that repository's
|
|
4
|
+
# `harness.config.json` at run time, answers "is this branch protected?", and
|
|
5
|
+
# derives the anchors (main checkout, work root, worktree directory, repo slug,
|
|
6
|
+
# state-dir paths) the scripts would otherwise each re-derive slightly
|
|
7
|
+
# differently.
|
|
8
|
+
#
|
|
9
|
+
# WHO SOURCES THIS, AND HOW. Every script in the configured `scriptsDir` that
|
|
10
|
+
# needs this library sources it by a path computed from `${BASH_SOURCE[0]}` —
|
|
11
|
+
# the sourcing script's own location — i.e.
|
|
12
|
+
#
|
|
13
|
+
# . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/harness-run-lib.sh"
|
|
14
|
+
#
|
|
15
|
+
# and never by a hardcoded path, an assumed session root, or the plugin-root
|
|
16
|
+
# token the runtime substitutes into hook `command` strings and agent bodies:
|
|
17
|
+
# that token is not exported into a script's environment, so a script body that
|
|
18
|
+
# reaches for it gets an empty string under `set -u` or an unset variable
|
|
19
|
+
# without it. (It is deliberately not spelled out anywhere in this file; its
|
|
20
|
+
# literal presence here is exactly the defect this paragraph prevents.) The
|
|
21
|
+
# project-command wrappers and `autonomous-format-stream.sh` need nothing from
|
|
22
|
+
# this library and do not source it.
|
|
23
|
+
#
|
|
24
|
+
# ONE SCRIPT MUST NOT USE THAT SPELLING, AND THE REASON GENERALISES.
|
|
25
|
+
# `autonomous-watcher.sh` sources this file BEFORE it settles `PATH`, because its
|
|
26
|
+
# bootstrap calls `hr_path_with_fallbacks` below; `dirname` is not a builtin, so
|
|
27
|
+
# on a service-manager unit that renders no `PATH` key the fork may not resolve
|
|
28
|
+
# and the start fails at the one step that cannot afford a lookup. It resolves
|
|
29
|
+
# its own directory from `${BASH_SOURCE[0]}` with a `case` strip plus the `cd`
|
|
30
|
+
# and `pwd` builtins instead. ANY script that sources this library ahead of its
|
|
31
|
+
# own `PATH` settle owes the same builtin-only form; every other script settles
|
|
32
|
+
# `PATH` first (or is never started by a service manager) and keeps the `dirname`
|
|
33
|
+
# idiom above.
|
|
34
|
+
#
|
|
35
|
+
# IT IS DELIBERATELY NOT THE PLUGIN'S LIBRARY. The guard hooks have their own
|
|
36
|
+
# copy of this shape. The plugin and this package install to unrelated roots, so
|
|
37
|
+
# neither can source the other's, and a script that tried would work on the
|
|
38
|
+
# machine that wrote it and nowhere else. The two files are kept in step by
|
|
39
|
+
# their shared contract — the three-state answer, the protected set and the
|
|
40
|
+
# floors below — not by sharing code.
|
|
41
|
+
#
|
|
42
|
+
# JURISDICTION. Configuration is read at `<repo_root>/harness.config.json` and
|
|
43
|
+
# nowhere else, where `<repo_root>` is resolved from a bare
|
|
44
|
+
# `git rev-parse --show-toplevel` at the directory the caller names. Nothing
|
|
45
|
+
# here reads an environment variable in place of a configured value, and nothing
|
|
46
|
+
# here writes anything inside a repository.
|
|
47
|
+
#
|
|
48
|
+
# THE ONE EXCEPTION TO "WRITES NOTHING", AND ITS FENCE: the machine-level usage
|
|
49
|
+
# lane at the bottom of this file publishes a state record and takes an advisory
|
|
50
|
+
# lock. Both live under `hr_lane_dir` — a machine-local path outside every
|
|
51
|
+
# repository — and nothing else here writes at all, so a caller that never calls
|
|
52
|
+
# an `hr_lane_*` function still gets a library that only reads. The lane's
|
|
53
|
+
# ceilings are the only environment values here that carry policy, because the
|
|
54
|
+
# lane is machine-scoped and has no configuration key to carry them; each is
|
|
55
|
+
# named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `HOME` and `PWD`
|
|
56
|
+
# are also read, as location anchors only, and `PATH` is read by
|
|
57
|
+
# `hr_path_with_fallbacks` alone — as that function's input, which it prints back
|
|
58
|
+
# transformed and never assigns.
|
|
59
|
+
#
|
|
60
|
+
# CONFIGURATION IS READ AT RUN TIME, NOT FROZEN AT GENERATION TIME. That is the
|
|
61
|
+
# whole reason the shipped scripts carry no `{{token}}`: a guard whose protected
|
|
62
|
+
# set was substituted into a file when `init` ran enforces the wrong set the
|
|
63
|
+
# moment `protectedBranches` changes, and the failure is silent.
|
|
64
|
+
#
|
|
65
|
+
# THE THREE-STATE ANSWER — the reason this library exists.
|
|
66
|
+
# `hr_branch_is_protected` returns:
|
|
67
|
+
#
|
|
68
|
+
# 0 protected — the branch matched a pattern in the resolved set
|
|
69
|
+
# 1 not protected — the set was resolved and the branch is not in it
|
|
70
|
+
# 2 unresolvable — the configuration could not be resolved at all
|
|
71
|
+
# (no `harness.config.json`, an unreadable one, absent
|
|
72
|
+
# or pre-1.5 `jq`, invalid JSON, more than one JSON
|
|
73
|
+
# document, or the required `defaultBranch` missing)
|
|
74
|
+
#
|
|
75
|
+
# A CONSUMER THAT TREATS 2 AS 1 TAKES A MUTATING ACTION ON A CONFIGURATION IT
|
|
76
|
+
# COULD NOT READ. Every caller therefore has a closed outcome for 2 and states
|
|
77
|
+
# it in its own header: the commit wrapper refuses loudly (non-zero), the push
|
|
78
|
+
# wrapper refuses visibly but non-fatally (exit 0 with a message, because its
|
|
79
|
+
# never-abort-the-caller contract is load-bearing), the watcher logs and defers.
|
|
80
|
+
#
|
|
81
|
+
# THE SAME THREE STATES REACH EVERY TYPED READER: `hr_state_dir`,
|
|
82
|
+
# `hr_command`, `hr_project_name` and the rest print their value and return 0,
|
|
83
|
+
# return 1 when the key is simply not set (and has no schema default to apply),
|
|
84
|
+
# and return 2 when the configuration could not be read. A caller must be able
|
|
85
|
+
# to tell "the key is empty" from "the file could not be read", because the
|
|
86
|
+
# first is an ordinary project and the second is a repository this script has no
|
|
87
|
+
# business acting in. A reader that wants the finer distinction between "no
|
|
88
|
+
# `harness.config.json` at all" and "one that would not parse" calls
|
|
89
|
+
# `hr_config_load` directly, which returns 1 for the first and 2 for the second.
|
|
90
|
+
#
|
|
91
|
+
# THE PROTECTED SET. It is *(`protectedBranches` if present, else that key's
|
|
92
|
+
# schema default)* ∪ *{`defaultBranch`}*, matched as `case` globs so one
|
|
93
|
+
# `release/*` entry covers its namespace. `defaultBranch` is a required key, so
|
|
94
|
+
# its absence is an unresolvable configuration rather than a defaultable one; a
|
|
95
|
+
# `protectedBranches` present but EMPTY is a configured set, so it yields
|
|
96
|
+
# `{defaultBranch}` and not the schema default. There is no built-in matcher: a
|
|
97
|
+
# repository whose default branch is `trunk` and whose list is
|
|
98
|
+
# `["trunk","release/*"]` has every other name as an ordinary branch, and this
|
|
99
|
+
# library says so.
|
|
100
|
+
#
|
|
101
|
+
# THE ONE BRANCH NAME IN THIS FILE IS `protectedBranches`'s SCHEMA DEFAULT, and
|
|
102
|
+
# it is not the hardcoding the rule above is about. It is spelled exactly once,
|
|
103
|
+
# in `hr_protected_default_var`, mirroring `schemas/harness.config.schema.json`
|
|
104
|
+
# and the same default the CLI applies when it generates the committed pre-push
|
|
105
|
+
# hook — so for one configuration the hook and this library RESOLVE the same
|
|
106
|
+
# set. They can still ENFORCE different sets, because they differ in when they
|
|
107
|
+
# resolve it: this library re-reads `harness.config.json` on every call, while
|
|
108
|
+
# the hook is written create-if-absent and carries the set substituted into its
|
|
109
|
+
# `case` label when `init` wrote it. An edit to `protectedBranches` therefore
|
|
110
|
+
# binds a wrapper at once and binds the hook only after `init --force`, or
|
|
111
|
+
# after deleting the hook and re-running `init`. A third run re-renders the
|
|
112
|
+
# hook as a CONSEQUENCE rather than as a way to change the set: `init
|
|
113
|
+
# --reset-config` rebuilds `harness.config.json` itself, and re-renders the
|
|
114
|
+
# hook from the rebuilt file when the `case` label the hook carried no longer
|
|
115
|
+
# matches the set that file resolves — after a `.bak`, and never when the label
|
|
116
|
+
# cannot be read. It is not a route to reach for deliberately: the rebuild
|
|
117
|
+
# takes the whole config from detection and the flags, so every value that was
|
|
118
|
+
# set by hand in the file it replaces goes with it. Which resolution is
|
|
119
|
+
# authoritative follows the caller: a push is judged by the `case` label in the
|
|
120
|
+
# hook file, because git runs that file, and a wrapper is judged by what this
|
|
121
|
+
# library returns. The default is only ever UNIONED with the configured set,
|
|
122
|
+
# never a replacement for it, so it can widen the refusal and can never narrow
|
|
123
|
+
# one. The defect it must never become is a `case` list of remembered names
|
|
124
|
+
# that replaces what the adopter configured: that leaves a repository whose
|
|
125
|
+
# integration branch is named anything else with no protection at all, which is
|
|
126
|
+
# a wrapper committing or pushing where it should have refused. `init` writes
|
|
127
|
+
# `protectedBranches` on every adoption, so this default is reached only by a
|
|
128
|
+
# configuration somebody hand-edited the key out of.
|
|
129
|
+
#
|
|
130
|
+
# FILE DISCIPLINE. Sourced, never executed (mode 0644; the shebang above is a
|
|
131
|
+
# dialect marker for editors and linters). No `set -e` and no `set -u` — a
|
|
132
|
+
# sourced library must not change its caller's shell — but every parameter
|
|
133
|
+
# expansion here is defaulted, so it is safe to source into a caller that sets
|
|
134
|
+
# both. No top-level side effects, no exiting, no writes outside the lane
|
|
135
|
+
# directory named above, and no diagnostics on stdout OR stderr: every reader is
|
|
136
|
+
# silent on failure and signals through its return status, because callers
|
|
137
|
+
# capture stdout. That silence is why the lane reports a lock it BROKE through a
|
|
138
|
+
# variable instead of a log line — the caller owns the log.
|
|
139
|
+
#
|
|
140
|
+
# NAMING. Every function is prefixed `hr_`; every variable this file touches
|
|
141
|
+
# outside a `local` is prefixed `HR_`. The `HR_`-prefixed ones exist to return a
|
|
142
|
+
# value WITHOUT a command substitution — a `$(…)` forks a subshell, and the
|
|
143
|
+
# watcher calls these on every tick: `HR_CFG_PID`, `HR_CFG_ROOT`,
|
|
144
|
+
# `HR_CFG_STATE`, `HR_CFG_FILE`, `HR_CFG_SCALARS`, `HR_CFG_LISTS`,
|
|
145
|
+
# `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`, and the lane's
|
|
146
|
+
# `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
|
|
147
|
+
# `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
|
|
148
|
+
# `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`. Every one of them is assigned
|
|
149
|
+
# before it is read by the function that owns it, so an inherited value from a
|
|
150
|
+
# parent process is overwritten rather than believed.
|
|
151
|
+
#
|
|
152
|
+
# BASH 3.2 IS THE FLOOR. macOS ships `/bin/bash` 3.2, so nothing here uses an
|
|
153
|
+
# associative array, `${var^^}` / `${var,,}`, `mapfile` or `local -n`. The cache
|
|
154
|
+
# below is a string-record store for exactly that reason, and the one
|
|
155
|
+
# case-folding step forks `tr` rather than reaching for a 4.x expansion.
|
|
156
|
+
#
|
|
157
|
+
# JQ 1.5 IS THE FLOOR, AND AN OLDER `jq` IS UNRESOLVABLE, NOT ABSENT.
|
|
158
|
+
# `hr_config_load`'s program uses five constructs that are all jq 1.5 additions:
|
|
159
|
+
# `input` / `inputs` (the multi-document refusal), `@tsv` (the record format),
|
|
160
|
+
# `try … catch`, `error("…")` and the `def s($k; $v)` value-parameter form. On an
|
|
161
|
+
# older `jq` the program is a COMPILE error, so the load fails and every reader
|
|
162
|
+
# returns 2 — the closed path — while `command -v jq` still succeeds and says
|
|
163
|
+
# nothing. That is why `doctor` checks the version rather than the binary. If
|
|
164
|
+
# you add a construct here, check it against 1.5 or raise this floor in both
|
|
165
|
+
# places.
|
|
166
|
+
#
|
|
167
|
+
# THE CACHE IS PER PROCESS, SO A LONG-LIVED CALLER MUST RESET IT PER TICK.
|
|
168
|
+
# `hr_config_load` runs ONE `jq` and holds the result in shell variables, so a
|
|
169
|
+
# script that reads a dozen keys forks `jq` once instead of a dozen times. The
|
|
170
|
+
# entry is keyed by root and stamped with `$$`, never by the file's contents:
|
|
171
|
+
# a caller that outlives an edit to `harness.config.json` — the watcher, which
|
|
172
|
+
# runs for days — is answered from the document it replaced until it calls
|
|
173
|
+
# `hr_config_reset`. THE WATCHER CALLS IT ONCE AT THE TOP OF EVERY TICK; a
|
|
174
|
+
# one-shot wrapper never needs to. A `$(…)` runs in a SUBSHELL, which inherits
|
|
175
|
+
# the cache but cannot write one back, so a caller that reads only through
|
|
176
|
+
# command substitutions loads the document once per read: call
|
|
177
|
+
# `hr_config_load "$root" || :` once, unsubstituted, to warm it. Forgetting that
|
|
178
|
+
# line is slower and still correct.
|
|
179
|
+
#
|
|
180
|
+
# REPRO — reproduce any answer by hand, against a throwaway fixture:
|
|
181
|
+
#
|
|
182
|
+
# . <repo>/<scripts_dir>/lib/harness-run-lib.sh
|
|
183
|
+
# root=$(git -C <dir> rev-parse --show-toplevel)
|
|
184
|
+
#
|
|
185
|
+
# adopted + valid hr_protected_patterns "$root"
|
|
186
|
+
# hr_branch_is_protected "$root" "$(hr_current_branch "$root")"; echo $?
|
|
187
|
+
# -> the resolved set, then 0 or 1
|
|
188
|
+
# hr_state_dir "$root"; hr_command "$root" test
|
|
189
|
+
# adopted + broken printf 'x' > "$root/harness.config.json"
|
|
190
|
+
# hr_branch_is_protected "$root" <branch>; echo $? -> 2
|
|
191
|
+
# hr_state_dir "$root"; echo $? -> 2, prints nothing
|
|
192
|
+
# not adopted mv "$root/harness.config.json" "$root/../saved.json"
|
|
193
|
+
# hr_config_file "$root"; echo $? -> 1, prints nothing
|
|
194
|
+
# hr_config_load "$root"; echo $? -> 1
|
|
195
|
+
# not a repository hr_repo_root /tmp; echo $? -> 1, prints nothing
|
|
196
|
+
# anchors hr_main_repo "$root"; hr_work_root "$root"
|
|
197
|
+
# hr_worktree_dir "$root" feat/x; hr_repo_slug "$root"
|
|
198
|
+
# hr_state_path "$root" autonomous_logs/registry.json
|
|
199
|
+
# the PATH policy run each in a SUBSHELL, so your own PATH is untouched:
|
|
200
|
+
# ( PATH="$HOME/.rbenv/shims:/usr/bin:/bin"
|
|
201
|
+
# hr_path_with_fallbacks )
|
|
202
|
+
# -> the shims entry STILL FIRST and `/usr/bin:/bin` still
|
|
203
|
+
# after it, then the five absent fallbacks appended in
|
|
204
|
+
# list order:
|
|
205
|
+
# /opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin:$HOME/.local/bin
|
|
206
|
+
# ( PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
|
207
|
+
# hr_path_with_fallbacks )
|
|
208
|
+
# -> that value, then the three absent ones —
|
|
209
|
+
# /opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin —
|
|
210
|
+
# which is how `jq`, a Homebrew toolchain and an agent CLI
|
|
211
|
+
# under `~/.local/bin` stay resolvable from a bare
|
|
212
|
+
# service-manager PATH, none of them being in `/usr/bin`.
|
|
213
|
+
# A Homebrew copy of a name `/usr/bin` DOES hold (`ruby`,
|
|
214
|
+
# `python3`, `curl`, `git`) is reached but no longer
|
|
215
|
+
# preferred, which is the price of never demoting the
|
|
216
|
+
# caller's order.
|
|
217
|
+
# the lane point it somewhere disposable first, so a live daemon's
|
|
218
|
+
# lane is not what you experiment on:
|
|
219
|
+
# export XDG_STATE_HOME=$(mktemp -d)
|
|
220
|
+
# hr_lane_dir; hr_lane_read -> the dir, `unknown 0`
|
|
221
|
+
# hr_lane_publish repo-a feat/x warning $(( $(date +%s) + 600 ))
|
|
222
|
+
# hr_lane_read -> `warning <epoch>`
|
|
223
|
+
# hr_lane_publish repo-b feat/y allowed 0; hr_lane_read
|
|
224
|
+
# -> STILL `warning <epoch>`: worst-wins, and the stored
|
|
225
|
+
# record is not spent yet
|
|
226
|
+
# hr_lane_acquire repo-a; echo $? -> 0
|
|
227
|
+
# hr_lane_owner -> `repo-a <pid> <epoch>`
|
|
228
|
+
# hr_lane_acquire repo-b; echo $? -> 1 (a LIVE foreign owner)
|
|
229
|
+
# hr_lane_release repo-b; echo $? -> 1 (never ours to release)
|
|
230
|
+
# hr_lane_release repo-a; hr_lane_owner; echo $? -> 1, free
|
|
231
|
+
# a stale lane hr_lane_acquire repo-a
|
|
232
|
+
# printf 'repo-a 999999 1\n' > \
|
|
233
|
+
# "$(hr_lane_dir)/run-lane.lock/owner"
|
|
234
|
+
# hr_lane_acquire repo-b; echo $? -> 0, and
|
|
235
|
+
# echo "$HR_LANE_BROKEN_OWNER" -> names repo-a 999999
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# The PATH policy — the one function here that runs BEFORE anything else works.
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# Print — never export, never assign — a `PATH` that keeps everything the caller
|
|
242
|
+
# inherited and adds the usual locations it is missing. Takes no arguments,
|
|
243
|
+
# reads `PATH` and `HOME` from the environment, always returns 0.
|
|
244
|
+
#
|
|
245
|
+
# IT APPENDS; IT NEVER PROMOTES AND NEVER DEMOTES. Each fallback directory is
|
|
246
|
+
# added to a SUFFIX only when it is absent from the inherited `PATH`, and the
|
|
247
|
+
# suffix goes AFTER the inherited value. A directory already there keeps its
|
|
248
|
+
# inherited position and is never re-added, and the relative order of everything
|
|
249
|
+
# the caller inherited is unchanged — whatever the caller put first stays first.
|
|
250
|
+
# Prepending, even prepending only what is absent, inserts a directory ahead of a
|
|
251
|
+
# version-manager shims directory the caller deliberately put first, and the tool
|
|
252
|
+
# that then resolves is the system one.
|
|
253
|
+
#
|
|
254
|
+
# IT NAMES NO TOOLCHAIN. The list is locations, not languages: which toolchain a
|
|
255
|
+
# repository needs is its own configuration's business.
|
|
256
|
+
#
|
|
257
|
+
# BUILTIN-ONLY, AND THAT IS THE CONTRACT, NOT AN OPTIMIZATION. The caller runs
|
|
258
|
+
# this to MAKE `PATH` usable, so every step is a `case`, a parameter expansion or
|
|
259
|
+
# `printf` — an external command here would be the failure this function exists
|
|
260
|
+
# to prevent.
|
|
261
|
+
#
|
|
262
|
+
# NO EMPTY ENTRY SURVIVES. An empty `PATH` element means the current directory,
|
|
263
|
+
# and callers of this are daemons, so a leading, trailing or doubled colon in the
|
|
264
|
+
# inherited value is dropped (order and duplicates otherwise untouched). With an
|
|
265
|
+
# unset or empty `PATH` the result is the fallback list alone; with an unset or
|
|
266
|
+
# empty `HOME` the `$HOME/.local/bin` entry is skipped rather than rendered as a
|
|
267
|
+
# bare `/.local/bin`.
|
|
268
|
+
hr_path_with_fallbacks() {
|
|
269
|
+
local inherited="${PATH-}" home="${HOME-}" kept="" suffix="" rest entry dir
|
|
270
|
+
rest="$inherited"
|
|
271
|
+
while [ -n "$rest" ]; do
|
|
272
|
+
entry=${rest%%:*}
|
|
273
|
+
if [ "$entry" = "$rest" ]; then rest=""; else rest=${rest#*:}; fi
|
|
274
|
+
[ -n "$entry" ] || continue
|
|
275
|
+
if [ -n "$kept" ]; then kept="$kept:$entry"; else kept="$entry"; fi
|
|
276
|
+
done
|
|
277
|
+
|
|
278
|
+
# Unquoted on purpose: these six are fixed literals with no space among them.
|
|
279
|
+
# `$HOME/.local/bin` is NOT in this list — a home directory can contain a
|
|
280
|
+
# space, and the entry is conditional — so it is handled after the loop, which
|
|
281
|
+
# is also where the policy's order puts it.
|
|
282
|
+
for dir in /opt/homebrew/bin /usr/local/bin /usr/bin /bin /usr/sbin /sbin; do
|
|
283
|
+
case ":$kept:" in *":$dir:"*) continue ;; esac
|
|
284
|
+
case ":$suffix:" in *":$dir:"*) continue ;; esac
|
|
285
|
+
if [ -n "$suffix" ]; then suffix="$suffix:$dir"; else suffix="$dir"; fi
|
|
286
|
+
done
|
|
287
|
+
|
|
288
|
+
if [ -n "$home" ]; then
|
|
289
|
+
dir="${home%/}/.local/bin"
|
|
290
|
+
case ":$kept:" in
|
|
291
|
+
*":$dir:"*) ;;
|
|
292
|
+
*)
|
|
293
|
+
if [ -n "$suffix" ]; then suffix="$suffix:$dir"; else suffix="$dir"; fi
|
|
294
|
+
;;
|
|
295
|
+
esac
|
|
296
|
+
fi
|
|
297
|
+
|
|
298
|
+
if [ -n "$kept" ] && [ -n "$suffix" ]; then
|
|
299
|
+
printf '%s\n' "$kept:$suffix"
|
|
300
|
+
elif [ -n "$kept" ]; then
|
|
301
|
+
printf '%s\n' "$kept"
|
|
302
|
+
else
|
|
303
|
+
printf '%s\n' "$suffix"
|
|
304
|
+
fi
|
|
305
|
+
return 0
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Resolution — the repository, its main checkout, and the directory holding
|
|
310
|
+
# both. Anchors are DERIVED, never remembered: the script this library is
|
|
311
|
+
# sourced into may be running in the main checkout or in any sibling worktree,
|
|
312
|
+
# and a fixed `dirname $0` walk only works for one repository layout.
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
# 0 when `jq` is on PATH. Its VERSION is not tested here — see the jq floor in
|
|
316
|
+
# the header: a pre-1.5 `jq` fails the load instead, which is the closed path.
|
|
317
|
+
hr_have_jq() {
|
|
318
|
+
command -v jq >/dev/null 2>&1
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Print the work-tree root of the repository containing <dir> (default `$PWD`);
|
|
322
|
+
# return 1, printing nothing, when <dir> is not inside a repository or `git` is
|
|
323
|
+
# not on PATH.
|
|
324
|
+
#
|
|
325
|
+
# The probe is issued BARE — the literal `rev-parse --show-toplevel`, with
|
|
326
|
+
# nothing added — because an unattended run's permission profile allow-lists it
|
|
327
|
+
# in that exact spelling, and a flag appended to it is a different string that
|
|
328
|
+
# stalls on a prompt.
|
|
329
|
+
hr_repo_root() {
|
|
330
|
+
local dir="${1-}" top
|
|
331
|
+
[ -n "$dir" ] || dir="${PWD-}"
|
|
332
|
+
[ -n "$dir" ] || dir="."
|
|
333
|
+
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || return 1
|
|
334
|
+
[ -n "$top" ] || return 1
|
|
335
|
+
printf '%s\n' "$top"
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
# Print the MAIN checkout of the repository containing <dir> (default `$PWD`) —
|
|
339
|
+
# the first entry of `git worktree list --porcelain`, which git documents as the
|
|
340
|
+
# main working tree. Return 1, printing nothing, when the probe does not answer.
|
|
341
|
+
#
|
|
342
|
+
# WHY THE MAIN CHECKOUT IS A SEPARATE ANCHOR: the inbox, the logs, the registry
|
|
343
|
+
# and the kill switch live in ONE place so every run is tailable and stoppable
|
|
344
|
+
# from it, while a run itself executes in a sibling worktree. A script that used
|
|
345
|
+
# its own checkout for both would write a second, invisible inbox per worktree.
|
|
346
|
+
hr_main_repo() {
|
|
347
|
+
local dir="${1-}" out line
|
|
348
|
+
[ -n "$dir" ] || dir="${PWD-}"
|
|
349
|
+
[ -n "$dir" ] || dir="."
|
|
350
|
+
out=$(git -C "$dir" worktree list --porcelain 2>/dev/null) || return 1
|
|
351
|
+
[ -n "$out" ] || return 1
|
|
352
|
+
while IFS= read -r line; do
|
|
353
|
+
case "$line" in
|
|
354
|
+
"worktree "*)
|
|
355
|
+
line=${line#worktree }
|
|
356
|
+
[ -n "$line" ] || return 1
|
|
357
|
+
printf '%s\n' "$line"
|
|
358
|
+
return 0
|
|
359
|
+
;;
|
|
360
|
+
esac
|
|
361
|
+
done <<EOF
|
|
362
|
+
$out
|
|
363
|
+
EOF
|
|
364
|
+
return 1
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# Print the directory above <repo_root> — where sibling worktrees live. Matches
|
|
368
|
+
# the CLI's `workRoot()` (the repository root's parent) byte for byte, because
|
|
369
|
+
# the generated permission profile is materialized from that one.
|
|
370
|
+
hr_work_root() {
|
|
371
|
+
local root="${1-}" parent
|
|
372
|
+
[ -n "$root" ] || return 1
|
|
373
|
+
while [ "$root" != "/" ] && [ "${root%/}" != "$root" ]; do root=${root%/}; done
|
|
374
|
+
parent=${root%/*}
|
|
375
|
+
[ -n "$parent" ] || parent="/"
|
|
376
|
+
printf '%s\n' "$parent"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Print the repository directory's own name — the value `projectName` defaults
|
|
380
|
+
# to, and the CLI's `defaultProjectName()`.
|
|
381
|
+
hr_default_project_name() {
|
|
382
|
+
local root="${1-}"
|
|
383
|
+
[ -n "$root" ] || return 1
|
|
384
|
+
while [ "$root" != "/" ] && [ "${root%/}" != "$root" ]; do root=${root%/}; done
|
|
385
|
+
root=${root##*/}
|
|
386
|
+
[ -n "$root" ] || return 1
|
|
387
|
+
printf '%s\n' "$root"
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
# Configuration — one `jq` per process (see the cache note in the header), read
|
|
392
|
+
# at the resolved repository root and nowhere else.
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
# Set `HR_CFG_FILE` to the configuration path and return 0 when the repository
|
|
396
|
+
# has adopted the harness; return 1 (clearing it) when it has not. The variable
|
|
397
|
+
# form exists so the readers can run the jurisdiction test without a `$(…)`.
|
|
398
|
+
hr_config_file_var() {
|
|
399
|
+
local root="${1-}" f
|
|
400
|
+
HR_CFG_FILE=""
|
|
401
|
+
[ -n "$root" ] || return 1
|
|
402
|
+
f="${root%/}/harness.config.json"
|
|
403
|
+
[ -f "$f" ] && [ -r "$f" ] || return 1
|
|
404
|
+
HR_CFG_FILE="$f"
|
|
405
|
+
return 0
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# Print the configuration path when the repository has adopted the harness;
|
|
409
|
+
# return 1 (printing nothing) when it has not. This is the jurisdiction test —
|
|
410
|
+
# "no such file" and "a file this account may not read" are one answer here,
|
|
411
|
+
# and `hr_config_load` is what separates them.
|
|
412
|
+
hr_config_file() {
|
|
413
|
+
hr_config_file_var "${1-}" || return 1
|
|
414
|
+
printf '%s\n' "$HR_CFG_FILE"
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Drop the cache. A one-shot wrapper never needs this; the watcher calls it at
|
|
418
|
+
# the top of every tick, so an edit to `harness.config.json` is picked up
|
|
419
|
+
# without restarting the daemon.
|
|
420
|
+
hr_config_reset() {
|
|
421
|
+
HR_CFG_PID=""
|
|
422
|
+
HR_CFG_ROOT=""
|
|
423
|
+
HR_CFG_STATE=""
|
|
424
|
+
HR_CFG_FILE=""
|
|
425
|
+
HR_CFG_SCALARS=""
|
|
426
|
+
HR_CFG_LISTS=""
|
|
427
|
+
HR_CFG_VALUE=""
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# Reverse `@tsv`'s escaping into `HR_CFG_VALUE`. Left-to-right, one backslash at
|
|
431
|
+
# a time, so `\\t` decodes to a literal backslash followed by `t` rather than to
|
|
432
|
+
# a tab. Returns immediately on the overwhelmingly common backslash-free value.
|
|
433
|
+
hr_tsv_unescape_var() {
|
|
434
|
+
local s="${1-}" out="" head c
|
|
435
|
+
HR_CFG_VALUE="$s"
|
|
436
|
+
case "$s" in *\\*) ;; *) return 0 ;; esac
|
|
437
|
+
while :; do
|
|
438
|
+
head=${s%%\\*}
|
|
439
|
+
if [ "$head" = "$s" ]; then
|
|
440
|
+
out="$out$s"
|
|
441
|
+
break
|
|
442
|
+
fi
|
|
443
|
+
out="$out$head"
|
|
444
|
+
s=${s#"$head"\\}
|
|
445
|
+
c=${s%"${s#?}"}
|
|
446
|
+
case "$c" in
|
|
447
|
+
t) out="$out"$'\t' ;;
|
|
448
|
+
n) out="$out
|
|
449
|
+
" ;;
|
|
450
|
+
r) out="$out"$'\r' ;;
|
|
451
|
+
\\) out="$out\\" ;;
|
|
452
|
+
'') out="$out\\"; break ;;
|
|
453
|
+
*) out="$out\\$c" ;;
|
|
454
|
+
esac
|
|
455
|
+
s=${s#?}
|
|
456
|
+
done
|
|
457
|
+
HR_CFG_VALUE="$out"
|
|
458
|
+
return 0
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
# Load (or reuse) the parsed configuration for <root>.
|
|
462
|
+
#
|
|
463
|
+
# 0 loaded — the document is present, parses as an object, and
|
|
464
|
+
# carries the required `defaultBranch`
|
|
465
|
+
# 1 not adopted — there is no `harness.config.json` at <root>
|
|
466
|
+
# 2 unresolvable — it is there and could not be turned into an answer:
|
|
467
|
+
# unreadable, absent or pre-1.5 `jq`, invalid JSON, more
|
|
468
|
+
# than one JSON document, not an object, or no
|
|
469
|
+
# `defaultBranch`
|
|
470
|
+
#
|
|
471
|
+
# THE 1/2 SPLIT IS THE POINT. "Not adopted" is a repository this script was
|
|
472
|
+
# never meant to run in; "unresolvable" is the repository it WAS meant to run in
|
|
473
|
+
# with a configuration it cannot read. Both are closed for a mutating caller,
|
|
474
|
+
# and they need different messages.
|
|
475
|
+
#
|
|
476
|
+
# A MEMO THIS PROCESS DID NOT WRITE IS IGNORED. `HR_CFG_*` are ordinary shell
|
|
477
|
+
# variables and a child inherits its parent's exported ones, so an inherited
|
|
478
|
+
# `HR_CFG_SCALARS` would otherwise be read as this repository's configuration
|
|
479
|
+
# and the file on disk never opened — an environment that dictates
|
|
480
|
+
# `defaultBranch` and `protectedBranches` would turn a refusal into a permit.
|
|
481
|
+
# The memo is stamped with `$$` and reused only when the stamp is this shell's
|
|
482
|
+
# own. `$$` is unchanged inside `$(…)` and `( )`, so the inherit-into-a-subshell
|
|
483
|
+
# property the warm-up rests on is untouched.
|
|
484
|
+
hr_config_load() {
|
|
485
|
+
local root="${1-}" f out line tag rest nl tab
|
|
486
|
+
[ -n "$root" ] || return 2
|
|
487
|
+
|
|
488
|
+
if [ "${HR_CFG_PID-}" = "$$" ] && [ "${HR_CFG_ROOT-}" = "$root" ] && [ -n "${HR_CFG_STATE-}" ]; then
|
|
489
|
+
case "${HR_CFG_STATE-}" in
|
|
490
|
+
ok) return 0 ;;
|
|
491
|
+
absent) return 1 ;;
|
|
492
|
+
*) return 2 ;;
|
|
493
|
+
esac
|
|
494
|
+
fi
|
|
495
|
+
|
|
496
|
+
hr_config_reset
|
|
497
|
+
HR_CFG_PID=$$
|
|
498
|
+
HR_CFG_ROOT="$root"
|
|
499
|
+
|
|
500
|
+
f="${root%/}/harness.config.json"
|
|
501
|
+
if [ ! -e "$f" ]; then
|
|
502
|
+
HR_CFG_STATE="absent"
|
|
503
|
+
return 1
|
|
504
|
+
fi
|
|
505
|
+
HR_CFG_STATE="bad"
|
|
506
|
+
hr_config_file_var "$root" || return 2
|
|
507
|
+
hr_have_jq || return 2
|
|
508
|
+
|
|
509
|
+
# ONE `jq`, emitting `<tag>\t<key>\t<value>` per line through `@tsv`, which
|
|
510
|
+
# escapes the only four characters that could break the format (tab, newline,
|
|
511
|
+
# carriage return, backslash) and nothing else. `-n` plus `input` reads the
|
|
512
|
+
# FIRST document and counting what is left is what refuses a `jq` stream,
|
|
513
|
+
# which is not a valid `.json` file and which the schema cannot describe: a
|
|
514
|
+
# per-key read would have applied its filter to every document and silently
|
|
515
|
+
# unioned them. A key whose PARENT has the wrong type (`"commands": "x"`)
|
|
516
|
+
# yields `null` for that key alone via `try … catch`, so one key fails rather
|
|
517
|
+
# than the document. `null`, the string `"null"` and `""` are not emitted at
|
|
518
|
+
# all, so a reader reports them as "not set".
|
|
519
|
+
#
|
|
520
|
+
# `protectedBranches.present` records that the key was there AS AN ARRAY,
|
|
521
|
+
# which is what lets an explicitly EMPTY list read as a configured set rather
|
|
522
|
+
# than as an absent one.
|
|
523
|
+
out=$(jq -n -r '
|
|
524
|
+
def s($k; $v):
|
|
525
|
+
if $v == null then empty
|
|
526
|
+
else ($v | tostring) as $t
|
|
527
|
+
| if $t == "" or $t == "null" then empty else ["S", $k, $t] | @tsv end
|
|
528
|
+
end;
|
|
529
|
+
def l($k; $v):
|
|
530
|
+
if ($v | type) == "array"
|
|
531
|
+
then $v[] | ["L", $k, (if . == null then "null" else tostring end)] | @tsv
|
|
532
|
+
else empty
|
|
533
|
+
end;
|
|
534
|
+
input as $doc
|
|
535
|
+
| (reduce inputs as $extra (0; . + 1)) as $rest
|
|
536
|
+
| if $rest > 0 then error("more than one JSON document") else $doc end
|
|
537
|
+
| if type != "object" then error("not an object") else . end
|
|
538
|
+
| s("projectName"; try .projectName catch null),
|
|
539
|
+
s("defaultBranch"; try .defaultBranch catch null),
|
|
540
|
+
s("stateDir"; try .stateDir catch null),
|
|
541
|
+
s("appDir"; try .appDir catch null),
|
|
542
|
+
s("scriptsDir"; try .scriptsDir catch null),
|
|
543
|
+
s("githooksDir"; try .githooksDir catch null),
|
|
544
|
+
s("agentModel"; try .agentModel catch null),
|
|
545
|
+
s("agentEffort"; try .agentEffort catch null),
|
|
546
|
+
s("pushEnvPath"; try .pushEnvPath catch null),
|
|
547
|
+
s("qa.credentialsPath"; try .qa.credentialsPath catch null),
|
|
548
|
+
s("commands.typecheck"; try .commands.typecheck catch null),
|
|
549
|
+
s("commands.test"; try .commands.test catch null),
|
|
550
|
+
s("commands.build"; try .commands.build catch null),
|
|
551
|
+
s("commands.devServer"; try .commands.devServer catch null),
|
|
552
|
+
s("commands.depInstall"; try .commands.depInstall catch null),
|
|
553
|
+
s("protectedBranches.present";
|
|
554
|
+
try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
|
|
555
|
+
l("protectedBranches"; try .protectedBranches catch null)
|
|
556
|
+
' "$HR_CFG_FILE" 2>/dev/null) || return 2
|
|
557
|
+
|
|
558
|
+
nl="
|
|
559
|
+
"
|
|
560
|
+
tab=$'\t'
|
|
561
|
+
while IFS= read -r line; do
|
|
562
|
+
[ -n "$line" ] || continue
|
|
563
|
+
tag=${line%%"$tab"*}
|
|
564
|
+
rest=${line#*"$tab"}
|
|
565
|
+
case "$tag" in
|
|
566
|
+
S) HR_CFG_SCALARS="$HR_CFG_SCALARS$nl$rest" ;;
|
|
567
|
+
L) HR_CFG_LISTS="$HR_CFG_LISTS$nl$rest" ;;
|
|
568
|
+
esac
|
|
569
|
+
done <<EOF
|
|
570
|
+
$out
|
|
571
|
+
EOF
|
|
572
|
+
HR_CFG_SCALARS="$HR_CFG_SCALARS$nl"
|
|
573
|
+
HR_CFG_LISTS="$HR_CFG_LISTS$nl"
|
|
574
|
+
|
|
575
|
+
# `defaultBranch` is required by the schema, so its absence is an
|
|
576
|
+
# unresolvable configuration rather than a defaultable one — and defaulting it
|
|
577
|
+
# here would put a remembered branch name into the protected set.
|
|
578
|
+
hr_cfg_scalar_var "defaultBranch" || return 2
|
|
579
|
+
[ -n "$HR_CFG_VALUE" ] || return 2
|
|
580
|
+
|
|
581
|
+
HR_CFG_STATE="ok"
|
|
582
|
+
return 0
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
# Set `HR_CFG_VALUE` to one cached scalar; return 1 when the key was not emitted
|
|
586
|
+
# (absent, `null`, `"null"` or empty). Lookup is a parameter expansion on
|
|
587
|
+
# `\n<key>\t`, which cannot false-match inside a value because a value carries
|
|
588
|
+
# no raw newline.
|
|
589
|
+
hr_cfg_scalar_var() {
|
|
590
|
+
local key="${1-}" rest nl tab
|
|
591
|
+
HR_CFG_VALUE=""
|
|
592
|
+
[ -n "$key" ] || return 1
|
|
593
|
+
nl="
|
|
594
|
+
"
|
|
595
|
+
tab=$'\t'
|
|
596
|
+
rest=${HR_CFG_SCALARS-}
|
|
597
|
+
case "$rest" in
|
|
598
|
+
*"$nl$key$tab"*) ;;
|
|
599
|
+
*) return 1 ;;
|
|
600
|
+
esac
|
|
601
|
+
rest=${rest#*"$nl$key$tab"}
|
|
602
|
+
rest=${rest%%"$nl"*}
|
|
603
|
+
hr_tsv_unescape_var "$rest"
|
|
604
|
+
[ -n "$HR_CFG_VALUE" ] || return 1
|
|
605
|
+
return 0
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
# Set `HR_CFG_VALUE` to the cached list elements, newline-joined in document
|
|
609
|
+
# order; return 1 when the key emitted no element at all.
|
|
610
|
+
hr_cfg_list_var() {
|
|
611
|
+
local key="${1-}" rest item out="" first=1 nl tab
|
|
612
|
+
HR_CFG_VALUE=""
|
|
613
|
+
[ -n "$key" ] || return 1
|
|
614
|
+
nl="
|
|
615
|
+
"
|
|
616
|
+
tab=$'\t'
|
|
617
|
+
rest=${HR_CFG_LISTS-}
|
|
618
|
+
while :; do
|
|
619
|
+
case "$rest" in
|
|
620
|
+
*"$nl$key$tab"*) ;;
|
|
621
|
+
*) break ;;
|
|
622
|
+
esac
|
|
623
|
+
rest=${rest#*"$nl$key$tab"}
|
|
624
|
+
item=${rest%%"$nl"*}
|
|
625
|
+
hr_tsv_unescape_var "$item"
|
|
626
|
+
if [ "$first" -eq 1 ]; then
|
|
627
|
+
out="$HR_CFG_VALUE"
|
|
628
|
+
first=0
|
|
629
|
+
else
|
|
630
|
+
out="$out
|
|
631
|
+
$HR_CFG_VALUE"
|
|
632
|
+
fi
|
|
633
|
+
done
|
|
634
|
+
[ "$first" -eq 0 ] || return 1
|
|
635
|
+
HR_CFG_VALUE="$out"
|
|
636
|
+
return 0
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
# The shared body of every scalar reader: print the configured value, else the
|
|
640
|
+
# supplied schema default, else return 1. Returns 2 — printing nothing — when
|
|
641
|
+
# the configuration could not be read, whether because there is none or because
|
|
642
|
+
# the one there does not parse.
|
|
643
|
+
hr_config_scalar() {
|
|
644
|
+
local root="${1-}" key="${2-}" default="${3-}"
|
|
645
|
+
[ -n "$key" ] || return 1
|
|
646
|
+
hr_config_load "$root" || return 2
|
|
647
|
+
if hr_cfg_scalar_var "$key"; then
|
|
648
|
+
printf '%s\n' "$HR_CFG_VALUE"
|
|
649
|
+
return 0
|
|
650
|
+
fi
|
|
651
|
+
[ -n "$default" ] || return 1
|
|
652
|
+
printf '%s\n' "$default"
|
|
653
|
+
return 0
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
# The same for a repo-relative directory key, with trailing slashes stripped so
|
|
657
|
+
# a caller can join with `/`. The value stays repo-relative: joining it to the
|
|
658
|
+
# repository root is the caller's step (`hr_state_path` is the one exception,
|
|
659
|
+
# and it says so).
|
|
660
|
+
hr_config_dir() {
|
|
661
|
+
local root="${1-}" key="${2-}" default="${3-}" value status
|
|
662
|
+
value=$(hr_config_scalar "$root" "$key" "$default")
|
|
663
|
+
status=$?
|
|
664
|
+
[ "$status" -eq 0 ] || return "$status"
|
|
665
|
+
while [ -n "$value" ] && [ "${value%/}" != "$value" ]; do value=${value%/}; done
|
|
666
|
+
[ -n "$value" ] || value="$default"
|
|
667
|
+
[ -n "$value" ] || return 1
|
|
668
|
+
printf '%s\n' "$value"
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
# Typed readers. Each takes <repo_root> first, prints its value on stdout, and
|
|
673
|
+
# applies that key's schema default and nothing else. 0 = a value; 1 = the key
|
|
674
|
+
# is not set and has no default; 2 = the configuration is unresolvable.
|
|
675
|
+
#
|
|
676
|
+
# There is deliberately NO reader for `clientEnvPrefix`: it is review vocabulary
|
|
677
|
+
# (the prefix a build tool requires before exposing a variable to a client
|
|
678
|
+
# bundle), no shipped script consumes it, and a reader nothing calls is dead
|
|
679
|
+
# surface. The worktree bootstrap's machine-local env symlink derives from
|
|
680
|
+
# `appDir` and a file-existence test instead.
|
|
681
|
+
# ---------------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
# `stateDir` — every run artifact's repo-relative home. Schema default
|
|
684
|
+
# `sdlc-harness/`, normalized here to carry no trailing slash.
|
|
685
|
+
hr_state_dir() {
|
|
686
|
+
hr_config_dir "${1-}" stateDir "sdlc-harness"
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# `scriptsDir` — where the generated wrappers, and this library, are written.
|
|
690
|
+
hr_scripts_dir() {
|
|
691
|
+
hr_config_dir "${1-}" scriptsDir "scripts"
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
# `appDir` — the repo-relative directory the application lives in. It anchors
|
|
695
|
+
# where the application is; it is not a working directory.
|
|
696
|
+
hr_app_dir() {
|
|
697
|
+
hr_config_dir "${1-}" appDir "."
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
# `githooksDir` — the committed git hooks the worktree bootstrap points
|
|
701
|
+
# `core.hooksPath` at.
|
|
702
|
+
hr_githooks_dir() {
|
|
703
|
+
hr_config_dir "${1-}" githooksDir "githooks"
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
# `projectName` — the stem of worktree directory names and of generated service
|
|
707
|
+
# labels. Falls back to the repository directory's own name, which is what the
|
|
708
|
+
# CLI seeds when the key is unset.
|
|
709
|
+
hr_project_name() {
|
|
710
|
+
local root="${1-}" value status
|
|
711
|
+
value=$(hr_config_scalar "$root" projectName "")
|
|
712
|
+
status=$?
|
|
713
|
+
[ "$status" -ne 2 ] || return 2
|
|
714
|
+
if [ "$status" -eq 0 ] && [ -n "$value" ]; then
|
|
715
|
+
printf '%s\n' "$value"
|
|
716
|
+
return 0
|
|
717
|
+
fi
|
|
718
|
+
hr_default_project_name "$root"
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
# `defaultBranch` — required, so this returns 2 rather than a remembered name
|
|
722
|
+
# when it is missing (`hr_config_load` has already refused such a document).
|
|
723
|
+
hr_default_branch() {
|
|
724
|
+
hr_config_scalar "${1-}" defaultBranch ""
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
# `agentModel` — what the outer loop passes to a headless run.
|
|
728
|
+
hr_agent_model() {
|
|
729
|
+
hr_config_scalar "${1-}" agentModel "opus"
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
# `agentEffort` — the reasoning-effort level the outer loop pins a headless run
|
|
733
|
+
# to. No schema default, so 1 means the adopter pinned none and the runtime
|
|
734
|
+
# applies its own per-model default; that is an ordinary project, not an error.
|
|
735
|
+
hr_agent_effort() {
|
|
736
|
+
hr_config_scalar "${1-}" agentEffort ""
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
# `pushEnvPath` — repo-relative path to the gitignored push-notification
|
|
740
|
+
# settings. No schema default: 1 means the adopter configured none, which is an
|
|
741
|
+
# ordinary project and not an error.
|
|
742
|
+
hr_push_env_path() {
|
|
743
|
+
hr_config_scalar "${1-}" pushEnvPath ""
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
# `qa.credentialsPath` — repo-relative path to the gitignored test-account
|
|
747
|
+
# credentials. No schema default, same as above.
|
|
748
|
+
hr_qa_creds_path() {
|
|
749
|
+
hr_config_scalar "${1-}" "qa.credentialsPath" ""
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
# The `commands.*` key set, in one place so a key added to the schema reaches
|
|
753
|
+
# every caller. Set as a variable rather than printed: callers test membership.
|
|
754
|
+
hr_command_keys_var() {
|
|
755
|
+
HR_CFG_COMMAND_KEYS='typecheck test build devServer depInstall'
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
# `commands.<key>` — the configured shell command for one verification or
|
|
759
|
+
# lifecycle step. 1 when that key is unset (a project with no build step is
|
|
760
|
+
# ordinary, and the caller prints one line and skips it) or when <key> is not
|
|
761
|
+
# one of the five; 2 when the configuration is unresolvable.
|
|
762
|
+
hr_command() {
|
|
763
|
+
local root="${1-}" key="${2-}" known=1 candidate
|
|
764
|
+
[ -n "$key" ] || return 1
|
|
765
|
+
hr_command_keys_var
|
|
766
|
+
for candidate in $HR_CFG_COMMAND_KEYS; do
|
|
767
|
+
# An `if` rather than a bare `[ … ] && …`: a trailing AND-list that fails is
|
|
768
|
+
# not in a `set -e`-exempt position, and a caller that sets `-e` would exit
|
|
769
|
+
# on the ordinary non-matching key.
|
|
770
|
+
if [ "$candidate" = "$key" ]; then known=0; fi
|
|
771
|
+
done
|
|
772
|
+
[ "$known" -eq 0 ] || return 1
|
|
773
|
+
hr_config_scalar "$root" "commands.$key" ""
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
# ---------------------------------------------------------------------------
|
|
777
|
+
# The protected-branch trichotomy.
|
|
778
|
+
# ---------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
# The schema default for `protectedBranches`. THE ONLY BRANCH NAME IN THIS FILE
|
|
781
|
+
# — see "THE ONE BRANCH NAME IN THIS FILE" in the header for why this one
|
|
782
|
+
# occurrence is a mirrored schema default rather than a remembered matcher: it
|
|
783
|
+
# is unioned with the configured set and can only ever widen a refusal.
|
|
784
|
+
hr_protected_default_var() {
|
|
785
|
+
HR_PROTECTED_DEFAULT='main'
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
# Print the resolved protected set, one glob pattern per line, de-duplicated:
|
|
789
|
+
# the configured list when the key is present, else that key's schema default,
|
|
790
|
+
# always unioned with `defaultBranch`. Return 2 (printing nothing) when the
|
|
791
|
+
# configuration cannot be resolved.
|
|
792
|
+
hr_protected_patterns() {
|
|
793
|
+
local root="${1-}" default_branch list pattern seen=""
|
|
794
|
+
hr_config_load "$root" || return 2
|
|
795
|
+
hr_cfg_scalar_var "defaultBranch" || return 2
|
|
796
|
+
default_branch="$HR_CFG_VALUE"
|
|
797
|
+
|
|
798
|
+
if hr_cfg_scalar_var "protectedBranches.present"; then
|
|
799
|
+
# Present, so the configured set wins even when it is EMPTY: an adopter who
|
|
800
|
+
# emptied the list configured "only the default branch", and re-adding the
|
|
801
|
+
# schema default there would protect a name they removed.
|
|
802
|
+
hr_cfg_list_var "protectedBranches" || HR_CFG_VALUE=""
|
|
803
|
+
list="$HR_CFG_VALUE"
|
|
804
|
+
else
|
|
805
|
+
hr_protected_default_var
|
|
806
|
+
list="$HR_PROTECTED_DEFAULT"
|
|
807
|
+
fi
|
|
808
|
+
|
|
809
|
+
while IFS= read -r pattern; do
|
|
810
|
+
[ -n "$pattern" ] || continue
|
|
811
|
+
case "$seen" in
|
|
812
|
+
*"|$pattern|"*) continue ;;
|
|
813
|
+
esac
|
|
814
|
+
seen="$seen|$pattern|"
|
|
815
|
+
printf '%s\n' "$pattern"
|
|
816
|
+
done <<EOF
|
|
817
|
+
$list
|
|
818
|
+
$default_branch
|
|
819
|
+
EOF
|
|
820
|
+
return 0
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
# Print the checked-out branch; print nothing on a detached HEAD or when the
|
|
824
|
+
# path is not a repository. Always returns 0 — emptiness is the signal, and what
|
|
825
|
+
# a detached HEAD *means* is the caller's decision.
|
|
826
|
+
hr_current_branch() {
|
|
827
|
+
local root="${1-}" branch
|
|
828
|
+
[ -n "$root" ] || return 0
|
|
829
|
+
branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null) || branch=""
|
|
830
|
+
if [ -n "$branch" ]; then printf '%s\n' "$branch"; fi
|
|
831
|
+
return 0
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# 0 = protected, 1 = not protected, 2 = unresolvable. Patterns are matched as
|
|
835
|
+
# `case` globs, so one `release/*` entry covers its namespace.
|
|
836
|
+
#
|
|
837
|
+
# An EMPTY branch — a detached HEAD, or a repository that could not be read —
|
|
838
|
+
# returns 2 rather than 1: there is nothing to judge, and 1 would read as a
|
|
839
|
+
# permit.
|
|
840
|
+
hr_branch_is_protected() {
|
|
841
|
+
local root="${1-}" branch="${2-}" patterns pattern
|
|
842
|
+
patterns=$(hr_protected_patterns "$root") || return 2
|
|
843
|
+
[ -n "$branch" ] || return 2
|
|
844
|
+
|
|
845
|
+
while IFS= read -r pattern; do
|
|
846
|
+
[ -n "$pattern" ] || continue
|
|
847
|
+
case "$branch" in
|
|
848
|
+
$pattern) return 0 ;;
|
|
849
|
+
esac
|
|
850
|
+
done <<EOF
|
|
851
|
+
$patterns
|
|
852
|
+
EOF
|
|
853
|
+
return 1
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
# ---------------------------------------------------------------------------
|
|
857
|
+
# Anchors, the slug and the machine-local directory.
|
|
858
|
+
# ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
# `/` → `-`, which is all a worktree directory name needs: it is the source
|
|
861
|
+
# derivation, and widening it would move existing checkouts.
|
|
862
|
+
hr_sanitize_branch() {
|
|
863
|
+
local branch="${1-}"
|
|
864
|
+
[ -n "$branch" ] || return 1
|
|
865
|
+
printf '%s\n' "${branch//\//-}"
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
# `<work_root>/<projectName>-<sanitized branch>` — byte-identical to what the
|
|
869
|
+
# CLI's `worktreeGlob()` materializes into the generated permission profile, so
|
|
870
|
+
# a worktree this creates is one that profile matches. Derive it here; never
|
|
871
|
+
# re-build the string in a caller.
|
|
872
|
+
hr_worktree_dir() {
|
|
873
|
+
local root="${1-}" branch="${2-}" work name safe
|
|
874
|
+
work=$(hr_work_root "$root") || return 1
|
|
875
|
+
name=$(hr_project_name "$root") || return 2
|
|
876
|
+
safe=$(hr_sanitize_branch "$branch") || return 1
|
|
877
|
+
printf '%s/%s-%s\n' "${work%/}" "$name" "$safe"
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
# The MACHINE-UNIQUE identifier for this repository: the daemon label, the
|
|
881
|
+
# systemd unit name, the machine-level usage lane and every notification title
|
|
882
|
+
# key on it.
|
|
883
|
+
#
|
|
884
|
+
# It is the MAIN checkout's absolute path, lowercased, with every character
|
|
885
|
+
# outside `[a-z0-9]` replaced by `-`, runs collapsed, leading and trailing `-`
|
|
886
|
+
# stripped, and the result truncated to 64 characters KEEPING THE TAIL — the
|
|
887
|
+
# tail is the distinguishing part, since two checkouts on one machine usually
|
|
888
|
+
# share a long prefix. Deriving it from the MAIN checkout is what makes every
|
|
889
|
+
# worktree of one repository answer the same slug.
|
|
890
|
+
#
|
|
891
|
+
# DETERMINISTIC AND HASH-FREE ON PURPOSE: the CLI implements the same function
|
|
892
|
+
# in TypeScript — `repoSlug()` in `cli/src/daemon/units.ts`, exercised against
|
|
893
|
+
# this one by the agreement test in `cli/test/daemon.test.mjs` — and that test
|
|
894
|
+
# only stays honest while both are one readable line-for-line transformation. If
|
|
895
|
+
# you change a step here, change it there in the same commit. Truncation is
|
|
896
|
+
# LAST, so a truncated slug may begin with `-`; that is the published order and
|
|
897
|
+
# both halves keep it. Both halves are also ASCII-only: `é` is a `-`, never a
|
|
898
|
+
# letter (see the `LC_ALL=C` below and the CLI's ASCII-only case fold).
|
|
899
|
+
#
|
|
900
|
+
# When the main-checkout probe does not answer, the given root is used instead:
|
|
901
|
+
# a slug is an identifier, not a permission, so a derivable answer beats a
|
|
902
|
+
# refusal. The consequence is worth knowing — a worktree whose `git worktree
|
|
903
|
+
# list` fails answers a different slug than its own main checkout would.
|
|
904
|
+
hr_repo_slug() {
|
|
905
|
+
# `[!a-z0-9]` below is a COLLATION range, so this line is load-bearing: under a
|
|
906
|
+
# UTF-8 collation locale `a-z` also matches `é`, and the slug would keep it —
|
|
907
|
+
# disagreeing with the CLI's `repoSlug()` (which is ASCII-only) and putting a
|
|
908
|
+
# character outside `[a-z0-9-]` into a daemon label and a lane key. `local`
|
|
909
|
+
# restores the caller's locale on return, and bash applies an LC_* assignment
|
|
910
|
+
# immediately whether or not it is exported.
|
|
911
|
+
local LC_ALL=C
|
|
912
|
+
local root="${1-}" main_repo lower slug
|
|
913
|
+
[ -n "$root" ] || return 1
|
|
914
|
+
main_repo=$(hr_main_repo "$root") || main_repo="$root"
|
|
915
|
+
[ -n "$main_repo" ] || return 1
|
|
916
|
+
|
|
917
|
+
# bash 3.2 has no case-folding expansion, so this forks once. `LC_ALL=C` keeps
|
|
918
|
+
# the fold ASCII-only and byte-safe; any character it leaves alone is outside
|
|
919
|
+
# `[a-z0-9]` and becomes a `-` on the next line anyway.
|
|
920
|
+
lower=$(printf '%s' "$main_repo" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
|
|
921
|
+
slug=${lower//[!a-z0-9]/-}
|
|
922
|
+
while :; do
|
|
923
|
+
case "$slug" in
|
|
924
|
+
*--*) slug=${slug//--/-} ;;
|
|
925
|
+
*) break ;;
|
|
926
|
+
esac
|
|
927
|
+
done
|
|
928
|
+
slug=${slug#-}
|
|
929
|
+
slug=${slug%-}
|
|
930
|
+
[ -n "$slug" ] || return 1
|
|
931
|
+
if [ "${#slug}" -gt 64 ]; then
|
|
932
|
+
slug=${slug: -64}
|
|
933
|
+
fi
|
|
934
|
+
printf '%s\n' "$slug"
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
# `<repo_root>/<state_dir>/<relative>` — the one place a run-artifact path is
|
|
938
|
+
# built, with `stateDir` normalized. With no <relative>, the state directory
|
|
939
|
+
# itself. Returns 2 when the configuration is unresolvable, because a script
|
|
940
|
+
# that guessed here would watch, log to, or stop the wrong directory silently.
|
|
941
|
+
hr_state_path() {
|
|
942
|
+
local root="${1-}" relative="${2-}" state status
|
|
943
|
+
[ -n "$root" ] || return 1
|
|
944
|
+
state=$(hr_state_dir "$root")
|
|
945
|
+
status=$?
|
|
946
|
+
[ "$status" -eq 0 ] || return "$status"
|
|
947
|
+
root=${root%/}
|
|
948
|
+
while [ -n "$relative" ] && [ "${relative#/}" != "$relative" ]; do relative=${relative#/}; done
|
|
949
|
+
if [ -n "$relative" ]; then
|
|
950
|
+
printf '%s/%s/%s\n' "$root" "$state" "$relative"
|
|
951
|
+
else
|
|
952
|
+
printf '%s/%s\n' "$root" "$state"
|
|
953
|
+
fi
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
# The machine-local settings directory — one place an operator keeps values that
|
|
957
|
+
# vary per machine rather than per repository, which is why it is not a config
|
|
958
|
+
# key and not a repository file. Return 1 when there is no home to anchor it to.
|
|
959
|
+
hr_machine_config_dir() {
|
|
960
|
+
local base="${XDG_CONFIG_HOME-}"
|
|
961
|
+
[ -n "$base" ] || base="${HOME-}/.config"
|
|
962
|
+
case "$base" in
|
|
963
|
+
/.config) return 1 ;;
|
|
964
|
+
esac
|
|
965
|
+
[ -n "$base" ] || return 1
|
|
966
|
+
printf '%s/autonomous-sdlc-harness\n' "${base%/}"
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
# The push-notification credential files, in RESOLUTION ORDER, one per line and
|
|
970
|
+
# whether or not each exists — the caller sources the first that does:
|
|
971
|
+
#
|
|
972
|
+
# 1. the machine-local file, so credentials for a machine are kept once rather
|
|
973
|
+
# than per repository;
|
|
974
|
+
# 2. the repository's configured `pushEnvPath`, when there is one.
|
|
975
|
+
#
|
|
976
|
+
# UNLIKE THE TYPED READERS THIS DEGRADES INSTEAD OF REFUSING: it returns 0 and
|
|
977
|
+
# prints just the machine-local candidate when the configuration cannot be read.
|
|
978
|
+
# Delivering a notification is not a mutating action, and a run that has just
|
|
979
|
+
# refused something unresolvable is exactly the run whose operator most needs to
|
|
980
|
+
# hear about it.
|
|
981
|
+
hr_push_env_files() {
|
|
982
|
+
local root="${1-}" dir path
|
|
983
|
+
if dir=$(hr_machine_config_dir); then
|
|
984
|
+
printf '%s/push.env\n' "$dir"
|
|
985
|
+
fi
|
|
986
|
+
path=$(hr_push_env_path "$root") || return 0
|
|
987
|
+
[ -n "$path" ] || return 0
|
|
988
|
+
case "$path" in
|
|
989
|
+
/*) printf '%s\n' "$path" ;;
|
|
990
|
+
*) printf '%s/%s\n' "${root%/}" "$path" ;;
|
|
991
|
+
esac
|
|
992
|
+
return 0
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
# ---------------------------------------------------------------------------
|
|
996
|
+
# THE MACHINE-LEVEL USAGE LANE.
|
|
997
|
+
#
|
|
998
|
+
# WHAT IT EXISTS TO STOP. The rate-limit window these runs consume belongs to the
|
|
999
|
+
# ACCOUNT, while every pause and resume decision is made from per-repository
|
|
1000
|
+
# files. Two daemons on one machine therefore each compute a resume time as if
|
|
1001
|
+
# they were the sole consumer: repository A pauses, repository B keeps spending
|
|
1002
|
+
# the shared window, A's resume time arrives already stale, A wakes, re-hits its
|
|
1003
|
+
# own gate and re-pauses. The published record carries the one fact a repository
|
|
1004
|
+
# cannot see for itself: what the rest of the machine has already observed of the
|
|
1005
|
+
# shared window. The advisory lock is the separate, opt-in half — it serializes
|
|
1006
|
+
# which repository on this machine starts.
|
|
1007
|
+
#
|
|
1008
|
+
# TWO HALVES, AND NEITHER LIMIT IS DERIVABLE FROM THE OTHER. The record
|
|
1009
|
+
# COORDINATES and is on by default (`USAGE_LANE_STATE_ENABLED`, `1`); by itself
|
|
1010
|
+
# it defers nobody. The lock SERIALIZES which repository starts, and is opt-in
|
|
1011
|
+
# and off by default (`USAGE_LANE_LOCK_ENABLED`, `0`). The per-repository cap
|
|
1012
|
+
# bounds HOW MANY runs one repository has in flight: a repository holding the
|
|
1013
|
+
# lock still obeys its own cap, and one that cannot take it starts nothing
|
|
1014
|
+
# however much of its own capacity is free. Both knobs belong to the WATCHER —
|
|
1015
|
+
# this library reads neither, and reads no environment variable in place of a
|
|
1016
|
+
# configured value.
|
|
1017
|
+
#
|
|
1018
|
+
# TWO ARTIFACTS, BOTH UNDER `hr_lane_dir` (created 0700, outside every
|
|
1019
|
+
# repository):
|
|
1020
|
+
#
|
|
1021
|
+
# usage-state.json the worst assessment any watcher has published:
|
|
1022
|
+
# {"schema":1,"state":"allowed|warning|overage|rejected|unknown",
|
|
1023
|
+
# "resume_at":<epoch>,"observed_at":<epoch>,
|
|
1024
|
+
# "observed_by":{"repo":"<slug>","branch":"<branch>"}}
|
|
1025
|
+
# Written atomically — a temp file in the same directory plus
|
|
1026
|
+
# a rename — so a reader sees the old record or the new one
|
|
1027
|
+
# and never a half-written line.
|
|
1028
|
+
# run-lane.lock a DIRECTORY holding one `owner` file whose single line is
|
|
1029
|
+
# `<slug> <pid> <acquired_at>`.
|
|
1030
|
+
#
|
|
1031
|
+
# THE LOCK IS A DIRECTORY BECAUSE `mkdir` IS THE ATOMIC PRIMITIVE AVAILABLE HERE.
|
|
1032
|
+
# `flock(1)` is a util-linux program and is absent on macOS; the shell's own
|
|
1033
|
+
# `set -o noclobber` redirection is defeated by an inherited `-C`. `mkdir` fails
|
|
1034
|
+
# when the name exists, on both supported platforms and on bash 3.2, which is the
|
|
1035
|
+
# whole test-and-set this needs.
|
|
1036
|
+
#
|
|
1037
|
+
# MERGED WORST-WINS, AND "WORSE" IS (STATE, THEN RESUME TIME). A publisher
|
|
1038
|
+
# replaces the stored record when its own state ranks higher, when the state
|
|
1039
|
+
# ranks the SAME and its resume time is later (the same state binding for longer
|
|
1040
|
+
# is the worse fact for every consumer), or when the stored record is SPENT — its
|
|
1041
|
+
# `resume_at` has passed, or it reported no resume time at all and has aged past
|
|
1042
|
+
# `HR_LANE_STATE_MAX_AGE_SECS`. Without that last clause a record that named no
|
|
1043
|
+
# reset would pin the file for the life of the machine.
|
|
1044
|
+
#
|
|
1045
|
+
# FAIL OPEN ON THE STATE, CLOSED ON THE LANE. An absent, unreadable or
|
|
1046
|
+
# unparseable `usage-state.json` reads as `unknown 0`, which defers nobody:
|
|
1047
|
+
# pausing on an unreadable file would put a machine-level fault in charge of run
|
|
1048
|
+
# state, and each repository's own gate is what pauses its runs. A lock directory
|
|
1049
|
+
# that cannot be read or created is NOT assumed free: `hr_lane_acquire` returns
|
|
1050
|
+
# non-zero, and the caller defers exactly as it defers for its own cap. The
|
|
1051
|
+
# closed half is reached only when the caller has enabled the lock.
|
|
1052
|
+
#
|
|
1053
|
+
# THE STALE-BREAKER, AND WHY IT REPORTS THROUGH A VARIABLE. A lock is broken and
|
|
1054
|
+
# re-taken when its owning pid no longer exists AND its record has aged past
|
|
1055
|
+
# `HR_LANE_LOCK_STALE_SECS`, or — whatever that pid says — when the record has
|
|
1056
|
+
# aged past `HR_LANE_LOCK_MAX_AGE_SECS`. NEITHER SIGNAL IS SUFFICIENT ON ITS
|
|
1057
|
+
# OWN. A dead pid does not mean an idle machine: a one-shot pass takes the lane,
|
|
1058
|
+
# starts a run that outlives it and exits, so its pid is gone within the second
|
|
1059
|
+
# while its run is still going — breaking on that alone would put two
|
|
1060
|
+
# repositories on the machine every time. A live pid does not mean a live
|
|
1061
|
+
# holder either, since a pid is recycled. Breaking renames the directory aside
|
|
1062
|
+
# before removing it, so a competing breaker that has already re-created the
|
|
1063
|
+
# lock cannot have its fresh `owner` deleted by this one — and, for the same
|
|
1064
|
+
# reason, a lock too young to be stale is held even when its `owner` file has
|
|
1065
|
+
# not been written yet. The previous owner is reported in
|
|
1066
|
+
# `HR_LANE_BROKEN_OWNER` for the caller to log, because this file prints nothing.
|
|
1067
|
+
#
|
|
1068
|
+
# THE THREE CEILINGS ARE THE ONLY ENVIRONMENT VALUES THAT CARRY POLICY HERE. The
|
|
1069
|
+
# file's other environment reads are location anchors, not policy:
|
|
1070
|
+
# `XDG_STATE_HOME` and `HOME` in `hr_lane_dir`, `XDG_CONFIG_HOME` and `HOME` in
|
|
1071
|
+
# `hr_machine_config_dir`, `PWD` in `hr_repo_root` and `hr_main_repo`. The
|
|
1072
|
+
# ceilings are machine-scoped policy with no configuration key:
|
|
1073
|
+
#
|
|
1074
|
+
# HR_LANE_STATE_MAX_AGE_SECS 21600 when a PUBLISHED RECORD THAT NAMED NO
|
|
1075
|
+
# reset time stops holding the merge — one
|
|
1076
|
+
# 5-hour window plus slack. Applied only to
|
|
1077
|
+
# such a record, so a legitimately long
|
|
1078
|
+
# weekly window is never aged out from
|
|
1079
|
+
# under its own `resume_at`.
|
|
1080
|
+
# HR_LANE_LOCK_STALE_SECS 900 how long a lock whose owner pid is GONE
|
|
1081
|
+
# is still honored — long enough to cover a
|
|
1082
|
+
# holder that runs as a series of one-shot
|
|
1083
|
+
# passes rather than as a daemon, short
|
|
1084
|
+
# enough that a crashed holder frees the
|
|
1085
|
+
# machine in minutes.
|
|
1086
|
+
# HR_LANE_LOCK_MAX_AGE_SECS 86400 when a lock is broken regardless of its
|
|
1087
|
+
# pid — far longer than any single run,
|
|
1088
|
+
# since a holder releases the lane as soon
|
|
1089
|
+
# as it has nothing live.
|
|
1090
|
+
# ---------------------------------------------------------------------------
|
|
1091
|
+
|
|
1092
|
+
# The machine-local lane directory — one per machine, deliberately not per
|
|
1093
|
+
# repository and deliberately not a configuration key: the thing it coordinates
|
|
1094
|
+
# is an account budget that no single repository owns. Return 1 when there is no
|
|
1095
|
+
# home to anchor it to. The location stays redirectable through
|
|
1096
|
+
# `XDG_STATE_HOME`.
|
|
1097
|
+
hr_lane_dir() {
|
|
1098
|
+
local base="${XDG_STATE_HOME-}"
|
|
1099
|
+
[ -n "$base" ] || base="${HOME-}/.local/state"
|
|
1100
|
+
case "$base" in
|
|
1101
|
+
/.local/state) return 1 ;;
|
|
1102
|
+
esac
|
|
1103
|
+
[ -n "$base" ] || return 1
|
|
1104
|
+
printf '%s/autonomous-sdlc-harness\n' "${base%/}"
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
# The two artifact paths, so no caller re-joins either string.
|
|
1108
|
+
hr_lane_state_file() {
|
|
1109
|
+
local dir
|
|
1110
|
+
dir=$(hr_lane_dir) || return 1
|
|
1111
|
+
printf '%s/usage-state.json\n' "$dir"
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
hr_lane_lock_dir() {
|
|
1115
|
+
local dir
|
|
1116
|
+
dir=$(hr_lane_dir) || return 1
|
|
1117
|
+
printf '%s/run-lane.lock\n' "$dir"
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
# Print the lane directory, creating it 0700 when it is not there. Return 1 —
|
|
1121
|
+
# printing nothing — when it cannot be created or cannot be written to, which is
|
|
1122
|
+
# the fail-CLOSED half of the contract: every writer below starts here.
|
|
1123
|
+
hr_lane_mkdir() {
|
|
1124
|
+
local dir
|
|
1125
|
+
dir=$(hr_lane_dir) || return 1
|
|
1126
|
+
if [ ! -d "$dir" ]; then
|
|
1127
|
+
# The umask makes the directory private from the instant it exists rather
|
|
1128
|
+
# than a `chmod` later; the `chmod` narrows a directory created under a
|
|
1129
|
+
# laxer umask by a version of this file that did not.
|
|
1130
|
+
(umask 077 && mkdir -p "$dir") 2>/dev/null || return 1
|
|
1131
|
+
chmod 700 "$dir" 2>/dev/null || :
|
|
1132
|
+
fi
|
|
1133
|
+
[ -d "$dir" ] && [ -w "$dir" ] || return 1
|
|
1134
|
+
printf '%s\n' "$dir"
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
# A path's modification time as an epoch, or 0. THE FALLBACK IS CHOSEN ON THE
|
|
1138
|
+
# VALUE, NOT THE EXIT STATUS: `-f` means `--file-system` to GNU `stat`, which
|
|
1139
|
+
# therefore succeeds while printing something that is not a timestamp. The only
|
|
1140
|
+
# `stat` in this file, and it exists for one case — a lock directory whose
|
|
1141
|
+
# `owner` file is missing or unreadable, where the directory's own mtime is the
|
|
1142
|
+
# only acquisition time there is.
|
|
1143
|
+
hr_lane_mtime() {
|
|
1144
|
+
local path="${1-}" m=""
|
|
1145
|
+
[ -n "$path" ] && [ -e "$path" ] || {
|
|
1146
|
+
printf '0\n'
|
|
1147
|
+
return 0
|
|
1148
|
+
}
|
|
1149
|
+
m=$(stat -f %m "$path" 2>/dev/null)
|
|
1150
|
+
case "$m" in '' | *[!0-9]*) m="" ;; esac
|
|
1151
|
+
if [ -z "$m" ]; then
|
|
1152
|
+
m=$(stat -c %Y "$path" 2>/dev/null)
|
|
1153
|
+
case "$m" in '' | *[!0-9]*) m="" ;; esac
|
|
1154
|
+
fi
|
|
1155
|
+
[ -n "$m" ] || m=0
|
|
1156
|
+
printf '%s\n' "$m"
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
# Set `HR_LANE_RANK` to a lane state's severity. A state this vocabulary does not
|
|
1160
|
+
# know ranks 0 — "no information" — so a hand-edited or truncated value can never
|
|
1161
|
+
# defer anybody, which is the fail-open rule applied at the one place it decides
|
|
1162
|
+
# anything.
|
|
1163
|
+
hr_lane_rank_var() {
|
|
1164
|
+
case "${1-}" in
|
|
1165
|
+
rejected) HR_LANE_RANK=4 ;;
|
|
1166
|
+
overage) HR_LANE_RANK=3 ;;
|
|
1167
|
+
warning) HR_LANE_RANK=2 ;;
|
|
1168
|
+
allowed) HR_LANE_RANK=1 ;;
|
|
1169
|
+
*) HR_LANE_RANK=0 ;;
|
|
1170
|
+
esac
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
# Set `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT` and
|
|
1174
|
+
# `HR_LANE_OBSERVED_REPO` from the published record. ALWAYS returns 0 with a
|
|
1175
|
+
# usable answer — `unknown`, `0`, `0`, `` — when the record is absent,
|
|
1176
|
+
# unreadable, not an object, not JSON at all, or `jq` is missing. Every field is
|
|
1177
|
+
# validated after it is read, so a hand-written file cannot put a value the rest
|
|
1178
|
+
# of the lane does not understand into a comparison.
|
|
1179
|
+
hr_lane_read_var() {
|
|
1180
|
+
local file out st ra oa repo
|
|
1181
|
+
HR_LANE_STATE="unknown"
|
|
1182
|
+
HR_LANE_RESUME_AT=0
|
|
1183
|
+
HR_LANE_OBSERVED_AT=0
|
|
1184
|
+
HR_LANE_OBSERVED_REPO=""
|
|
1185
|
+
file=$(hr_lane_state_file) || return 0
|
|
1186
|
+
[ -f "$file" ] && [ -r "$file" ] || return 0
|
|
1187
|
+
hr_have_jq || return 0
|
|
1188
|
+
out=$(jq -r '
|
|
1189
|
+
if type == "object"
|
|
1190
|
+
then "\(.state // "unknown") \(.resume_at // 0) \(.observed_at // 0) \(.observed_by.repo // "")"
|
|
1191
|
+
else empty
|
|
1192
|
+
end' "$file" 2>/dev/null) || return 0
|
|
1193
|
+
[ -n "$out" ] || return 0
|
|
1194
|
+
st=""
|
|
1195
|
+
ra=""
|
|
1196
|
+
oa=""
|
|
1197
|
+
repo=""
|
|
1198
|
+
read -r st ra oa repo <<EOF
|
|
1199
|
+
$out
|
|
1200
|
+
EOF
|
|
1201
|
+
case "$st" in
|
|
1202
|
+
allowed | warning | overage | rejected | unknown) ;;
|
|
1203
|
+
*) st="unknown" ;;
|
|
1204
|
+
esac
|
|
1205
|
+
# A non-integer epoch — a float, a quoted string, a truncated write — reads as
|
|
1206
|
+
# "no time reported" rather than as an error: the consumers below compare it
|
|
1207
|
+
# with `-gt`, where a non-numeric operand aborts the pass.
|
|
1208
|
+
case "$ra" in '' | *[!0-9]*) ra=0 ;; esac
|
|
1209
|
+
case "$oa" in '' | *[!0-9]*) oa=0 ;; esac
|
|
1210
|
+
HR_LANE_STATE="$st"
|
|
1211
|
+
HR_LANE_RESUME_AT="$ra"
|
|
1212
|
+
HR_LANE_OBSERVED_AT="$oa"
|
|
1213
|
+
HR_LANE_OBSERVED_REPO="$repo"
|
|
1214
|
+
return 0
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
# Echo `"<state> <resume_at>"` — the published record, or `unknown 0` when there
|
|
1218
|
+
# is nothing usable to publish from. The shape deliberately matches the per-repo
|
|
1219
|
+
# gate's own assessment output, so a caller reads both the same way.
|
|
1220
|
+
hr_lane_read() {
|
|
1221
|
+
hr_lane_read_var
|
|
1222
|
+
printf '%s %s\n' "$HR_LANE_STATE" "$HR_LANE_RESUME_AT"
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
# hr_lane_publish <slug> <branch> <state> <resume_at>
|
|
1226
|
+
#
|
|
1227
|
+
# Merge this repository's assessment into the shared record, worst-wins (see the
|
|
1228
|
+
# section header for what "worse" means and when a stored record is spent).
|
|
1229
|
+
# Returns 0 when the file now reflects the worst known observation — INCLUDING
|
|
1230
|
+
# the case where the stored record was already worse and was deliberately left
|
|
1231
|
+
# alone — and 1 only when the lane directory or the write could not be had.
|
|
1232
|
+
#
|
|
1233
|
+
# `<state>` outside the vocabulary is published as `unknown`, and both identity
|
|
1234
|
+
# fields are reduced to a safe character set: this writes JSON with `printf`
|
|
1235
|
+
# rather than `jq`, so a value that could carry a quote or a backslash into the
|
|
1236
|
+
# document is not written at all.
|
|
1237
|
+
hr_lane_publish() {
|
|
1238
|
+
local slug="${1-}" branch="${2-}" state="${3-}" resume_at="${4-}"
|
|
1239
|
+
local dir file tmp now new_rank old_rank replace=0 ceiling
|
|
1240
|
+
[ -n "$slug" ] || return 1
|
|
1241
|
+
case "$state" in
|
|
1242
|
+
allowed | warning | overage | rejected | unknown) ;;
|
|
1243
|
+
*) state="unknown" ;;
|
|
1244
|
+
esac
|
|
1245
|
+
case "$resume_at" in '' | *[!0-9]*) resume_at=0 ;; esac
|
|
1246
|
+
slug=${slug//[!a-zA-Z0-9._-]/-}
|
|
1247
|
+
branch=${branch//[!a-zA-Z0-9._\/-]/-}
|
|
1248
|
+
now=$(date +%s 2>/dev/null) || now=0
|
|
1249
|
+
case "$now" in '' | *[!0-9]*) now=0 ;; esac
|
|
1250
|
+
|
|
1251
|
+
dir=$(hr_lane_mkdir) || return 1
|
|
1252
|
+
file="$dir/usage-state.json"
|
|
1253
|
+
|
|
1254
|
+
hr_lane_read_var
|
|
1255
|
+
hr_lane_rank_var "$state"
|
|
1256
|
+
new_rank=$HR_LANE_RANK
|
|
1257
|
+
hr_lane_rank_var "$HR_LANE_STATE"
|
|
1258
|
+
old_rank=$HR_LANE_RANK
|
|
1259
|
+
ceiling=${HR_LANE_STATE_MAX_AGE_SECS:-21600}
|
|
1260
|
+
case "$ceiling" in '' | *[!0-9]*) ceiling=21600 ;; esac
|
|
1261
|
+
|
|
1262
|
+
if [ ! -f "$file" ]; then
|
|
1263
|
+
replace=1
|
|
1264
|
+
elif [ "$new_rank" -gt "$old_rank" ]; then
|
|
1265
|
+
replace=1
|
|
1266
|
+
elif [ "$new_rank" -eq "$old_rank" ] && [ "$resume_at" -gt "$HR_LANE_RESUME_AT" ]; then
|
|
1267
|
+
replace=1
|
|
1268
|
+
elif [ "$HR_LANE_RESUME_AT" -gt 0 ] && [ "$now" -ge "$HR_LANE_RESUME_AT" ]; then
|
|
1269
|
+
replace=1
|
|
1270
|
+
elif [ "$HR_LANE_RESUME_AT" -le 0 ] && [ "$now" -ge $((HR_LANE_OBSERVED_AT + ceiling)) ]; then
|
|
1271
|
+
replace=1
|
|
1272
|
+
fi
|
|
1273
|
+
[ "$replace" -eq 1 ] || return 0
|
|
1274
|
+
|
|
1275
|
+
# Same directory, so the rename below is within one filesystem and therefore
|
|
1276
|
+
# atomic; a reader mid-publish sees the previous record, never a partial one.
|
|
1277
|
+
tmp=$(mktemp "$dir/.usage-state.XXXXXX" 2>/dev/null) || tmp="$dir/.usage-state.$$.tmp"
|
|
1278
|
+
chmod 600 "$tmp" 2>/dev/null || :
|
|
1279
|
+
if ! printf '{"schema":1,"state":"%s","resume_at":%s,"observed_at":%s,"observed_by":{"repo":"%s","branch":"%s"}}\n' \
|
|
1280
|
+
"$state" "$resume_at" "$now" "$slug" "$branch" >"$tmp" 2>/dev/null; then
|
|
1281
|
+
rm -f "$tmp" 2>/dev/null || :
|
|
1282
|
+
return 1
|
|
1283
|
+
fi
|
|
1284
|
+
if ! mv "$tmp" "$file" 2>/dev/null; then
|
|
1285
|
+
rm -f "$tmp" 2>/dev/null || :
|
|
1286
|
+
return 1
|
|
1287
|
+
fi
|
|
1288
|
+
return 0
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
# Write the owner record of a lock we hold or have just created: `<slug> <pid>
|
|
1292
|
+
# <acquired_at>`, through a temp file and a rename so a reader never sees a
|
|
1293
|
+
# half-written line. `$$` is this shell's pid and is unchanged inside `$(…)`, so
|
|
1294
|
+
# a caller that reaches the lane through a command substitution would record its
|
|
1295
|
+
# PARENT's pid — which is why every lane call site invokes these unsubstituted.
|
|
1296
|
+
hr_lane_write_owner() {
|
|
1297
|
+
local lock="${1-}" slug="${2-}" at="${3-}" tmp
|
|
1298
|
+
[ -n "$lock" ] && [ -n "$slug" ] || return 1
|
|
1299
|
+
case "$at" in '' | *[!0-9]*) at=0 ;; esac
|
|
1300
|
+
tmp="$lock/.owner.$$"
|
|
1301
|
+
if ! printf '%s %s %s\n' "$slug" "$$" "$at" >"$tmp" 2>/dev/null; then
|
|
1302
|
+
rm -f "$tmp" 2>/dev/null || :
|
|
1303
|
+
return 1
|
|
1304
|
+
fi
|
|
1305
|
+
if ! mv "$tmp" "$lock/owner" 2>/dev/null; then
|
|
1306
|
+
rm -f "$tmp" 2>/dev/null || :
|
|
1307
|
+
return 1
|
|
1308
|
+
fi
|
|
1309
|
+
return 0
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
# Set `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID` and `HR_LANE_OWNER_AT` and return
|
|
1313
|
+
# 0 when the lane IS HELD; return 1 (with all three cleared) when it is free or
|
|
1314
|
+
# when the lane directory cannot be resolved.
|
|
1315
|
+
#
|
|
1316
|
+
# A lock whose `owner` file is missing or unreadable is still HELD — the
|
|
1317
|
+
# directory is the lock, not the file inside it — and the directory's own mtime
|
|
1318
|
+
# stands in for the acquisition time so the ceiling can still break it. Reading
|
|
1319
|
+
# it as free would hand two repositories the lane at once, which is the one
|
|
1320
|
+
# outcome this whole mechanism exists to prevent.
|
|
1321
|
+
hr_lane_owner_var() {
|
|
1322
|
+
local lock line
|
|
1323
|
+
HR_LANE_OWNER_SLUG=""
|
|
1324
|
+
HR_LANE_OWNER_PID=""
|
|
1325
|
+
# Empty, not 0: the mtime fallback below is applied to an EMPTY value, and a
|
|
1326
|
+
# placeholder 0 here would look like an acquisition time that was read.
|
|
1327
|
+
HR_LANE_OWNER_AT=""
|
|
1328
|
+
lock=$(hr_lane_lock_dir) || return 1
|
|
1329
|
+
[ -d "$lock" ] || return 1
|
|
1330
|
+
line=""
|
|
1331
|
+
if [ -r "$lock/owner" ]; then
|
|
1332
|
+
IFS= read -r line <"$lock/owner" 2>/dev/null || line=""
|
|
1333
|
+
fi
|
|
1334
|
+
if [ -n "$line" ]; then
|
|
1335
|
+
read -r HR_LANE_OWNER_SLUG HR_LANE_OWNER_PID HR_LANE_OWNER_AT <<EOF
|
|
1336
|
+
$line
|
|
1337
|
+
EOF
|
|
1338
|
+
fi
|
|
1339
|
+
case "${HR_LANE_OWNER_PID-}" in '' | *[!0-9]*) HR_LANE_OWNER_PID="" ;; esac
|
|
1340
|
+
case "${HR_LANE_OWNER_AT-}" in '' | *[!0-9]*) HR_LANE_OWNER_AT="" ;; esac
|
|
1341
|
+
[ -n "$HR_LANE_OWNER_AT" ] || HR_LANE_OWNER_AT=$(hr_lane_mtime "$lock")
|
|
1342
|
+
return 0
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
# Echo `"<slug> <pid> <acquired_at>"` for a HELD lane, using `-` for a field the
|
|
1346
|
+
# owner record did not carry; return 1, printing nothing, when the lane is free
|
|
1347
|
+
# or unresolvable. A pure reader: it neither creates the lane directory nor
|
|
1348
|
+
# breaks anything.
|
|
1349
|
+
hr_lane_owner() {
|
|
1350
|
+
hr_lane_owner_var || return 1
|
|
1351
|
+
printf '%s %s %s\n' "${HR_LANE_OWNER_SLUG:--}" "${HR_LANE_OWNER_PID:--}" "${HR_LANE_OWNER_AT:-0}"
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
# hr_lane_acquire <slug>
|
|
1355
|
+
#
|
|
1356
|
+
# 0 the lane is ours — taken now, already ours, or taken after breaking a
|
|
1357
|
+
# stale lock (in which case `HR_LANE_BROKEN_OWNER` names the previous owner
|
|
1358
|
+
# for the caller to log)
|
|
1359
|
+
# 1 it is held by a foreign owner the breaker's two ceilings still honor, or
|
|
1360
|
+
# the lane could not be reached at all — an unwritable directory, a lost
|
|
1361
|
+
# race with another breaker, no home
|
|
1362
|
+
#
|
|
1363
|
+
# IT NEVER BLOCKS AND NEVER SLEEPS. The caller is a poll loop: waiting inside
|
|
1364
|
+
# this function would stall every other pass of that loop behind a lock some
|
|
1365
|
+
# other machine-local daemon is holding for hours. A caller that cannot take the
|
|
1366
|
+
# lane defers, exactly as it defers for its own concurrency cap, and asks again
|
|
1367
|
+
# on its next pass.
|
|
1368
|
+
hr_lane_acquire() {
|
|
1369
|
+
local slug="${1-}" dir lock now ceiling stale_ceiling past_ceiling stale live=0
|
|
1370
|
+
HR_LANE_BROKEN_OWNER=""
|
|
1371
|
+
[ -n "$slug" ] || return 1
|
|
1372
|
+
dir=$(hr_lane_mkdir) || return 1
|
|
1373
|
+
lock="$dir/run-lane.lock"
|
|
1374
|
+
now=$(date +%s 2>/dev/null) || now=0
|
|
1375
|
+
case "$now" in '' | *[!0-9]*) now=0 ;; esac
|
|
1376
|
+
|
|
1377
|
+
# The test-and-set. `mkdir` fails when the name exists, atomically, which is
|
|
1378
|
+
# the whole primitive this rests on.
|
|
1379
|
+
if mkdir "$lock" 2>/dev/null; then
|
|
1380
|
+
if hr_lane_write_owner "$lock" "$slug" "$now"; then
|
|
1381
|
+
return 0
|
|
1382
|
+
fi
|
|
1383
|
+
rmdir "$lock" 2>/dev/null || :
|
|
1384
|
+
return 1
|
|
1385
|
+
fi
|
|
1386
|
+
|
|
1387
|
+
# Anything other than "it already exists" is a lane this process cannot reason
|
|
1388
|
+
# about, and an unreachable lane is never assumed free.
|
|
1389
|
+
[ -d "$lock" ] || return 1
|
|
1390
|
+
hr_lane_owner_var || return 1
|
|
1391
|
+
|
|
1392
|
+
if [ "$HR_LANE_OWNER_SLUG" = "$slug" ]; then
|
|
1393
|
+
# Already ours. Re-stamp it, so that after a daemon restart the pid the
|
|
1394
|
+
# liveness breaker tests is the live one rather than its predecessor's.
|
|
1395
|
+
hr_lane_write_owner "$lock" "$slug" "$now" || :
|
|
1396
|
+
return 0
|
|
1397
|
+
fi
|
|
1398
|
+
|
|
1399
|
+
# The two ceilings of the stale-breaker (see the section header). The pid only
|
|
1400
|
+
# chooses WHICH one applies: a live owner is held until the long ceiling, a
|
|
1401
|
+
# vanished one until the short ceiling — never instantly, because a one-shot
|
|
1402
|
+
# pass that started a run and exited is a dead pid with a live run behind it.
|
|
1403
|
+
ceiling=${HR_LANE_LOCK_MAX_AGE_SECS:-86400}
|
|
1404
|
+
case "$ceiling" in '' | *[!0-9]*) ceiling=86400 ;; esac
|
|
1405
|
+
if [ -n "$HR_LANE_OWNER_PID" ] && kill -0 "$HR_LANE_OWNER_PID" 2>/dev/null; then
|
|
1406
|
+
live=1
|
|
1407
|
+
else
|
|
1408
|
+
stale_ceiling=${HR_LANE_LOCK_STALE_SECS:-900}
|
|
1409
|
+
case "$stale_ceiling" in '' | *[!0-9]*) stale_ceiling=900 ;; esac
|
|
1410
|
+
if [ "$stale_ceiling" -lt "$ceiling" ]; then
|
|
1411
|
+
ceiling="$stale_ceiling"
|
|
1412
|
+
fi
|
|
1413
|
+
fi
|
|
1414
|
+
# Age is evidence only when both timestamps are real. A record whose
|
|
1415
|
+
# acquisition time could not be read at all — no `owner` file AND no usable
|
|
1416
|
+
# directory mtime — is not breakable by age: the lane is never taken on a
|
|
1417
|
+
# guess. `live` is not tested again here; it has already chosen the ceiling.
|
|
1418
|
+
past_ceiling=0
|
|
1419
|
+
if [ "$now" -gt 0 ] && [ "$HR_LANE_OWNER_AT" -gt 0 ] && [ $((now - HR_LANE_OWNER_AT)) -ge "$ceiling" ]; then
|
|
1420
|
+
past_ceiling=1
|
|
1421
|
+
fi
|
|
1422
|
+
if [ "$past_ceiling" -eq 0 ]; then
|
|
1423
|
+
return 1
|
|
1424
|
+
fi
|
|
1425
|
+
|
|
1426
|
+
# Break it. RENAMED ASIDE FIRST, then emptied: a second breaker that has
|
|
1427
|
+
# already re-created the lock must not have its fresh `owner` removed by this
|
|
1428
|
+
# one. Debris under `run-lane.lock.stale.*` means an operator put something
|
|
1429
|
+
# else inside the lock directory — visible, and harmless.
|
|
1430
|
+
stale="$lock.stale.$$"
|
|
1431
|
+
if [ -e "$stale" ]; then
|
|
1432
|
+
# A leftover from an earlier break by this same pid. Cleared first, because
|
|
1433
|
+
# `mv` into an EXISTING directory would move the lock INSIDE it instead of
|
|
1434
|
+
# renaming it, and the lock would then still be there.
|
|
1435
|
+
rm -f "$stale/owner" 2>/dev/null || :
|
|
1436
|
+
rmdir "$stale" 2>/dev/null || :
|
|
1437
|
+
if [ -e "$stale" ]; then
|
|
1438
|
+
return 1
|
|
1439
|
+
fi
|
|
1440
|
+
fi
|
|
1441
|
+
HR_LANE_BROKEN_OWNER="${HR_LANE_OWNER_SLUG:--} ${HR_LANE_OWNER_PID:--} ${HR_LANE_OWNER_AT:-0}"
|
|
1442
|
+
if ! mv "$lock" "$stale" 2>/dev/null; then
|
|
1443
|
+
HR_LANE_BROKEN_OWNER=""
|
|
1444
|
+
return 1
|
|
1445
|
+
fi
|
|
1446
|
+
rm -f "$stale/owner" 2>/dev/null || :
|
|
1447
|
+
rmdir "$stale" 2>/dev/null || :
|
|
1448
|
+
if mkdir "$lock" 2>/dev/null; then
|
|
1449
|
+
if hr_lane_write_owner "$lock" "$slug" "$now"; then
|
|
1450
|
+
return 0
|
|
1451
|
+
fi
|
|
1452
|
+
# Took the directory and could not name an owner in it: remove it rather
|
|
1453
|
+
# than leave an unattributable lock the ceilings would honor.
|
|
1454
|
+
rmdir "$lock" 2>/dev/null || :
|
|
1455
|
+
fi
|
|
1456
|
+
# Another breaker won the re-take, or the owner could not be written. Nothing
|
|
1457
|
+
# was granted, so nothing is reported.
|
|
1458
|
+
HR_LANE_BROKEN_OWNER=""
|
|
1459
|
+
return 1
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
# hr_lane_release <slug>
|
|
1463
|
+
#
|
|
1464
|
+
# 0 the lane is not held by <slug> any more — released now, or already free
|
|
1465
|
+
# 1 it is held by SOMEBODY ELSE (nothing was touched), or the removal failed
|
|
1466
|
+
#
|
|
1467
|
+
# ONLY THE OWNER RELEASES. A watcher that cleared a lane it does not own would
|
|
1468
|
+
# put two repositories on the machine at once, which is exactly what the lock is
|
|
1469
|
+
# for; a lane held by a dead foreign owner is `hr_lane_acquire`'s business to
|
|
1470
|
+
# break, not this one's.
|
|
1471
|
+
hr_lane_release() {
|
|
1472
|
+
local slug="${1-}" lock
|
|
1473
|
+
[ -n "$slug" ] || return 1
|
|
1474
|
+
lock=$(hr_lane_lock_dir) || return 1
|
|
1475
|
+
[ -d "$lock" ] || return 0
|
|
1476
|
+
hr_lane_owner_var || return 0
|
|
1477
|
+
[ "$HR_LANE_OWNER_SLUG" = "$slug" ] || return 1
|
|
1478
|
+
rm -f "$lock/owner" 2>/dev/null || :
|
|
1479
|
+
rmdir "$lock" 2>/dev/null || return 1
|
|
1480
|
+
return 0
|
|
1481
|
+
}
|