Skip to content

Commit bea7e2b

Browse files
authored
feat(webapp,run-store): route run-graph reads and writes through the run-store router (#4237)
## Summary Run-graph data (runs, batches, waitpoints, and their related tables) can now live in a database separate from the control plane, with every read and write routed to the correct database by each run's residency. This makes reading and writing run data more reliable once the two are split, and is a no-op for single-database installs. ## Design - Run-graph table access goes through the run-store router, which selects the legacy or the new run-ops store per run instead of assuming one shared client. - The legacy run-ops client is now independently pointable, so legacy run data can be served from its own database (and replica) rather than the control-plane connection. - Run-graph writes go straight to the run-graph database instead of being forwarded through the control plane, and replication targets are split so runs in the new database still replicate to analytics without under-counting. - Read-through slots refuse the control-plane client, so a missing residency fails loudly instead of silently reading the wrong database. - Migration `20260710120000_drop_remaining_run_graph_seam_foreign_keys` drops the foreign keys that still crossed the run-graph / control-plane seam, which is what lets the two live in separate databases. The split stays off unless explicitly enabled and the two databases are confirmed physically distinct; startup fails closed otherwise. Verified by running the full dashboard end-to-end suite against both a single-database configuration and a three-database configuration (control plane, the new database, and a physically separate legacy database), with runs on both residencies. No misrouted reads in either configuration.
1 parent c235857 commit bea7e2b

67 files changed

Lines changed: 3875 additions & 1155 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Improved the reliability of how run data is read and written.

apps/webapp/app/db.server.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
assertSplitRealtimeInterlock,
2626
} from "./v3/runOpsMigration/splitMode.server";
2727
import { computeRunOpsSplitReadEnabled } from "./v3/runOpsMigration/runOpsSplitReadGate";
28+
import { assertControlPlaneCoresidencyAdvisory } from "./v3/runOpsMigration/controlPlaneCoresidencySentinel.server";
2829
import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server";
2930
import type { Span } from "@opentelemetry/api";
3031
import { context, trace } from "@opentelemetry/api";
@@ -188,13 +189,18 @@ export type RunOpsTopology = {
188189
export type SelectRunOpsTopologyConfig = {
189190
splitEnabled: boolean;
190191
legacyUrl?: string;
192+
legacyReplicaUrl?: string;
191193
newUrl?: string;
192194
newReplicaUrl?: string;
193195
};
194196
export type RunOpsClientBuilders = {
195197
controlPlane: RunOpsClients;
196198
buildNewWriter: (url: string, clientType: string) => RunOpsPrismaClient;
197199
buildNewReplica: (url: string, clientType: string) => RunOpsPrismaClient;
200+
// Legacy builders return the same PrismaClient/PrismaReplicaClient types as the control plane (no
201+
// RunOpsPrismaClient double-cast needed): the legacy DB carries the full control-plane schema.
202+
buildLegacyWriter: (url: string, clientType: string) => PrismaClient;
203+
buildLegacyReplica: (url: string, clientType: string) => PrismaReplicaClient;
198204
};
199205

200206
// Pure run-ops client selector. No env, no isSplitEnabled() — those
@@ -220,7 +226,15 @@ export function selectRunOpsTopology(
220226
return { newRunOps: cpFallback, legacyRunOps: controlPlane, controlPlane };
221227
}
222228

223-
const legacyRunOps = controlPlane;
229+
// Track 2: build an INDEPENDENT legacy client from its own DSN instead of aliasing the control
230+
// plane. legacyUrl is guaranteed present (the missing-URL branch above aliases and returns).
231+
const legacyWriter = builders.buildLegacyWriter(config.legacyUrl, "run-ops-legacy-writer");
232+
// Mirror the NEW replica + control-plane $replica fallback: brand a real replica (in the builder),
233+
// otherwise reuse the legacy WRITER so replica reads fall back to the legacy primary — unbranded.
234+
const legacyReplica: PrismaReplicaClient = config.legacyReplicaUrl
235+
? builders.buildLegacyReplica(config.legacyReplicaUrl, "run-ops-legacy-reader")
236+
: legacyWriter;
237+
const legacyRunOps: RunOpsClients = { writer: legacyWriter, replica: legacyReplica };
224238

225239
const newWriter = builders.buildNewWriter(config.newUrl, "run-ops-new-writer");
226240
const newReplica: RunOpsPrismaClient = config.newReplicaUrl
@@ -246,10 +260,19 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
246260
// Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on.
247261
const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.RUN_OPS_LEGACY_DATABASE_URL;
248262

263+
// Without a dedicated legacy replica URL, legacy reads fall back to the legacy WRITER (primary).
264+
// Surface that so a prod misdeploy is observable instead of a silent load shift onto the primary.
265+
if (splitEnabled && !env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL) {
266+
logger.warn(
267+
"RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is unset while split is enabled; legacy reads will hit the legacy primary"
268+
);
269+
}
270+
249271
return selectRunOpsTopology(
250272
{
251273
splitEnabled,
252274
legacyUrl: env.RUN_OPS_LEGACY_DATABASE_URL,
275+
legacyReplicaUrl: env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL,
253276
newUrl,
254277
newReplicaUrl: env.RUN_OPS_DATABASE_READ_REPLICA_URL,
255278
},
@@ -268,6 +291,18 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
268291
tagDatasourceRunOps("replica", buildRunOpsReplicaClient({ url, clientType }))
269292
)
270293
),
294+
// Legacy client shares the exact control-plane wrapper stack (the legacy DB carries the full
295+
// control-plane schema); markReadReplicaClient only on a real replica URL, as with the NEW replica.
296+
buildLegacyWriter: (url, clientType) =>
297+
captureInfrastructureErrors(
298+
tagDatasource("writer", buildWriterClient({ url, clientType }))
299+
),
300+
buildLegacyReplica: (url, clientType) =>
301+
markReadReplicaClient(
302+
captureInfrastructureErrors(
303+
tagDatasource("replica", buildReplicaClient({ url, clientType }))
304+
)
305+
),
271306
}
272307
);
273308
});
@@ -281,8 +316,17 @@ export const runOpsNewPrisma: PrismaClient = runOpsTopology.newRunOps
281316
.writer as unknown as PrismaClient;
282317
export const runOpsNewReplica: PrismaReplicaClient = runOpsTopology.newRunOps
283318
.replica as unknown as PrismaReplicaClient;
319+
// Track 2: under split-on these point at the INDEPENDENT legacy client (its own DSN); under split-off
320+
// or missing URLs they still alias the control-plane client, so single-DB installs are unchanged.
284321
export const runOpsLegacyPrisma: PrismaClient = runOpsTopology.legacyRunOps.writer;
285322
export const runOpsLegacyReplica: PrismaReplicaClient = runOpsTopology.legacyRunOps.replica;
323+
// Branded legacy handles typed as RunOpsPrismaClient for the run-store boundary — same underlying
324+
// legacy writer/replica as runOpsLegacyPrisma/runOpsLegacyReplica above, but carrying the run-ops
325+
// brand so the guard classifies provably-legacy access as `runops`, not `cp`.
326+
export const runOpsLegacyPrismaClient: RunOpsPrismaClient = runOpsTopology.legacyRunOps
327+
.writer as unknown as RunOpsPrismaClient;
328+
export const runOpsLegacyReplicaClient: RunOpsPrismaClient = runOpsTopology.legacyRunOps
329+
.replica as unknown as RunOpsPrismaClient;
286330

