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
12 changes: 11 additions & 1 deletion frontend/src/__tests__/ladder-timeline-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
jest.mock('../api/client', () => ({ apiRequest: jest.fn() }));

import { getLadderEvents, type LadderEvent } from '../api/ladder';
import { getLadderEvents, getLadderRuns, type LadderEvent, type LadderRun } from '../api/ladder';
import { apiRequest } from '../api/client';

beforeEach(() => jest.resetAllMocks());
Expand Down Expand Up @@ -31,3 +31,13 @@ test('OD cross-check compares the displayed od_usd_hr values and tolerates a pre
jest.mocked(apiRequest).mockResolvedValueOnce({ total_count: 1, total_usd_hr: '5.000000', events: [legacy] });
expect(await getLadderEvents('account', 'aws')).toHaveLength(1);
});

// #1792: legacy rows say "cancelled", new ones "canceled"; the loader returns one spelling.
test('loaders normalize both stored spellings of canceled', async () => {
const event: LadderEvent = { id: 'old', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr', payment_option: 'no-upfront', status: 'cancelled', run_status: 'cancelled', revision: 0, amount_usd_hr: '1.000000', scheduled_date: '2030-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z' };
jest.mocked(apiRequest).mockResolvedValueOnce({ total_count: 3, total_usd_hr: '3.000000', events: [event, { ...event, id: 'new', status: 'canceled', run_status: 'canceled' }, { ...event, id: 'live', status: 'scheduled', run_status: 'planned' }] });
expect((await getLadderEvents('account', 'aws')).map(e => [e.status, e.run_status])).toEqual([['canceled', 'canceled'], ['canceled', 'canceled'], ['scheduled', 'planned']]);
const run = { id: 'r1', config_id: 'config', status: 'cancelled', started_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', baseline_usd_hr: null, target_usd_hr: null, existing_usd_hr: null, actions: [] } as unknown as LadderRun;
jest.mocked(apiRequest).mockResolvedValueOnce({ total_count: 2, runs: [run, { ...run, id: 'r2', status: 'executing' }] });
expect((await getLadderRuns('account', 'aws')).map(r => r.status)).toEqual(['canceled', 'executing']);
});
12 changes: 6 additions & 6 deletions frontend/src/__tests__/ladder-timeline-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ test('eligibility covers status, execution, deadline, and unavailable revision A
test('ladder labels cover every known enum and show unknown codes raw', () => {
expect(['ec2-instance-sp', 'compute-sp', 'convertible-ri', 'azure-reservation', 'azure-savings-plan'].map(v => ladderLabel('layer_type', v)))
.toEqual(['EC2 Instance Savings Plan', 'Compute Savings Plan', 'Convertible RI', 'Azure Reservation', 'Azure Savings Plan']);
expect(['scheduled', 'fired', 'completed', 'cancelled', 'failed'].map(v => ladderLabel('status', v)))
.toEqual(['Scheduled', 'Fired', 'Completed', 'Cancelled', 'Failed']);
expect(['scheduled', 'fired', 'completed', 'canceled', 'failed'].map(v => ladderLabel('status', v)))
.toEqual(['Scheduled', 'Fired', 'Completed', 'Canceled', 'Failed']);
expect(['1yr', '3yr'].map(v => ladderLabel('term', v))).toEqual(['1 Year', '3 Years']);
expect(['no-upfront', 'partial-upfront', 'all-upfront'].map(v => ladderLabel('payment_option', v))).toEqual(['No Upfront', 'Partial Upfront', 'All Upfront']);
expect(ladderLabel('layer_type', 'new-thing')).toBe('new-thing');
Expand All @@ -66,7 +66,7 @@ test('sub-0.0001 amounts are not shown as $0.0000 and edit reasons use readable
expect(formatLadderAmount('0.000040')).toBe('<$0.0001/hr');
expect(formatLadderAmount('0.000100')).toBe('$0.0001/hr');
expect(ladderEditReason({ ...event, run_status: 'awaiting_approval' })).toBe('Run is awaiting approval.');
expect(ladderEditReason({ ...event, status: 'cancelled' })).toBe('Tranche is cancelled.');
expect(ladderEditReason({ ...event, status: 'canceled' })).toBe('Tranche is canceled.');
});

// FIXTURE: hand-written tranches.
Expand All @@ -81,15 +81,15 @@ test('ladderCumulative carries each layer forward on shared times and keeps zero
expect([...result.rowTotals]).toEqual([['a', 2500000n], ['b', 3500000n], ['c', 3500000n]]);
});

// FIXTURE: hand-written statuses; the gate repro was scheduled 1.0 + cancelled 5.0 + failed 7.0 showing $13.00/hr instead of $1.00/hr.
// FIXTURE: hand-written statuses; the gate repro was scheduled 1.0 + canceled 5.0 + failed 7.0 showing $13.00/hr instead of $1.00/hr.
test('ladderCumulative counts only scheduled, fired and completed tranches (#698 gate)', () => {
const at = (id: string, status: string, amount: string, day: number) => ({ ...event, id, status, amount_usd_hr: amount, scheduled_date: `2030-01-0${day}T00:00:00Z` });
const rows = [at('s', 'scheduled', '1.000000', 1), at('c', 'cancelled', '5.000000', 2), at('f', 'failed', '7.000000', 3), at('x', 'weird', '9.000000', 4), at('fi', 'fired', '2.000000', 5), at('d', 'completed', '3.000000', 6)];
const rows = [at('s', 'scheduled', '1.000000', 1), at('c', 'canceled', '5.000000', 2), at('f', 'failed', '7.000000', 3), at('x', 'weird', '9.000000', 4), at('fi', 'fired', '2.000000', 5), at('d', 'completed', '3.000000', 6)];
const result = ladderCumulative(rows, 0n);
expect(result.totals).toEqual([1000000n, 3000000n, 6000000n]);
expect([...result.rowTotals]).toEqual([['s', 1000000n], ['c', null], ['f', null], ['x', null], ['fi', 3000000n], ['d', 6000000n]]);
expect(rows.map(ladderNotCountedNote)).toEqual([undefined, 'Not counted', 'Not counted', 'Not counted (unknown status)', undefined, undefined]);
expect(ladderCumulative([at('c', 'cancelled', '5.000000', 1)], 0n).times).toEqual([]);
expect(ladderCumulative([at('c', 'canceled', '5.000000', 1)], 0n).times).toEqual([]);
});

test('typed UTC filter dates parse strictly and whole days cover both edges (issue #753)', () => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ test('a missing design token fails loudly instead of painting a default color (#
// FIXTURE: hand-written tranches reproducing the gate finding (status filter cleared so all statuses are visible).
test('cancelled, failed and unknown tranches stay visible but are not counted in chart, summary or table (#698 gate)', async () => {
const row = (id: string, status: string, amount: string, day: number) => ({ ...event, id, status, amount_usd_hr: amount, scheduled_date: `2030-01-0${day}T00:00:00Z` });
jest.mocked(getLadderEvents).mockResolvedValue([row('s', 'scheduled', '1.000000', 1), row('c', 'cancelled', '5.000000', 2), row('f', 'failed', '7.000000', 3), row('x', 'weird', '9.000000', 4)]);
jest.mocked(getLadderEvents).mockResolvedValue([row('s', 'scheduled', '1.000000', 1), row('c', 'canceled', '5.000000', 2), row('f', 'failed', '7.000000', 3), row('x', 'weird', '9.000000', 4)]);
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const status = document.querySelector<HTMLSelectElement>('[data-filter="status"]')!;
status.value = ''; status.dispatchEvent(new Event('change'));
Expand All @@ -336,7 +336,7 @@ test('layers are told apart by point shape as well as color, and a selection wit
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const config = jest.mocked(Chart).mock.calls[0]![1] as { data: { datasets: { pointStyle: string }[] } };
expect(new Set(config.data.datasets.map(d => d.pointStyle)).size).toBe(2);
jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, status: 'cancelled' }]);
jest.mocked(getLadderEvents).mockResolvedValue([{ ...event, status: 'canceled' }]);
document.body.innerHTML = '<section id="timeline"></section>';
jest.mocked(Chart).mockClear();
await initLadderTimeline(document.getElementById('timeline')!, [], false);
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/api/ladder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,18 @@ async function completeTimeline<T extends LadderEvent | LadderRun>(
return items;
}

export function getLadderEvents(accountID: string, provider: string): Promise<LadderEvent[]> {
return completeTimeline<LadderEvent>('tranches', accountID, provider);
// Rows written before cloud-commitments-go#554 keep the legacy "cancelled";
// one spelling here keeps labels, filters and counts on a single value (#1792).
const canceledSpelling = (status: string): string => (status === 'cancelled' ? 'canceled' : status);

export async function getLadderEvents(accountID: string, provider: string): Promise<LadderEvent[]> {
const events = await completeTimeline<LadderEvent>('tranches', accountID, provider);
return events.map(event => ({ ...event, status: canceledSpelling(event.status), run_status: canceledSpelling(event.run_status) }));
}

export function getLadderRuns(accountID: string, provider: string): Promise<LadderRun[]> {
return completeTimeline<LadderRun>('runs', accountID, provider);
export async function getLadderRuns(accountID: string, provider: string): Promise<LadderRun[]> {
const runs = await completeTimeline<LadderRun>('runs', accountID, provider);
return runs.map(run => ({ ...run, status: canceledSpelling(run.status) }));
}

export interface LadderAmendment {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/ladder-timeline-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ const layerLabels: Record<string, string> = {
'azure-reservation': 'Azure Reservation', 'azure-savings-plan': 'Azure Savings Plan',
};
const statusLabels: Record<string, string> = {
scheduled: 'Scheduled', fired: 'Fired', completed: 'Completed', cancelled: 'Cancelled', failed: 'Failed',
scheduled: 'Scheduled', fired: 'Fired', completed: 'Completed', canceled: 'Canceled', failed: 'Failed',
};

const runStatusLabels: Record<string, string> = {
planned: 'planned', awaiting_approval: 'awaiting approval', approved: 'approved', executing: 'executing',
completed: 'completed', failed: 'failed', cancelled: 'cancelled', expired: 'expired',
completed: 'completed', failed: 'failed', canceled: 'canceled', expired: 'expired',
};

/** Label for a ladder enum code. An unknown code is shown raw so it stays visible. */
Expand Down Expand Up @@ -130,9 +130,9 @@ export function formatLadderUtcInput(value: string): string {
return `${date} ${time.endsWith(':00') ? time.slice(0, 5) : time}`;
}

/** Statuses that count toward cumulative commitment; cancelled and failed tranches were never bought. */
/** Statuses that count toward cumulative commitment; canceled and failed tranches were never bought. */
export const ladderCountedStatuses: readonly string[] = ['scheduled', 'fired', 'completed'];
export const LADDER_COUNTED_NOTE = 'Counts scheduled, fired and completed tranches; cancelled, failed and unrecognised statuses are excluded.';
export const LADDER_COUNTED_NOTE = 'Counts scheduled, fired and completed tranches; canceled, failed and unrecognised statuses are excluded.';

/** Note for a row that is excluded from the cumulative, or undefined when it is counted. */
export function ladderNotCountedNote(event: LadderEvent): string | undefined {
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261106191535-c83aef8b18a9
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107014806-0b677e19ae94
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107014806-0b677e19ae94
github.com/aws/aws-lambda-go v1.55.1
github.com/aws/aws-sdk-go-v2/service/kms v1.62.1
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261106191535-c83aef8b18a9 h1:omDAY4B4wshoedBYE368TW05d0PR2HTnebPgu8WqIFw=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:l9V+ecJCfAigYr6XiK9kpIUemF9bnXOKHJN1s4TPsM0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261106191535-c83aef8b18a9 h1:z4LNOkM5qTNq7dbzfdh0VdsSW4BTxRuVaXjOZfRADe0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:uVh1sdSIwKbHCWZ/TA66m2cR9Odb/36sxDHNKLyuMKY=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261106191535-c83aef8b18a9 h1:OISjPdyg7xcy8kCJelHJle4YnTMQycAWzglVi6L15uw=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:X+73EdStvYV6pqEVZZFyMfLG1frORlO9B1IsuK0LfOs=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261106191535-c83aef8b18a9 h1:7Vz6QVVlwe0GG7TT2+4tQp9HBBCZ0YkfEDwFZOF+Ir8=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261106191535-c83aef8b18a9/go.mod h1:kfbQ5CsDr1958iSuZq6edcuB0es62XRCXGn/hFFYBW0=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107014806-0b677e19ae94 h1:4AYCeaAqeNBKKLs6Q/FeIRWXCWFUe59/RiBrY9O39Xw=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261107014806-0b677e19ae94/go.mod h1:l9V+ecJCfAigYr6XiK9kpIUemF9bnXOKHJN1s4TPsM0=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107014806-0b677e19ae94 h1:J7e8eVErWWG+yP80X2GAv5/dTTXQemejLXC1qYsvBWk=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261107014806-0b677e19ae94/go.mod h1:uVh1sdSIwKbHCWZ/TA66m2cR9Odb/36sxDHNKLyuMKY=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107014806-0b677e19ae94 h1:55Yb/A/GF5yDCH+AxUnHbVZFMFNKMVjxipkgxgBLwss=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261107014806-0b677e19ae94/go.mod h1:h6EewdM42n97XHBC3/aMxZFAMUOoEla4Y18TDYtfC6w=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107014806-0b677e19ae94 h1:7k9LzEBsSzF2HQFlUDDm6AMj/hs4VKLbL6MoC3Ghkq4=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261107014806-0b677e19ae94/go.mod h1:kfbQ5CsDr1958iSuZq6edcuB0es62XRCXGn/hFFYBW0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
Expand Down
28 changes: 28 additions & 0 deletions internal/api/handler_purchases_retry_ladder_canceled_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package api

import (
"context"
"errors"
"testing"

"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// #1792: LockLadderTrancheForRetryTx returns the stored tranche status raw
// (store_postgres_ladder_reconcile.go). Rows written before
// cloud-commitments-go#554 keep the double-l spelling, new ones write "canceled"; Retry
// must refuse a canceled tranche under either spelling.
func TestGuardLadderTrancheForRetry_RefusesBothCanceledSpellings(t *testing.T) {
for _, stored := range []string{"canceled", "cancelled"} { //nolint:misspell // legacy stored ladder status value
t.Run(stored, func(t *testing.T) {
store := new(MockConfigStore)
store.On("LockLadderTrancheForRetryTx", mock.Anything, mock.Anything, "tranche-key").
Return(ladder.TrancheStatus(stored), true, nil)
h := &Handler{config: store}
err := h.guardLadderTrancheForRetry(context.Background(), nil, "tranche-key")
require.True(t, errors.Is(err, errRetryLadderTrancheSettled), "a %q tranche must refuse Retry, got %v", stored, err)
})
}
}
9 changes: 6 additions & 3 deletions internal/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9349,10 +9349,12 @@ components:
enum: [all-upfront, partial-upfront, no-upfront]
status:
type: string
enum: [scheduled, fired, completed, cancelled, failed]
description: New rows write "canceled"; rows written before cloud-commitments-go#554 keep "cancelled".
enum: [scheduled, fired, completed, canceled, cancelled, failed]
run_status:
type: string
enum: [planned, awaiting_approval, approved, executing, completed, failed, cancelled, expired]
description: New rows write "canceled"; rows written before cloud-commitments-go#554 keep "cancelled".
enum: [planned, awaiting_approval, approved, executing, completed, failed, canceled, cancelled, expired]
execution_id:
type: string
description: Omitted until the tranche has an execution.
Expand Down Expand Up @@ -9405,7 +9407,8 @@ components:
format: uuid
status:
type: string
enum: [planned, awaiting_approval, approved, executing, completed, failed, cancelled, expired]
description: New rows write "canceled"; rows written before cloud-commitments-go#554 keep "cancelled".
enum: [planned, awaiting_approval, approved, executing, completed, failed, canceled, cancelled, expired]
started_at:
type: string
format: date-time
Expand Down
2 changes: 1 addition & 1 deletion internal/config/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ type StoreInterface interface {
// current status), so callers can distinguish a race from a hard error.
// Statuses are typed ladder.RunStatus so callers cannot pass an arbitrary
// string that would never match a stored status. A transition to
// RunStatusCancelled is refused (ErrLadderRunCancelViaTransition).
// RunStatusCanceled is refused (ErrLadderRunCancelViaTransition).
//
// CancelLadderRun is the only way to cancel a ladder run: a CAS UPDATE
// from one of fromStatuses to cancelled that stamps cancelled_by with the
Expand Down
10 changes: 10 additions & 0 deletions internal/config/recommendations_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ func SuppressionTupleKey(accountID, provider, service, region, resourceType, eng
return accountID + sep + provider + sep + service + sep + region + sep + resourceType + sep + engine
}

// SuppressionEngineSpellings returns every engine spelling a stored rec can
// carry that CanonicalSuppressionEngine maps to canonical.
func SuppressionEngineSpellings(provider, service, canonical string) []string {
out := []string{canonical}
if CanonicalSuppressionEngine(provider, service, "") == canonical && canonical != "" {
out = append(out, "")
}
return out
}

// SuppressionServiceSpellings returns every service spelling a stored rec
// can carry that CanonicalSuppressionService maps to canonical for provider:
// canonical itself (when it maps to itself) and its legacy and dash-free
Expand Down
6 changes: 3 additions & 3 deletions internal/config/store_postgres_ladder.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ func cancelOverdueLadderTranchesTx(ctx context.Context, tx pgx.Tx, configID *str
AND status = $3
AND execution_id IS NULL
AND scheduled_date <= $4
`, string(ladder.TrancheStatusCancelled), *configID, string(ladder.TrancheStatusScheduled), ladderGraceCutoff(asOf))
`, string(ladder.TrancheStatusCanceled), *configID, string(ladder.TrancheStatusScheduled), ladderGraceCutoff(asOf))
if err != nil {
return fmt.Errorf("failed to cancel overdue ladder_tranches config_id=%s: %w", *configID, err)
}
Expand Down Expand Up @@ -438,11 +438,11 @@ func (s *PostgresStore) LatestLadderRunStartedAt(ctx context.Context, configID s
// one of fromStatuses to toStatus via a CAS UPDATE. Returns the updated row
// on success, or (nil, nil) when zero rows are affected (race lost or
// unexpected current status). A hard DB error is returned as a non-nil error.
// A transition to RunStatusCancelled is refused with
// A transition to RunStatusCanceled is refused with
// ErrLadderRunCancelViaTransition: cancels go through CancelLadderRun, which
// stamps cancelled_by in the same UPDATE.
func (s *PostgresStore) TransitionLadderRunStatus(ctx context.Context, id string, fromStatuses []ladder.RunStatus, toStatus ladder.RunStatus) (*LadderRunDB, error) {
if toStatus == ladder.RunStatusCancelled {
if toStatus == ladder.RunStatusCanceled {
return nil, ErrLadderRunCancelViaTransition
}
// Typed public signature; the stringly-typed shape stays at the DB boundary.
Expand Down
Loading
Loading