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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Detached workflows use one controller root

Detached Boatstack workflows now read, write, and verify generated feature state under the same external controller root.

Use `boatstack-helper attach --repo . --force` once to import a valid older embedded open-feature package. Boatstack verifies fingerprints and copies it atomically. It stops on conflicting packages or stale receipts and does not delete the embedded source.
42 changes: 29 additions & 13 deletions labs/12-product-engineering-loop/product-engineering-loop/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ type AttachOptions struct {

// AttachResult is the deterministic outcome of an attach request.
type AttachResult struct {
SchemaVersion int `json:"schema_version"`
VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
Mode string `json:"mode,omitempty"`
RepoID string `json:"repo_id,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
Reason string `json:"reason"`
SchemaVersion int `json:"schema_version"`
VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
Mode string `json:"mode,omitempty"`
RepoID string `json:"repo_id,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
Reason string `json:"reason"`
FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"`
}

func blockedAttach(reason string) AttachResult {
Expand Down Expand Up @@ -62,12 +63,22 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) {

ctx := detachedContextFromIdentity(stateRoot, identity)

// Synthesize configuration from the repository (test command, default branch,
// context) exactly as embedded init does.
config := defaultConfig(root, detectTestCommand(root))
rawConfig, err := MarshalJSON(config)
// Prefer the repository's declared source configuration during explicit
// reattachment. Falling back to discovery is valid only when no source exists.
configPath := filepath.Join(root, sourceConfigName)
config, rawConfig, err := LoadConfig(configPath)
if os.IsNotExist(err) {
config = defaultConfig(root, detectTestCommand(root))
rawConfig, err = MarshalJSON(config)
}
if err != nil {
return blockedAttach(err.Error()), nil
return blockedAttach("Boatstack could not load the repository source configuration: " + err.Error()), nil
}
imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx)
if migrationErr != nil {
result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error())
result.FeatureMigrations = migrationResults
return result, nil
}

// Generate the controller bundle and write it under the external control root.
Expand All @@ -86,6 +97,10 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) {
if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil {
return blockedAttach(err.Error()), nil
}
migrationResults, err = applyDetachedFeatureImports(imports, migrationResults)
if err != nil {
return blockedAttach("Boatstack could not import embedded feature state: " + err.Error()), nil
}

// Write the binding and index it in the registry.
binding := DetachedBinding{
Expand Down Expand Up @@ -132,6 +147,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) {
RepoRoot: root,
ControlRoot: ctx.controlRoot,
WorktreeID: identity.WorktreeID,
FeatureMigrations: migrationResults,
Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.",
}, nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ func checkPlanCommand(arguments []string) int {
readinessFingerprint := ""
if version, _ := check.Plan["schema_version"].(float64); version >= 3 {
readiness, readinessErr := boatstack.CheckPlanReadiness(*plan)
repo, _ := boatstack.ResolveRepository(filepath.Dir(*plan))
repo, _ := boatstack.ResolveControllerRepository(filepath.Dir(*plan))
if readinessErr != nil {
boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error())
return fail(readinessErr)
Expand Down Expand Up @@ -1102,7 +1102,16 @@ func doctorCommand(arguments []string) int {
if err := boatstack.DoctorRepairHint(boatstack.Doctor(*repo)); err != nil {
return fail(err)
}
root, err := boatstack.ResolveRepository(*repo)
if err != nil {
return fail(err)
}
ctx, err := boatstack.ResolveWorkspaceContext(root)
if err != nil {
return fail(err)
}
fmt.Printf("PASS: Boatstack %s installation and generated adapters are healthy\n", boatstack.Version)
fmt.Printf("SUPERVISION_MODE=%s\nCONTROLLER_ROOT=%s\nHEALTH=VERIFIED\n", ctx.Mode, ctx.ExportRoot())
hosts, err := boatstack.DoctorHookHosts(*repo)
if err != nil {
return fail(err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func ProjectOperatorContext(repoPath, operation, host string) (OperatorContext,
}
out := OperatorContext{
SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded),
RepoRoot: root, Operation: operation, Host: host,
RepoRoot: root, ControlRoot: root, Operation: operation, Host: host,
}

if ctx, ok, verifyErr := detachedContextFor(root); verifyErr != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ func archiveDeliveryReceipt(repo, feature, sliceID, gate, observationID string)
}

func appendChangeObservation(repo string, observation ChangeObservation) error {
path := filepath.Join(repo, ".product-loop", "features", observation.Feature, "changes.md")
path := filepath.Join(WorkspaceFor(repo).FeatureDir(observation.Feature), "changes.md")
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
Expand All @@ -577,7 +577,7 @@ func appendChangeObservation(repo string, observation ChangeObservation) error {
}

func nextChangeObservationID(repo, feature string, fallback int) string {
path := filepath.Join(repo, ".product-loop", "features", feature, "changes.md")
path := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "changes.md")
value, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("CHG-%03d", fallback)
Expand Down Expand Up @@ -625,7 +625,7 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio
evidenceHash := SHA256Bytes([]byte(strings.TrimSpace(options.Evidence)))
mechanismHash := SHA256Bytes([]byte(strings.TrimSpace(options.Mechanism)))
if repairClass {
changePath := filepath.Join(repo, ".product-loop", "features", options.Feature, "changes.md")
changePath := filepath.Join(WorkspaceFor(repo).FeatureDir(options.Feature), "changes.md")
if prior, readErr := os.ReadFile(changePath); readErr == nil {
for _, block := range strings.Split(string(prior), "\n## ") {
if strings.Contains(block, "- Classification: `"+classification+"`") &&
Expand Down Expand Up @@ -905,7 +905,7 @@ func resolveAddressableSliceByBranch(state DeliveryState, branch string) (int, D
}

func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error {
lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json")
lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json")
lockHash, err := SHA256File(lockPath)
if err != nil {
return fmt.Errorf("managed delivery requires its current plan lock: %w", err)
Expand Down Expand Up @@ -1109,7 +1109,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error
}
evidencePath := strings.TrimSpace(options.EvidencePath)
if evidencePath == "" {
evidencePath = featureEvidencePath(filepath.Join(repo, ".product-loop", "features", options.Feature))
evidencePath = featureEvidencePath(WorkspaceFor(repo).FeatureDir(options.Feature))
} else if !filepath.IsAbs(evidencePath) {
evidencePath = filepath.Join(repo, evidencePath)
}
Expand All @@ -1129,7 +1129,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error
if recorded := deliveryEvidenceGateStatus(string(evidenceValue), gateLabel, slice.ID, explicit); recorded != status {
return DeliveryGateReceipt{}, fmt.Errorf("evidence ledger must mark the %s gate for delivery slice %s as %s; found %q", gate, slice.ID, status, recorded)
}
relEvidence, err := repositoryRelativePath(repo, evidencePath)
relEvidence, err := repositoryRelativePath(WorkspaceFor(repo).ExportRoot(), evidencePath)
if err != nil {
return DeliveryGateReceipt{}, err
}
Expand Down Expand Up @@ -1451,7 +1451,7 @@ type DiscardDeliveryResult struct {
// orphan, so discard-delivery must clear it. It refuses a dir carrying a
// plan.lock.json (a registered, live feature) so it never touches active work.
func discardOrphanFeatureArtifacts(repo, feature string) (DiscardDeliveryResult, bool, error) {
dir := filepath.Join(repo, ".product-loop", "features", feature)
dir := WorkspaceFor(repo).FeatureDir(feature)
info, statErr := os.Stat(dir)
if os.IsNotExist(statErr) {
return DiscardDeliveryResult{}, false, nil
Expand Down
Loading
Loading