devrites 4.2.0 → 4.4.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 +6 -0
- package/README.md +1 -1
- package/docs/cli.md +15 -0
- package/docs/engine/commands.md +20 -2
- package/docs/orchestration.md +6 -3
- package/engine/commands.go +1 -1
- package/engine/internal/lib/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/open_visual.go +254 -0
- package/engine/internal/lib/open_visual_test.go +280 -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/visual_outline.go +163 -0
- package/engine/internal/lib/visual_outline_test.go +161 -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 +76 -3
- package/engine/root_routing_test.go +50 -15
- package/engine/testdata/visual/open-visual-smoke.html +675 -0
- package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
- package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- 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-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- 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-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- 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-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
- package/package.json +1 -1
- package/scripts/install-lib.sh +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// CreateOpts configures parallel worktree creation.
|
|
13
|
+
type CreateOpts struct {
|
|
14
|
+
Root string
|
|
15
|
+
Slug string
|
|
16
|
+
BatchID string
|
|
17
|
+
BaseSHA string
|
|
18
|
+
Session string
|
|
19
|
+
Slices []SlicePaths
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Create path-disjoint-gates, creates <=3 worktrees from base, and writes a running lease.
|
|
23
|
+
func Create(opts CreateOpts) (*Lease, error) {
|
|
24
|
+
if err := validateSlug(opts.Slug); err != nil {
|
|
25
|
+
return nil, err
|
|
26
|
+
}
|
|
27
|
+
if err := validateBatchID(opts.BatchID); err != nil {
|
|
28
|
+
return nil, err
|
|
29
|
+
}
|
|
30
|
+
if len(opts.Slices) < 2 || len(opts.Slices) > 3 {
|
|
31
|
+
return nil, fmt.Errorf("create requires 2 or 3 slices (got %d)", len(opts.Slices))
|
|
32
|
+
}
|
|
33
|
+
if _, err := CheckPathDisjoint(opts.Slices, opts.Root); err != nil {
|
|
34
|
+
return nil, err
|
|
35
|
+
}
|
|
36
|
+
base, err := revParse(opts.Root, opts.BaseSHA)
|
|
37
|
+
if err != nil {
|
|
38
|
+
return nil, fmt.Errorf("base sha: %w", err)
|
|
39
|
+
}
|
|
40
|
+
leasePath, err := LeasePath(opts.Root, opts.Slug)
|
|
41
|
+
if err != nil {
|
|
42
|
+
return nil, err
|
|
43
|
+
}
|
|
44
|
+
if _, err := os.Stat(leasePath); err == nil {
|
|
45
|
+
return nil, fmt.Errorf("lease already exists: %s", leasePath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
session := opts.Session
|
|
49
|
+
if session == "" {
|
|
50
|
+
session = fmt.Sprintf("pid:%d", os.Getpid())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createdPaths := make([]string, 0, len(opts.Slices))
|
|
54
|
+
createdBranches := make([]string, 0, len(opts.Slices))
|
|
55
|
+
cleanupPartial := func() {
|
|
56
|
+
for _, p := range createdPaths {
|
|
57
|
+
_ = worktreeRemove(opts.Root, p)
|
|
58
|
+
}
|
|
59
|
+
for _, b := range createdBranches {
|
|
60
|
+
_ = deleteBranch(opts.Root, b)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
slices := make([]LeaseSlice, 0, len(opts.Slices))
|
|
65
|
+
for _, sp := range opts.Slices {
|
|
66
|
+
if err := validateSliceID(sp.ID); err != nil {
|
|
67
|
+
cleanupPartial()
|
|
68
|
+
return nil, err
|
|
69
|
+
}
|
|
70
|
+
paths, err := validateSlicePaths(sp.Paths, fmt.Sprintf("slice %q", sp.ID), opts.Root)
|
|
71
|
+
if err != nil {
|
|
72
|
+
cleanupPartial()
|
|
73
|
+
return nil, err
|
|
74
|
+
}
|
|
75
|
+
branch := WorkerBranch(opts.Slug, opts.BatchID, sp.ID)
|
|
76
|
+
wt := WorkerWorktreePath(opts.Root, opts.BatchID, sp.ID)
|
|
77
|
+
if _, err := os.Stat(wt); err == nil {
|
|
78
|
+
cleanupPartial()
|
|
79
|
+
return nil, fmt.Errorf("worktree path already exists: %s", wt)
|
|
80
|
+
}
|
|
81
|
+
if err := os.MkdirAll(filepath.Dir(wt), 0o755); err != nil {
|
|
82
|
+
cleanupPartial()
|
|
83
|
+
return nil, err
|
|
84
|
+
}
|
|
85
|
+
if err := worktreeAdd(opts.Root, wt, branch, base); err != nil {
|
|
86
|
+
cleanupPartial()
|
|
87
|
+
return nil, fmt.Errorf("worktree add %s: %w", sp.ID, err)
|
|
88
|
+
}
|
|
89
|
+
createdPaths = append(createdPaths, wt)
|
|
90
|
+
createdBranches = append(createdBranches, branch)
|
|
91
|
+
slices = append(slices, LeaseSlice{
|
|
92
|
+
ID: sp.ID,
|
|
93
|
+
Paths: paths,
|
|
94
|
+
WorktreePath: wt,
|
|
95
|
+
Branch: branch,
|
|
96
|
+
WrightStatus: WrightPending,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lease := &Lease{
|
|
101
|
+
BatchID: opts.BatchID,
|
|
102
|
+
CreatedAt: NowUTC(),
|
|
103
|
+
BaseSHA: base,
|
|
104
|
+
N: len(slices),
|
|
105
|
+
Status: StatusRunning,
|
|
106
|
+
ControlPIDOrSession: session,
|
|
107
|
+
Slices: slices,
|
|
108
|
+
}
|
|
109
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
110
|
+
cleanupPartial()
|
|
111
|
+
return nil, err
|
|
112
|
+
}
|
|
113
|
+
return lease, nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// RecordGreen marks a slice green with its transfer commit.
|
|
117
|
+
func RecordGreen(repoRoot, slug, sliceID, commit string) (*Lease, error) {
|
|
118
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return nil, err
|
|
121
|
+
}
|
|
122
|
+
lease, err := ReadLease(leasePath)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return nil, err
|
|
125
|
+
}
|
|
126
|
+
if lease.Status != StatusRunning {
|
|
127
|
+
return nil, fmt.Errorf("record-green requires status=running (have %s)", lease.Status)
|
|
128
|
+
}
|
|
129
|
+
sha, err := revParse(repoRoot, commit)
|
|
130
|
+
if err != nil {
|
|
131
|
+
return nil, fmt.Errorf("transfer commit: %w", err)
|
|
132
|
+
}
|
|
133
|
+
found := false
|
|
134
|
+
for i := range lease.Slices {
|
|
135
|
+
if lease.Slices[i].ID == sliceID {
|
|
136
|
+
lease.Slices[i].WrightStatus = WrightGreen
|
|
137
|
+
lease.Slices[i].TransferCommit = sha
|
|
138
|
+
found = true
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if !found {
|
|
143
|
+
return nil, fmt.Errorf("slice not in lease: %s", sliceID)
|
|
144
|
+
}
|
|
145
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
146
|
+
return nil, err
|
|
147
|
+
}
|
|
148
|
+
return lease, nil
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Abort marks the lease aborted and restores control tip to base_sha.
|
|
152
|
+
func Abort(repoRoot, slug string, force bool) (*Lease, error) {
|
|
153
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return nil, err
|
|
156
|
+
}
|
|
157
|
+
lease, err := ReadLease(leasePath)
|
|
158
|
+
if err != nil {
|
|
159
|
+
return nil, err
|
|
160
|
+
}
|
|
161
|
+
base, err := revParse(repoRoot, lease.BaseSHA)
|
|
162
|
+
if err != nil {
|
|
163
|
+
return nil, err
|
|
164
|
+
}
|
|
165
|
+
if err := ensureControlAtBase(repoRoot, base, force); err != nil {
|
|
166
|
+
return nil, err
|
|
167
|
+
}
|
|
168
|
+
lease.Status = StatusAborted
|
|
169
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
170
|
+
return nil, err
|
|
171
|
+
}
|
|
172
|
+
return lease, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func ensureControlAtBase(repoRoot, base string, force bool) error {
|
|
176
|
+
head, err := headSHA(repoRoot)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return err
|
|
179
|
+
}
|
|
180
|
+
if head == base {
|
|
181
|
+
return nil
|
|
182
|
+
}
|
|
183
|
+
dirty, err := porcelainDirty(repoRoot)
|
|
184
|
+
if err != nil {
|
|
185
|
+
return err
|
|
186
|
+
}
|
|
187
|
+
if dirty && !force {
|
|
188
|
+
return fmt.Errorf("control tree dirty; refuse reset to base (pass --force to override)")
|
|
189
|
+
}
|
|
190
|
+
if err := resetHard(repoRoot, base); err != nil {
|
|
191
|
+
return err
|
|
192
|
+
}
|
|
193
|
+
head, err = headSHA(repoRoot)
|
|
194
|
+
if err != nil {
|
|
195
|
+
return err
|
|
196
|
+
}
|
|
197
|
+
if head != base {
|
|
198
|
+
return fmt.Errorf("failed to leave control at base %s", base)
|
|
199
|
+
}
|
|
200
|
+
return nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// IntegrateOpts configures staging integrate.
|
|
204
|
+
type IntegrateOpts struct {
|
|
205
|
+
Root string
|
|
206
|
+
Slug string
|
|
207
|
+
ApplyToControl bool
|
|
208
|
+
Force bool
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Integrate all-or-nothing applies sibling transfer commits onto a staging branch.
|
|
212
|
+
func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
|
|
213
|
+
leasePath, err := LeasePath(opts.Root, opts.Slug)
|
|
214
|
+
if err != nil {
|
|
215
|
+
return "", nil, err
|
|
216
|
+
}
|
|
217
|
+
lease, err = ReadLease(leasePath)
|
|
218
|
+
if err != nil {
|
|
219
|
+
return "", nil, err
|
|
220
|
+
}
|
|
221
|
+
if lease.Status != StatusRunning {
|
|
222
|
+
return "", nil, fmt.Errorf("integrate requires status=running (have %s)", lease.Status)
|
|
223
|
+
}
|
|
224
|
+
base, err := revParse(opts.Root, lease.BaseSHA)
|
|
225
|
+
if err != nil {
|
|
226
|
+
return "", nil, err
|
|
227
|
+
}
|
|
228
|
+
if err := ensureControlAtBase(opts.Root, base, opts.Force); err != nil {
|
|
229
|
+
return "", nil, err
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for _, sl := range lease.Slices {
|
|
233
|
+
if sl.WrightStatus != WrightGreen {
|
|
234
|
+
return "", nil, fmt.Errorf("slice %s is not green", sl.ID)
|
|
235
|
+
}
|
|
236
|
+
if sl.TransferCommit == "" {
|
|
237
|
+
return "", nil, fmt.Errorf("slice %s missing transfer_commit", sl.ID)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
ibranch := IntegrateBranchName(opts.Slug, lease.BatchID)
|
|
242
|
+
stageWT := filepath.Join(ScratchRoot(opts.Root), lease.BatchID, "integrate")
|
|
243
|
+
_ = os.RemoveAll(stageWT)
|
|
244
|
+
if exists, _ := branchExists(opts.Root, ibranch); exists {
|
|
245
|
+
_ = deleteBranch(opts.Root, ibranch)
|
|
246
|
+
}
|
|
247
|
+
if err := os.MkdirAll(filepath.Dir(stageWT), 0o755); err != nil {
|
|
248
|
+
return "", nil, err
|
|
249
|
+
}
|
|
250
|
+
if err := worktreeAdd(opts.Root, stageWT, ibranch, base); err != nil {
|
|
251
|
+
return "", nil, fmt.Errorf("staging worktree: %w", err)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fail := func(reason error) (string, *Lease, error) {
|
|
255
|
+
_ = worktreeRemove(opts.Root, stageWT)
|
|
256
|
+
_ = deleteBranch(opts.Root, ibranch)
|
|
257
|
+
_, _ = git(opts.Root, "worktree", "prune")
|
|
258
|
+
lease.Status = StatusIntegrateFailed
|
|
259
|
+
_ = WriteLease(leasePath, lease)
|
|
260
|
+
_ = ensureControlAtBase(opts.Root, base, true)
|
|
261
|
+
return "", lease, reason
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for _, sl := range lease.Slices {
|
|
265
|
+
tc, err := revParse(opts.Root, sl.TransferCommit)
|
|
266
|
+
if err != nil {
|
|
267
|
+
return fail(fmt.Errorf("slice %s transfer: %w", sl.ID, err))
|
|
268
|
+
}
|
|
269
|
+
ok, err := isAncestor(opts.Root, base, tc)
|
|
270
|
+
if err != nil {
|
|
271
|
+
return fail(err)
|
|
272
|
+
}
|
|
273
|
+
if !ok {
|
|
274
|
+
return fail(fmt.Errorf("transfer_commit for %s is not a descendant of base", sl.ID))
|
|
275
|
+
}
|
|
276
|
+
got, err := diffNames(opts.Root, base, tc)
|
|
277
|
+
if err != nil {
|
|
278
|
+
return fail(err)
|
|
279
|
+
}
|
|
280
|
+
want := append([]string(nil), sl.Paths...)
|
|
281
|
+
sort.Strings(want)
|
|
282
|
+
sort.Strings(got)
|
|
283
|
+
if !pathListsEqual(want, got) {
|
|
284
|
+
return fail(fmt.Errorf("exact-path proof failed for slice %s: want=%v got=%v", sl.ID, want, got))
|
|
285
|
+
}
|
|
286
|
+
for _, p := range got {
|
|
287
|
+
if p == ".devrites" || strings.HasPrefix(p, ".devrites/") {
|
|
288
|
+
return fail(fmt.Errorf("path proof failed: .devrites path in transfer: %s", p))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
stageHead, err := headSHA(stageWT)
|
|
293
|
+
if err != nil {
|
|
294
|
+
return fail(err)
|
|
295
|
+
}
|
|
296
|
+
canFF, err := isAncestor(opts.Root, stageHead, tc)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return fail(err)
|
|
299
|
+
}
|
|
300
|
+
if canFF {
|
|
301
|
+
if err := mergeFFOnly(stageWT, tc); err != nil {
|
|
302
|
+
return fail(fmt.Errorf("ff-only integrate failed for slice %s: %w", sl.ID, err))
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
if err := cherryPickRange(stageWT, base, tc); err != nil {
|
|
306
|
+
cherryPickAbort(stageWT)
|
|
307
|
+
return fail(fmt.Errorf("cherry-pick replay failed for slice %s: %w", sl.ID, err))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
tip, err = headSHA(stageWT)
|
|
313
|
+
if err != nil {
|
|
314
|
+
return fail(err)
|
|
315
|
+
}
|
|
316
|
+
_ = worktreeRemove(opts.Root, stageWT)
|
|
317
|
+
_, _ = git(opts.Root, "worktree", "prune")
|
|
318
|
+
|
|
319
|
+
if opts.ApplyToControl {
|
|
320
|
+
if err := mergeFFOnly(opts.Root, tip); err != nil {
|
|
321
|
+
return fail(fmt.Errorf("control fast-forward to integrate tip failed: %w", err))
|
|
322
|
+
}
|
|
323
|
+
} else if err := ensureControlAtBase(opts.Root, base, opts.Force); err != nil {
|
|
324
|
+
return fail(err)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
lease.Status = StatusComplete
|
|
328
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
329
|
+
return "", nil, err
|
|
330
|
+
}
|
|
331
|
+
return tip, lease, nil
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func pathListsEqual(a, b []string) bool {
|
|
335
|
+
if len(a) != len(b) {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
for i := range a {
|
|
339
|
+
if a[i] != b[i] {
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Cleanup removes worker worktrees/branches and clears the lease (complete or --force).
|
|
347
|
+
func Cleanup(repoRoot, slug string, force bool) error {
|
|
348
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
349
|
+
if err != nil {
|
|
350
|
+
return err
|
|
351
|
+
}
|
|
352
|
+
if _, err := os.Stat(leasePath); os.IsNotExist(err) {
|
|
353
|
+
return nil
|
|
354
|
+
}
|
|
355
|
+
lease, err := ReadLease(leasePath)
|
|
356
|
+
if err != nil {
|
|
357
|
+
return err
|
|
358
|
+
}
|
|
359
|
+
if lease.Status != StatusComplete && !force {
|
|
360
|
+
return fmt.Errorf("cleanup refuses status=%s without --force (abort keeps workers for diagnosis)", lease.Status)
|
|
361
|
+
}
|
|
362
|
+
for _, sl := range lease.Slices {
|
|
363
|
+
if sl.WorktreePath != "" {
|
|
364
|
+
_ = worktreeRemove(repoRoot, sl.WorktreePath)
|
|
365
|
+
_ = os.RemoveAll(sl.WorktreePath)
|
|
366
|
+
}
|
|
367
|
+
if sl.Branch != "" {
|
|
368
|
+
_ = deleteBranch(repoRoot, sl.Branch)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
ibranch := IntegrateBranchName(slug, lease.BatchID)
|
|
372
|
+
_ = deleteBranch(repoRoot, ibranch)
|
|
373
|
+
_ = os.RemoveAll(filepath.Join(ScratchRoot(repoRoot), lease.BatchID))
|
|
374
|
+
_, _ = git(repoRoot, "worktree", "prune")
|
|
375
|
+
return ClearLease(leasePath)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// StatusReport returns a human-readable lease status.
|
|
379
|
+
func StatusReport(repoRoot, slug string) (string, error) {
|
|
380
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
381
|
+
if err != nil {
|
|
382
|
+
return "", err
|
|
383
|
+
}
|
|
384
|
+
if _, err := os.Stat(leasePath); os.IsNotExist(err) {
|
|
385
|
+
return fmt.Sprintf("status: no lease at %s\n", leasePath), nil
|
|
386
|
+
}
|
|
387
|
+
lease, err := ReadLease(leasePath)
|
|
388
|
+
if err != nil {
|
|
389
|
+
return "", err
|
|
390
|
+
}
|
|
391
|
+
var b strings.Builder
|
|
392
|
+
fmt.Fprintf(&b, "lease: %s\n", leasePath)
|
|
393
|
+
fmt.Fprintf(&b, "status: %s\n", lease.Status)
|
|
394
|
+
fmt.Fprintf(&b, "base_sha: %s\n", lease.BaseSHA)
|
|
395
|
+
for _, sl := range lease.Slices {
|
|
396
|
+
tc := sl.TransferCommit
|
|
397
|
+
if tc == "" {
|
|
398
|
+
tc = "-"
|
|
399
|
+
}
|
|
400
|
+
fmt.Fprintf(&b, "slice %s: wright=%s wt=%s branch=%s transfer=%s\n",
|
|
401
|
+
sl.ID, sl.WrightStatus, sl.WorktreePath, sl.Branch, tc)
|
|
402
|
+
}
|
|
403
|
+
if head, err := headSHA(repoRoot); err == nil {
|
|
404
|
+
fmt.Fprintf(&b, "control_HEAD: %s\n", head)
|
|
405
|
+
}
|
|
406
|
+
return b.String(), nil
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// LeaseJSON returns indented lease JSON.
|
|
410
|
+
func LeaseJSON(lease *Lease) (string, error) {
|
|
411
|
+
b, err := json.MarshalIndent(lease, "", " ")
|
|
412
|
+
if err != nil {
|
|
413
|
+
return "", err
|
|
414
|
+
}
|
|
415
|
+
return string(b), nil
|
|
416
|
+
}
|