devrites 4.2.0 → 4.3.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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +4 -0
- package/docs/orchestration.md +6 -3
- package/engine/internal/lib/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/skilltrust.go +196 -0
- package/engine/internal/lib/taskgraph.go +162 -0
- package/engine/internal/lib/taskgraph_test.go +150 -0
- package/engine/internal/lib/workspace_read.go +29 -0
- package/engine/internal/parallel/cli.go +467 -0
- package/engine/internal/parallel/git.go +181 -0
- package/engine/internal/parallel/lease.go +244 -0
- package/engine/internal/parallel/ops.go +416 -0
- package/engine/internal/parallel/parallel_test.go +242 -0
- package/engine/internal/parallel/pathdisjoint.go +129 -0
- package/engine/internal/parallel/pathdisjoint_test.go +97 -0
- package/engine/internal/state/status.go +41 -16
- package/engine/main.go +72 -3
- package/engine/root_routing_test.go +1 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/.claude/skills/rite-build/SKILL.md +29 -36
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/.claude/skills/rite-build/reference/output.md +2 -0
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"os/exec"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/devrites/devrites/internal/gitenv"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const gitTimeout = 60 * time.Second
|
|
17
|
+
|
|
18
|
+
func git(repo string, args ...string) (string, error) {
|
|
19
|
+
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
|
20
|
+
defer cancel()
|
|
21
|
+
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repo}, args...)...)
|
|
22
|
+
env := append(gitenv.Sanitize(os.Environ()),
|
|
23
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
24
|
+
"GCM_INTERACTIVE=never",
|
|
25
|
+
"GIT_PAGER=cat",
|
|
26
|
+
"PAGER=cat",
|
|
27
|
+
"LC_ALL=C",
|
|
28
|
+
)
|
|
29
|
+
if gitOpNeedsIdentity(args) {
|
|
30
|
+
env = ensureGitIdentityEnv(env)
|
|
31
|
+
}
|
|
32
|
+
cmd.Env = env
|
|
33
|
+
var stdout, stderr bytes.Buffer
|
|
34
|
+
cmd.Stdout = &stdout
|
|
35
|
+
cmd.Stderr = &stderr
|
|
36
|
+
err := cmd.Run()
|
|
37
|
+
out := strings.TrimSpace(stdout.String())
|
|
38
|
+
if err != nil {
|
|
39
|
+
detail := strings.TrimSpace(stderr.String())
|
|
40
|
+
if detail == "" {
|
|
41
|
+
detail = out
|
|
42
|
+
}
|
|
43
|
+
if ctx.Err() != nil {
|
|
44
|
+
return out, fmt.Errorf("git %s: %w", strings.Join(args, " "), ctx.Err())
|
|
45
|
+
}
|
|
46
|
+
var exitErr *exec.ExitError
|
|
47
|
+
if errors.As(err, &exitErr) {
|
|
48
|
+
return out, fmt.Errorf("git %s: exit %d: %s", strings.Join(args, " "), exitErr.ExitCode(), detail)
|
|
49
|
+
}
|
|
50
|
+
return out, fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, detail)
|
|
51
|
+
}
|
|
52
|
+
return out, nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func gitOpNeedsIdentity(args []string) bool {
|
|
56
|
+
for _, a := range args {
|
|
57
|
+
switch a {
|
|
58
|
+
case "cherry-pick", "commit", "merge", "rebase", "am":
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ensureGitIdentityEnv fills author/committer when the host has none (common on
|
|
66
|
+
// CI images). Prefer existing env identity when present.
|
|
67
|
+
func ensureGitIdentityEnv(env []string) []string {
|
|
68
|
+
keys := map[string]string{
|
|
69
|
+
"GIT_AUTHOR_NAME": "devrites-engine",
|
|
70
|
+
"GIT_AUTHOR_EMAIL": "engine@devrites.invalid",
|
|
71
|
+
"GIT_COMMITTER_NAME": "devrites-engine",
|
|
72
|
+
"GIT_COMMITTER_EMAIL": "engine@devrites.invalid",
|
|
73
|
+
}
|
|
74
|
+
have := make(map[string]bool, len(env))
|
|
75
|
+
for _, e := range env {
|
|
76
|
+
if i := strings.IndexByte(e, '='); i > 0 {
|
|
77
|
+
have[e[:i]] = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for k, v := range keys {
|
|
81
|
+
if !have[k] {
|
|
82
|
+
env = append(env, k+"="+v)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return env
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func revParse(repo, rev string) (string, error) {
|
|
89
|
+
return git(repo, "rev-parse", "--verify", rev+"^{commit}")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func isAncestor(repo, ancestor, descendant string) (bool, error) {
|
|
93
|
+
_, err := git(repo, "merge-base", "--is-ancestor", ancestor, descendant)
|
|
94
|
+
if err == nil {
|
|
95
|
+
return true, nil
|
|
96
|
+
}
|
|
97
|
+
if strings.Contains(err.Error(), "exit 1") {
|
|
98
|
+
return false, nil
|
|
99
|
+
}
|
|
100
|
+
return false, err
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func diffNames(repo, a, b string) ([]string, error) {
|
|
104
|
+
out, err := git(repo, "diff", "--name-only", a, b)
|
|
105
|
+
if err != nil {
|
|
106
|
+
return nil, err
|
|
107
|
+
}
|
|
108
|
+
if out == "" {
|
|
109
|
+
return nil, nil
|
|
110
|
+
}
|
|
111
|
+
lines := strings.Split(out, "\n")
|
|
112
|
+
for i := range lines {
|
|
113
|
+
lines[i] = strings.TrimSpace(lines[i])
|
|
114
|
+
}
|
|
115
|
+
return lines, nil
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func headSHA(repo string) (string, error) {
|
|
119
|
+
return revParse(repo, "HEAD")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func resetHard(repo, sha string) error {
|
|
123
|
+
_, err := git(repo, "reset", "--hard", sha)
|
|
124
|
+
return err
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func porcelainDirty(repo string) (bool, error) {
|
|
128
|
+
out, err := git(repo, "status", "--porcelain", "--untracked-files=no")
|
|
129
|
+
if err != nil {
|
|
130
|
+
return false, err
|
|
131
|
+
}
|
|
132
|
+
return out != "", nil
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func branchExists(repo, branch string) (bool, error) {
|
|
136
|
+
_, err := git(repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
|
137
|
+
if err == nil {
|
|
138
|
+
return true, nil
|
|
139
|
+
}
|
|
140
|
+
if strings.Contains(err.Error(), "exit 1") {
|
|
141
|
+
return false, nil
|
|
142
|
+
}
|
|
143
|
+
return false, err
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func deleteBranch(repo, branch string) error {
|
|
147
|
+
exists, err := branchExists(repo, branch)
|
|
148
|
+
if err != nil || !exists {
|
|
149
|
+
return err
|
|
150
|
+
}
|
|
151
|
+
_, err = git(repo, "branch", "-D", branch)
|
|
152
|
+
return err
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func worktreeAdd(repo, path, branch, startPoint string) error {
|
|
156
|
+
_, err := git(repo, "worktree", "add", "-b", branch, path, startPoint)
|
|
157
|
+
return err
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func worktreeRemove(repo, path string) error {
|
|
161
|
+
_, err := git(repo, "worktree", "remove", "--force", path)
|
|
162
|
+
if err != nil {
|
|
163
|
+
_ = os.RemoveAll(path)
|
|
164
|
+
_, _ = git(repo, "worktree", "prune")
|
|
165
|
+
}
|
|
166
|
+
return err
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func mergeFFOnly(repo, commit string) error {
|
|
170
|
+
_, err := git(repo, "merge", "--ff-only", commit)
|
|
171
|
+
return err
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func cherryPickRange(repo, fromExclusive, toInclusive string) error {
|
|
175
|
+
_, err := git(repo, "cherry-pick", "-x", fromExclusive+".."+toInclusive)
|
|
176
|
+
return err
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func cherryPickAbort(repo string) {
|
|
180
|
+
_, _ = git(repo, "cherry-pick", "--abort")
|
|
181
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"regexp"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const (
|
|
14
|
+
StatusRunning = "running"
|
|
15
|
+
StatusAborted = "aborted"
|
|
16
|
+
StatusIntegrateFailed = "integrate-failed"
|
|
17
|
+
StatusComplete = "complete"
|
|
18
|
+
|
|
19
|
+
WrightPending = "pending"
|
|
20
|
+
WrightGreen = "green"
|
|
21
|
+
WrightRed = "red"
|
|
22
|
+
WrightGap = "gap"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
var (
|
|
26
|
+
slugRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
27
|
+
batchRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
28
|
+
sliceRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
29
|
+
shaRE = regexp.MustCompile(`(?i)^[0-9a-f]{7,40}$`)
|
|
30
|
+
jsonFenceRE = regexp.MustCompile("(?s)```json\\n(.*?)\\n```")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Lease is the machine-readable parallel batch lease (control tree only).
|
|
34
|
+
type Lease struct {
|
|
35
|
+
BatchID string `json:"batch_id"`
|
|
36
|
+
CreatedAt string `json:"created_at"`
|
|
37
|
+
BaseSHA string `json:"base_sha"`
|
|
38
|
+
N int `json:"n"`
|
|
39
|
+
Status string `json:"status"`
|
|
40
|
+
ControlPIDOrSession string `json:"control_pid_or_session"`
|
|
41
|
+
Slices []LeaseSlice `json:"slices"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// LeaseSlice records one worker worktree under the lease.
|
|
45
|
+
type LeaseSlice struct {
|
|
46
|
+
ID string `json:"id"`
|
|
47
|
+
Paths []string `json:"paths"`
|
|
48
|
+
WorktreePath string `json:"worktree_path"`
|
|
49
|
+
Branch string `json:"branch"`
|
|
50
|
+
WrightStatus string `json:"wright_status"`
|
|
51
|
+
TransferCommit string `json:"transfer_commit,omitempty"`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func LeasePath(repoRoot, slug string) (string, error) {
|
|
55
|
+
if err := validateSlug(slug); err != nil {
|
|
56
|
+
return "", err
|
|
57
|
+
}
|
|
58
|
+
return filepath.Join(repoRoot, ".devrites", "work", slug, "parallel-lease.md"), nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func ScratchRoot(repoRoot string) string {
|
|
62
|
+
return filepath.Join(repoRoot, ".scratch", "parallel-wt")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func WorkerBranch(slug, batchID, sliceID string) string {
|
|
66
|
+
return fmt.Sprintf("devrites/parallel/%s/%s/%s", slug, batchID, sliceID)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func IntegrateBranchName(slug, batchID string) string {
|
|
70
|
+
return fmt.Sprintf("devrites/parallel/%s/%s/integrate", slug, batchID)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func WorkerWorktreePath(repoRoot, batchID, sliceID string) string {
|
|
74
|
+
return filepath.Join(ScratchRoot(repoRoot), batchID, sliceID)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func validateSlug(slug string) error {
|
|
78
|
+
if !slugRE.MatchString(slug) {
|
|
79
|
+
return fmt.Errorf("invalid slug %q", slug)
|
|
80
|
+
}
|
|
81
|
+
return nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func validateBatchID(batchID string) error {
|
|
85
|
+
if !batchRE.MatchString(batchID) {
|
|
86
|
+
return fmt.Errorf("invalid batch id %q", batchID)
|
|
87
|
+
}
|
|
88
|
+
return nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func validateSliceID(id string) error {
|
|
92
|
+
if !sliceRE.MatchString(id) {
|
|
93
|
+
return fmt.Errorf("invalid slice id %q", id)
|
|
94
|
+
}
|
|
95
|
+
return nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func ValidateLease(lease *Lease) error {
|
|
99
|
+
if lease == nil {
|
|
100
|
+
return fmt.Errorf("lease is nil")
|
|
101
|
+
}
|
|
102
|
+
if err := validateBatchID(lease.BatchID); err != nil {
|
|
103
|
+
return err
|
|
104
|
+
}
|
|
105
|
+
if lease.CreatedAt == "" {
|
|
106
|
+
return fmt.Errorf("lease missing created_at")
|
|
107
|
+
}
|
|
108
|
+
if !shaRE.MatchString(lease.BaseSHA) {
|
|
109
|
+
return fmt.Errorf("invalid base_sha %q", lease.BaseSHA)
|
|
110
|
+
}
|
|
111
|
+
switch lease.Status {
|
|
112
|
+
case StatusRunning, StatusAborted, StatusIntegrateFailed, StatusComplete:
|
|
113
|
+
default:
|
|
114
|
+
return fmt.Errorf("invalid lease status %q", lease.Status)
|
|
115
|
+
}
|
|
116
|
+
if len(lease.Slices) < 2 || len(lease.Slices) > 3 {
|
|
117
|
+
return fmt.Errorf("lease must have 2 or 3 slices (got %d)", len(lease.Slices))
|
|
118
|
+
}
|
|
119
|
+
if lease.N != len(lease.Slices) {
|
|
120
|
+
return fmt.Errorf("lease n=%d does not match slices=%d", lease.N, len(lease.Slices))
|
|
121
|
+
}
|
|
122
|
+
seen := make(map[string]struct{}, len(lease.Slices))
|
|
123
|
+
for i, sl := range lease.Slices {
|
|
124
|
+
if err := validateSliceID(sl.ID); err != nil {
|
|
125
|
+
return fmt.Errorf("slice %d: %w", i, err)
|
|
126
|
+
}
|
|
127
|
+
if _, ok := seen[sl.ID]; ok {
|
|
128
|
+
return fmt.Errorf("duplicate slice id %q", sl.ID)
|
|
129
|
+
}
|
|
130
|
+
seen[sl.ID] = struct{}{}
|
|
131
|
+
if _, err := validateSlicePaths(sl.Paths, fmt.Sprintf("slice %q", sl.ID), ""); err != nil {
|
|
132
|
+
return err
|
|
133
|
+
}
|
|
134
|
+
switch sl.WrightStatus {
|
|
135
|
+
case "", WrightPending, WrightGreen, WrightRed, WrightGap:
|
|
136
|
+
default:
|
|
137
|
+
return fmt.Errorf("slice %q: invalid wright_status %q", sl.ID, sl.WrightStatus)
|
|
138
|
+
}
|
|
139
|
+
if sl.TransferCommit != "" && !shaRE.MatchString(sl.TransferCommit) {
|
|
140
|
+
return fmt.Errorf("slice %q: invalid transfer_commit %q", sl.ID, sl.TransferCommit)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return nil
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func EmitLeaseMarkdown(lease *Lease) (string, error) {
|
|
147
|
+
if err := ValidateLease(lease); err != nil {
|
|
148
|
+
return "", err
|
|
149
|
+
}
|
|
150
|
+
payload, err := json.MarshalIndent(lease, "", " ")
|
|
151
|
+
if err != nil {
|
|
152
|
+
return "", err
|
|
153
|
+
}
|
|
154
|
+
var b strings.Builder
|
|
155
|
+
b.WriteString("# parallel-lease.md — advisory control lease for /rite-build --parallel\n")
|
|
156
|
+
b.WriteString("# Machine block below is authoritative for helpers; YAML mirrors schema.\n\n")
|
|
157
|
+
fmt.Fprintf(&b, "batch_id: %s\n", yamlQuote(lease.BatchID))
|
|
158
|
+
fmt.Fprintf(&b, "created_at: %s\n", yamlQuote(lease.CreatedAt))
|
|
159
|
+
fmt.Fprintf(&b, "base_sha: %s\n", yamlQuote(lease.BaseSHA))
|
|
160
|
+
fmt.Fprintf(&b, "n: %d\n", lease.N)
|
|
161
|
+
fmt.Fprintf(&b, "status: %s\n", yamlQuote(lease.Status))
|
|
162
|
+
fmt.Fprintf(&b, "control_pid_or_session: %s\n", yamlQuote(lease.ControlPIDOrSession))
|
|
163
|
+
b.WriteString("slices:\n")
|
|
164
|
+
for _, sl := range lease.Slices {
|
|
165
|
+
fmt.Fprintf(&b, " - id: %s\n", yamlQuote(sl.ID))
|
|
166
|
+
if len(sl.Paths) == 0 {
|
|
167
|
+
b.WriteString(" paths: []\n")
|
|
168
|
+
} else {
|
|
169
|
+
b.WriteString(" paths:\n")
|
|
170
|
+
for _, p := range sl.Paths {
|
|
171
|
+
fmt.Fprintf(&b, " - %s\n", yamlQuote(p))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
fmt.Fprintf(&b, " worktree_path: %s\n", yamlQuote(sl.WorktreePath))
|
|
175
|
+
fmt.Fprintf(&b, " branch: %s\n", yamlQuote(sl.Branch))
|
|
176
|
+
status := sl.WrightStatus
|
|
177
|
+
if status == "" {
|
|
178
|
+
status = WrightPending
|
|
179
|
+
}
|
|
180
|
+
fmt.Fprintf(&b, " wright_status: %s\n", yamlQuote(status))
|
|
181
|
+
if sl.TransferCommit != "" {
|
|
182
|
+
fmt.Fprintf(&b, " transfer_commit: %s\n", yamlQuote(sl.TransferCommit))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
b.WriteString("\n<!-- machine-readable lease SSOT -->\n")
|
|
186
|
+
b.WriteString("```json\n")
|
|
187
|
+
b.Write(payload)
|
|
188
|
+
b.WriteString("\n```\n")
|
|
189
|
+
return b.String(), nil
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func yamlQuote(v string) string {
|
|
193
|
+
if v == "" || strings.ContainsAny(v, ":#{}[]&*!|>'\"%@`") || strings.TrimSpace(v) != v {
|
|
194
|
+
raw, _ := json.Marshal(v)
|
|
195
|
+
return string(raw)
|
|
196
|
+
}
|
|
197
|
+
return v
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func WriteLease(path string, lease *Lease) error {
|
|
201
|
+
text, err := EmitLeaseMarkdown(lease)
|
|
202
|
+
if err != nil {
|
|
203
|
+
return err
|
|
204
|
+
}
|
|
205
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
206
|
+
return err
|
|
207
|
+
}
|
|
208
|
+
tmp := path + ".tmp"
|
|
209
|
+
if err := os.WriteFile(tmp, []byte(text), 0o644); err != nil {
|
|
210
|
+
return err
|
|
211
|
+
}
|
|
212
|
+
return os.Rename(tmp, path)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func ReadLease(path string) (*Lease, error) {
|
|
216
|
+
data, err := os.ReadFile(path)
|
|
217
|
+
if err != nil {
|
|
218
|
+
return nil, err
|
|
219
|
+
}
|
|
220
|
+
match := jsonFenceRE.FindSubmatch(data)
|
|
221
|
+
if match == nil {
|
|
222
|
+
return nil, fmt.Errorf("lease missing machine-readable json fence: %s", path)
|
|
223
|
+
}
|
|
224
|
+
var lease Lease
|
|
225
|
+
if err := json.Unmarshal(match[1], &lease); err != nil {
|
|
226
|
+
return nil, fmt.Errorf("lease json invalid: %w", err)
|
|
227
|
+
}
|
|
228
|
+
if err := ValidateLease(&lease); err != nil {
|
|
229
|
+
return nil, err
|
|
230
|
+
}
|
|
231
|
+
return &lease, nil
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func ClearLease(path string) error {
|
|
235
|
+
err := os.Remove(path)
|
|
236
|
+
if err != nil && !os.IsNotExist(err) {
|
|
237
|
+
return err
|
|
238
|
+
}
|
|
239
|
+
return nil
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
func NowUTC() string {
|
|
243
|
+
return time.Now().UTC().Format(time.RFC3339)
|
|
244
|
+
}
|