287331
export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
288332
newReplica: runOpsNewReplicaClient,
@@ -295,8 +339,8 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
295339

296340
// Boot-time interlock: if the flag is on but the distinct-DB sentinel does not
297341
// confirm two physically-distinct run-ops DBs, refuse to enable split (data-loss
298-
// interlock). Async, so it cannot live in the synchronous singleton factory —
299-
// call it from the eager-boot path before any run-ops routing is wired.
342+
// interlock). Async, so it cannot live in the synchronous singleton factory — called
343+
// fire-and-forget from the eager-boot path (routing is wired synchronously at module load).
300344
export async function assertRunOpsSplitSentinel(): Promise<void> {
301345
if (!env.RUN_OPS_SPLIT_ENABLED) return;
302346
// Realtime interlock (synchronous): Electric replicates only from the control-plane
@@ -312,6 +356,9 @@ export async function assertRunOpsSplitSentinel(): Promise<void> {
312356
"RUN_OPS_SPLIT_ENABLED is on but the distinct-DB sentinel did not confirm two physically-distinct run-ops DBs; refusing to enable split (data-loss interlock)."
313357
);
314358
}
359+
// Advisory-only (T2.3): observe legacy vs control-plane co-residency. Emits a metric + log and only
360+
// throws when RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT is on AND co-residency is positively confirmed.
361+
await assertControlPlaneCoresidencyAdvisory();
315362
}
316363

