Skip to content

Commit dc940c1

Browse files
fix(execution-plans): preserve retired scopes and broken views
1 parent dcdfef5 commit dc940c1

5 files changed

Lines changed: 219 additions & 7 deletions

File tree

internal/admin/dashboard/static/js/modules/execution-plans.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
executionPlanSubmitting: false,
1212
executionPlanDeactivatingID: '',
1313
executionPlanFormError: '',
14+
executionPlanHydratedScope: {
15+
scope_provider: '',
16+
scope_model: ''
17+
},
1418
guardrailRefs: [],
1519
executionPlanForm: {
1620
scope_provider: '',
@@ -73,7 +77,12 @@
7377

7478
executionPlanProviderOptions() {
7579
const options = new Set();
76-
this.models.forEach((model) => {
80+
const preservedProvider = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_provider || '').trim();
81+
if (preservedProvider) {
82+
options.add(preservedProvider);
83+
}
84+
const models = Array.isArray(this.models) ? this.models : [];
85+
models.forEach((model) => {
7786
const providerType = String(model && model.provider_type || '').trim();
7887
if (providerType) {
7988
options.add(providerType);
@@ -85,7 +94,13 @@
8594
executionPlanModelOptions(providerType) {
8695
const wantedProvider = String(providerType || '').trim();
8796
const options = new Set();
88-
this.models.forEach((model) => {
97+
const preservedProvider = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_provider || '').trim();
98+
const preservedModel = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_model || '').trim();
99+
if (wantedProvider && wantedProvider === preservedProvider && preservedModel) {
100+
options.add(preservedModel);
101+
}
102+
const models = Array.isArray(this.models) ? this.models : [];
103+
models.forEach((model) => {
89104
if (wantedProvider && String(model && model.provider_type || '').trim() !== wantedProvider) {
90105
return;
91106
}
@@ -193,11 +208,19 @@
193208
this.executionPlanNotice = '';
194209

195210
if (!plan) {
211+
this.executionPlanHydratedScope = {
212+
scope_provider: '',
213+
scope_model: ''
214+
};
196215
this.executionPlanForm = this.defaultExecutionPlanForm();
197216
this.scrollExecutionPlanFormIntoView();
198217
return;
199218
}
200219

220+
this.executionPlanHydratedScope = {
221+
scope_provider: String(plan.scope && plan.scope.scope_provider || '').trim(),
222+
scope_model: String(plan.scope && plan.scope.scope_model || '').trim()
223+
};
201224
const features = this.executionPlanSourceFeatures(plan);
202225
const guardrails = this.executionPlanSourceGuardrails(plan);
203226
this.executionPlanForm = {
@@ -223,6 +246,10 @@
223246
this.executionPlanFormOpen = false;
224247
this.executionPlanSubmitting = false;
225248
this.executionPlanFormError = '';
249+
this.executionPlanHydratedScope = {
250+
scope_provider: '',
251+
scope_model: ''
252+
};
226253
this.executionPlanForm = this.defaultExecutionPlanForm();
227254
},
228255

@@ -292,9 +319,12 @@
292319
},
293320

294321
validateExecutionPlanRequest(payload) {
322+
const preservedProvider = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_provider || '').trim();
323+
const preservedModel = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_model || '').trim();
324+
295325
if (payload.scope_provider) {
296326
const providers = this.executionPlanProviderOptions();
297-
if (!providers.includes(payload.scope_provider)) {
327+
if (!providers.includes(payload.scope_provider) && payload.scope_provider !== preservedProvider) {
298328
return 'Choose a registered provider.';
299329
}
300330
}
@@ -303,7 +333,8 @@
303333
}
304334
if (payload.scope_model) {
305335
const models = this.executionPlanModelOptions(payload.scope_provider);
306-
if (!models.includes(payload.scope_model)) {
336+
const isPreservedModel = payload.scope_provider === preservedProvider && payload.scope_model === preservedModel;
337+
if (!models.includes(payload.scope_model) && !isPreservedModel) {
307338
return 'Choose a registered model for the selected provider.';
308339
}
309340
}

internal/admin/dashboard/static/js/modules/execution-plans.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,49 @@ test('openExecutionPlanCreate hydrates features and guardrails via shared normal
125125
);
126126
});
127127

128+
test('editing a cloned workflow preserves retired provider and model options', () => {
129+
const module = createExecutionPlansModule();
130+
module.models = [
131+
{ provider_type: 'openai', model: { id: 'gpt-5' } }
132+
];
133+
module.scrollExecutionPlanFormIntoView = () => {};
134+
135+
module.openExecutionPlanCreate({
136+
scope: {
137+
scope_provider: 'anthropic',
138+
scope_model: 'claude-retired'
139+
},
140+
name: 'Retired workflow',
141+
description: 'Cloned from an older deployment',
142+
plan_payload: {
143+
features: {
144+
cache: true,
145+
audit: true,
146+
usage: true,
147+
guardrails: false
148+
},
149+
guardrails: []
150+
}
151+
});
152+
153+
assert.equal(
154+
JSON.stringify(module.executionPlanProviderOptions()),
155+
JSON.stringify(['anthropic', 'openai'])
156+
);
157+
assert.equal(
158+
JSON.stringify(module.executionPlanModelOptions('anthropic')),
159+
JSON.stringify(['claude-retired'])
160+
);
161+
assert.equal(module.validateExecutionPlanRequest(module.buildExecutionPlanRequest()), '');
162+
163+
const invalidPayload = module.buildExecutionPlanRequest();
164+
invalidPayload.scope_model = 'different-retired-model';
165+
assert.equal(
166+
module.validateExecutionPlanRequest(invalidPayload),
167+
'Choose a registered model for the selected provider.'
168+
);
169+
});
170+
128171
test('buildExecutionPlanRequest preserves blank guardrail steps as invalid so validation rejects them', () => {
129172
const module = createExecutionPlansModule();
130173
module.models = [

internal/executionplans/service.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,16 @@ func (s *Service) ListViews(ctx context.Context) ([]View, error) {
255255
for _, version := range versions {
256256
view, err := s.viewForVersion(version)
257257
if err != nil {
258-
return nil, err
258+
slog.Warn("execution plan view build failed", "version_id", strings.TrimSpace(version.ID), "error", err)
259+
views = append(views, viewWithError(version, err))
260+
continue
259261
}
260262
views = append(views, view)
261263
}
262264

263265
sort.SliceStable(views, func(i, j int) bool {
264266
left, right := views[i], views[j]
265-
if leftSpecificity, rightSpecificity := scopeSpecificity(left.Scope), scopeSpecificity(right.Scope); leftSpecificity != rightSpecificity {
267+
if leftSpecificity, rightSpecificity := viewScopeSpecificity(left.ScopeType), viewScopeSpecificity(right.ScopeType); leftSpecificity != rightSpecificity {
266268
return leftSpecificity < rightSpecificity
267269
}
268270
if left.ScopeDisplay != right.ScopeDisplay {
@@ -429,6 +431,48 @@ func (s *Service) viewForVersion(version Version) (View, error) {
429431
}, nil
430432
}
431433

434+
func viewWithError(version Version, err error) View {
435+
scope := Scope{
436+
Provider: strings.TrimSpace(version.Scope.Provider),
437+
Model: strings.TrimSpace(version.Scope.Model),
438+
}
439+
version.Scope = scope
440+
441+
return View{
442+
Version: version,
443+
ScopeType: rawScopeType(scope),
444+
ScopeDisplay: rawScopeDisplay(scope),
445+
CompileError: err.Error(),
446+
}
447+
}
448+
449+
func rawScopeType(scope Scope) string {
450+
switch {
451+
case strings.TrimSpace(scope.Provider) == "" && strings.TrimSpace(scope.Model) == "":
452+
return "global"
453+
case strings.TrimSpace(scope.Provider) != "" && strings.TrimSpace(scope.Model) == "":
454+
return "provider"
455+
default:
456+
return "provider_model"
457+
}
458+
}
459+
460+
func rawScopeDisplay(scope Scope) string {
461+
provider := strings.TrimSpace(scope.Provider)
462+
model := strings.TrimSpace(scope.Model)
463+
464+
switch {
465+
case provider == "" && model == "":
466+
return "global"
467+
case provider != "" && model == "":
468+
return provider
469+
case provider == "" && model != "":
470+
return model
471+
default:
472+
return provider + "/" + model
473+
}
474+
}
475+
432476
func scopeType(scope Scope) string {
433477
switch {
434478
case strings.TrimSpace(scope.Provider) == "":
@@ -462,6 +506,17 @@ func scopeSpecificity(scope Scope) int {
462506
}
463507
}
464508

509+
func viewScopeSpecificity(scopeType string) int {
510+
switch strings.TrimSpace(scopeType) {
511+
case "global":
512+
return 0
513+
case "provider":
514+
return 1
515+
default:
516+
return 2
517+
}
518+
}
519+
465520
func (s *Service) snapshot() snapshot {
466521
if s == nil {
467522
return snapshot{

internal/executionplans/service_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,19 @@ func (c *previewEmptyCompiler) Compile(version Version) (*CompiledPlan, error) {
181181
return c.delegate.Compile(version)
182182
}
183183

184+
type versionFailingCompiler struct {
185+
delegate Compiler
186+
version string
187+
err error
188+
}
189+
190+
func (c *versionFailingCompiler) Compile(version Version) (*CompiledPlan, error) {
191+
if version.ID == c.version {
192+
return nil, c.err
193+
}
194+
return c.delegate.Compile(version)
195+
}
196+
184197
type contextCancelingStore struct {
185198
staticStore
186199
cancelOnCreate context.CancelFunc
@@ -434,6 +447,73 @@ func TestServiceListViews_IncludesEffectiveFeatures(t *testing.T) {
434447
}
435448
}
436449

450+
func TestServiceListViews_AnnotatesCompileFailuresPerRow(t *testing.T) {
451+
store := &staticStore{
452+
versions: []Version{
453+
{
454+
ID: "global-v1",
455+
Scope: Scope{},
456+
ScopeKey: "global",
457+
Version: 1,
458+
Active: true,
459+
Name: "global",
460+
Payload: Payload{
461+
SchemaVersion: 1,
462+
Features: FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: false},
463+
},
464+
},
465+
{
466+
ID: "provider-v1",
467+
Scope: Scope{Provider: "openai"},
468+
ScopeKey: "provider:openai",
469+
Version: 1,
470+
Active: true,
471+
Name: "broken-provider",
472+
Payload: Payload{
473+
SchemaVersion: 1,
474+
Features: FeatureFlags{Cache: false, Audit: true, Usage: true, Guardrails: false},
475+
},
476+
},
477+
},
478+
}
479+
service, err := NewService(store, &versionFailingCompiler{
480+
delegate: NewCompiler(nil),
481+
version: "provider-v1",
482+
err: errors.New("compile failed for provider-v1"),
483+
})
484+
if err != nil {
485+
t.Fatalf("NewService() error = %v", err)
486+
}
487+
488+
views, err := service.ListViews(context.Background())
489+
if err != nil {
490+
t.Fatalf("ListViews() error = %v, want nil", err)
491+
}
492+
if len(views) != 2 {
493+
t.Fatalf("len(views) = %d, want 2", len(views))
494+
}
495+
496+
if views[0].ID != "global-v1" {
497+
t.Fatalf("views[0].ID = %q, want global-v1", views[0].ID)
498+
}
499+
if views[0].CompileError != "" {
500+
t.Fatalf("views[0].CompileError = %q, want empty", views[0].CompileError)
501+
}
502+
503+
if views[1].ID != "provider-v1" {
504+
t.Fatalf("views[1].ID = %q, want provider-v1", views[1].ID)
505+
}
506+
if views[1].CompileError != "compile execution plan \"provider-v1\": compile failed for provider-v1" {
507+
t.Fatalf("views[1].CompileError = %q, want wrapped compile failure", views[1].CompileError)
508+
}
509+
if views[1].ScopeType != "provider" {
510+
t.Fatalf("views[1].ScopeType = %q, want provider", views[1].ScopeType)
511+
}
512+
if views[1].ScopeDisplay != "openai" {
513+
t.Fatalf("views[1].ScopeDisplay = %q, want openai", views[1].ScopeDisplay)
514+
}
515+
}
516+
437517
func TestServiceDeactivate_RefreshesSnapshot(t *testing.T) {
438518
store := &staticStore{
439519
versions: []Version{

internal/executionplans/view.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import "gomodel/internal/core"
44

55
// View is the admin-facing representation of one active execution-plan version.
66
// It includes both the persisted payload and the effective runtime features after
7-
// process-level feature caps are applied.
7+
// process-level feature caps are applied. Broken rows are still returned with
8+
// CompileError populated so the admin API can inspect persisted workflows that
9+
// no longer compile cleanly.
810
type View struct {
911
Version
1012
ScopeType string `json:"scope_type"`
1113
ScopeDisplay string `json:"scope_display"`
1214
EffectiveFeatures core.ExecutionFeatures `json:"effective_features"`
1315
GuardrailsHash string `json:"guardrails_hash,omitempty"`
16+
CompileError string `json:"compile_error,omitempty"`
1417
}

0 commit comments

Comments
 (0)