317364
function getClient() {

apps/webapp/app/env.server.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,10 @@ const EnvironmentSchema = z
138138
.string()
139139
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid")
140140
.optional(),
141-
// The LEGACY run-ops DB (the control-plane DB during the transition). When unset, legacy
142-
// run-ops reuses the existing DATABASE_URL (legacy run-ops == control-plane DB initially).
141+
// The LEGACY run-ops DB. Now a CONNECTED DSN (Track 2): when split is on and this is set it builds
142+
// an INDEPENDENT legacy Prisma client, no longer an alias of the control-plane client (nor merely
143+
// the sentinel's probe target). Unset -> legacy reuses the control-plane client / DATABASE_URL, so
144+
// single-DB and self-host installs boot byte-identical.
143145
RUN_OPS_LEGACY_DATABASE_URL: z
144146
.string()
145147
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_URL is invalid")
@@ -151,6 +153,22 @@ const EnvironmentSchema = z
151153
.string()
152154
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_READ_REPLICA_URL is invalid")
153155
.optional(),
156+
// The LEGACY run-ops DB read replica (Track 2). Unset -> the legacy replica handle falls back to the
157+
// legacy WRITER (as $replica does with no CP replica). Set in production so legacy reads hit the reader.
158+
RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL: z
159+
.string()
160+
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is invalid")
161+
.optional(),
162+
// Direct DSN for applying the full @trigger.dev/database migrations to the LEGACY run-ops DB, keeping
163+
// its schema current after the control plane moves off it. Direct, not pooled — migrations never run
164+
// over a pooler. Optional; unset -> the entrypoint's legacy migrate step is skipped.
165+
RUN_OPS_LEGACY_DIRECT_URL: z
166+
.string()
167+
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DIRECT_URL is invalid")
168+
.optional(),
169+
// Advisory control-plane co-residency sentinel enforcement (Track 2, T2.3). Default OFF; the advisory
170+
// arm always emits its metric, this only turns a still-co-resident pair into a hard boot failure.
171+
RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT: BoolEnv.default(false),
154172
// --- Control-plane datasource repoint. Additive-only. ---
155173
// Optional control-plane DB. Unset (self-host/single-DB) -> getClient()/getReplicaClient() fall back to
156174
// DATABASE_URL/DATABASE_READ_REPLICA_URL, so boot is byte-identical. When set, these point at the
@@ -1618,6 +1636,14 @@ const EnvironmentSchema = z
16181636
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
16191637
RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"),
16201638

1639+
// Connection URL for the LEGACY runs-replication source (the runs-CDC slot on the legacy runs DB, plus
1640+
// the admin recovery route). Direct, not pooled: replication can't run over a pooler. Optional; unset ->
1641+
// falls back to DATABASE_URL, so nothing changes today.
1642+
RUN_REPLICATION_LEGACY_DATABASE_URL: z
1643+
.string()
1644+
.refine(isValidDatabaseUrl, "RUN_REPLICATION_LEGACY_DATABASE_URL is invalid")
1645+
.optional(),
1646+
16211647
// --- Run-ops DB split — second replication source (the NEW dedicated run-ops DB). ---
16221648
// Cloud-only; only consulted when isSplitEnabled() is true. Self-host never sets these.
16231649
// Connection URL for the run-ops DB used by the runs-replication source. Required when the split is
@@ -1658,6 +1684,12 @@ const EnvironmentSchema = z
16581684
SESSION_REPLICATION_PUBLICATION_NAME: z
16591685
.string()
16601686
.default("sessions_to_clickhouse_v1_publication"),
1687+
// Connection URL for the sessions-replication slot. Direct, not pooled: replication can't run over a
1688+
// pooler. Optional; unset -> falls back to DATABASE_URL, so nothing changes today.
1689+
SESSION_REPLICATION_DATABASE_URL: z
1690+
.string()
1691+
.refine(isValidDatabaseUrl, "SESSION_REPLICATION_DATABASE_URL is invalid")
1692+
.optional(),
16611693
SESSION_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(1),
16621694
SESSION_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
16631695
SESSION_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),

apps/webapp/app/models/waitpointTag.server.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Prisma } from "@trigger.dev/database";
2-
import { prisma } from "~/db.server";
2+
import { runStore } from "~/v3/runStore.server";
33

44
export const MAX_TAGS_PER_WAITPOINT = 10;
55
const MAX_RETRIES = 3;
@@ -19,19 +19,10 @@ export async function createWaitpointTag({
1919

2020
while (attempts < MAX_RETRIES) {
2121
try {
22-
return await prisma.waitpointTag.upsert({
23-
where: {
24-
environmentId_name: {
25-
environmentId,
26-
name: tag,
27-
},
28-
},
29-
create: {
30-
name: tag,
31-
environmentId,
32-
projectId,
33-
},
34-
update: {},
22+
return await runStore.upsertWaitpointTag({
23+
environmentId,
24+
name: tag,
25+
projectId,
3526
});
3627
} catch (error) {
3728
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {

apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import type { TaskRunExecutionResult } from "@trigger.dev/core/v3";
22
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
33
import { executionResultForTaskRun } from "~/models/taskRun.server";
44
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
5-
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
5+
import { runStore as defaultRunStore } from "~/v3/runStore.server";
66
import { BasePresenter } from "./basePresenter.server";
77

88
type ApiRunResultReadThroughDeps = {
99
splitEnabled?: boolean;
1010
newClient?: PrismaReplicaClient;
11-
// LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to this._replica.
11+
// LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to runOpsLegacyReplica
12+
// (the Aurora legacy read replica), never the control-plane replica.
1213
legacyReplica?: PrismaReplicaClient;
1314
isPastRetention?: (runId: string) => boolean;
1415
};
@@ -17,7 +18,8 @@ export class ApiRunResultPresenter extends BasePresenter {
1718
constructor(
1819
prisma?: PrismaClientOrTransaction,
1920
replica?: PrismaClientOrTransaction,
20-
private readonly _readThrough?: ApiRunResultReadThroughDeps
21+
private readonly _readThrough?: ApiRunResultReadThroughDeps,
22+
private readonly runStore = defaultRunStore
2123
) {
2224
super(prisma, replica);
2325
}
@@ -27,31 +29,14 @@ export class ApiRunResultPresenter extends BasePresenter {
2729
env: AuthenticatedEnvironment
2830
): Promise<TaskRunExecutionResult | undefined> {
2931
return this.traceWithEnv("call", env, async (span) => {
30-
const findRun = (client: PrismaReplicaClient) =>
31-
client.taskRun.findFirst({
32-
where: { friendlyId, runtimeEnvironmentId: env.id },
33-
include: { attempts: { orderBy: { createdAt: "desc" } } },
34-
});
35-
36-
// Single-run result poll routed through run-ops read-through. Split on: primary store first,
37-
// then the secondary read replica for runs that miss on new; past-retention ids return
38-
// undefined -> the route's normal 404. Split off (single-DB / self-host): readThroughRun does
39-
// one plain findFirst against the single client (passthrough).
40-
const result = await readThroughRun({
41-
runId: friendlyId,
42-
environmentId: env.id,
43-
readNew: findRun,
44-
readLegacy: findRun,
45-
deps: {
46-
splitEnabled: this._readThrough?.splitEnabled,
47-
newClient: this._readThrough?.newClient ?? (this._prisma as PrismaReplicaClient),
48-
legacyReplica: this._readThrough?.legacyReplica ?? (this._replica as PrismaReplicaClient),
49-
isPastRetention: this._readThrough?.isPastRetention,
50-
},
51-
});
52-
53-
const taskRun =
54-
result.source === "new" || result.source === "legacy-replica" ? result.value : undefined;
32+
// Single-run result poll routed through the run store, which selects the owning DB by
33+
// run-id residency (id shape): a run-ops (NEW) id reads the new store, a cuid (LEGACY) id
34+
// reads the legacy store. Single-DB / self-host collapses to one plain findFirst against the
35+
// one store (passthrough). The identical TaskRun(+attempts) lookup runs inside the router.
36+
const taskRun = await this.runStore.findRun(
37+
{ friendlyId, runtimeEnvironmentId: env.id },
38+
{ include: { attempts: { orderBy: { createdAt: "desc" } } } }
39+
);
5540

5641
if (!taskRun) {
5742
return undefined;

apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts

Lines changed: 29 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { BasePresenter } from "./basePresenter.server";
55
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
66
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
77
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
8-
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
8+
import { runStore as defaultRunStore } from "~/v3/runStore.server";
99

10-
// When omitted, clients default to the inherited _replica handle => passthrough reads the
11-
// replica exactly as today. isPastRetention is injectable for tests. Typed PrismaReplicaClient
12-
// to match readThroughRun's readNew/readLegacy + deps.
10+
// Retained only to preserve the public constructor signature the route passes. Run-ops routing
11+
// (NEW vs LEGACY residency, replica reads) is now handled inside the injected `runStore`, so
12+
// these deps are no longer consulted for the read.
1313
type ApiWaitpointPresenterReadThroughDeps = {
1414
newClient?: PrismaReplicaClient;
1515
legacyReplica?: PrismaReplicaClient;
@@ -21,7 +21,8 @@ export class ApiWaitpointPresenter extends BasePresenter {
2121
constructor(
2222
prismaClient?: PrismaClientOrTransaction,
2323
replicaClient?: PrismaClientOrTransaction,
24-
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps
24+
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps,
25+
private readonly runStore = defaultRunStore
2526
) {
2627
super(prismaClient, replicaClient);
2728
}
@@ -39,56 +40,32 @@ export class ApiWaitpointPresenter extends BasePresenter {
3940
waitpointId: string
4041
) {
4142
return this.trace("call", async (span) => {
42-
// Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY
43-
// RUN-OPS READ REPLICA ONLY on a new-probe miss — never the legacy primary.
44-
// Split off (single-DB / self-host): one plain waitpoint.findFirst against the replica
45-
// (passthrough). The waitpointId is the residency-classifiable run-ops id (the route
46-
// pre-decodes the friendlyId via WaitpointId.toId).
47-
const hydrate = (client: PrismaReplicaClient) =>
48-
client.waitpoint.findFirst({
49-
where: {
50-
id: waitpointId,
51-
environmentId: environment.id,
52-
},
53-
select: {
54-
id: true,
55-
friendlyId: true,
56-
type: true,
57-
status: true,
58-
idempotencyKey: true,
59-
userProvidedIdempotencyKey: true,
60-
idempotencyKeyExpiresAt: true,
61-
inactiveIdempotencyKey: true,
62-
output: true,
63-
outputType: true,
64-
outputIsError: true,
65-
completedAfter: true,
66-
completedAt: true,
67-
createdAt: true,
68-
tags: true,
69-
},
70-
});
71-
72-
const result = await readThroughRun({
73-
runId: waitpointId,
74-
environmentId: environment.id,
75-
readNew: (client) => hydrate(client),
76-
readLegacy: (replica) => hydrate(replica),
77-
deps: {
78-
splitEnabled: this.readThroughDeps?.splitEnabled,
79-
// Default both clients to the inherited _replica handle (declared
80-
// PrismaClientOrTransaction but $replica at runtime) so passthrough reads the replica
81-
// as today; split mode injects a distinct newClient.
82-
newClient: this.readThroughDeps?.newClient ?? (this._replica as PrismaReplicaClient),
83-
legacyReplica:
84-
this.readThroughDeps?.legacyReplica ?? (this._replica as PrismaReplicaClient),
85-
isPastRetention: this.readThroughDeps?.isPastRetention,
43+
// The store routes by the waitpointId's residency (id shape) and reads the owning
44+
// store's replica. waitpointId is pre-decoded from the friendlyId via WaitpointId.toId.
45+
const waitpoint = await this.runStore.findWaitpoint({
46+
where: {
47+
id: waitpointId,
48+
environmentId: environment.id,
49+
},
50+
select: {
51+
id: true,
52+
friendlyId: true,
53+
type: true,
54+
status: true,
55+
idempotencyKey: true,
56+
userProvidedIdempotencyKey: true,
57+
idempotencyKeyExpiresAt: true,
58+
inactiveIdempotencyKey: true,
59+
output: true,
60+
outputType: true,
61+
outputIsError: true,
62+
completedAfter: true,
63+
completedAt: true,
64+
createdAt: true,
65+
tags: true,
8666
},
8767
});
8868

89-
const waitpoint =
90-
result.source === "new" || result.source === "legacy-replica" ? result.value : null;
91-
9269
if (!waitpoint) {
9370
logger.error(`WaitpointPresenter: Waitpoint not found`, {
9471
id: waitpointId,

0 commit comments

Comments
 (0)