@memberjunction/schema-engine 6.1.0-edge.2 → 6.1.0-edge.4

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.
@@ -9,7 +9,7 @@
9
9
  * In-memory concurrency mutex (one operation at a time).
10
10
  */
11
11
  import { BaseSingleton } from '@memberjunction/global';
12
- import { LogError, Metadata } from '@memberjunction/core';
12
+ import { LogError, Metadata, RunView, } from '@memberjunction/core';
13
13
  import { Octokit } from '@octokit/rest';
14
14
  import { RSUMetrics } from './RSUMetrics.js';
15
15
  import { GetDialect } from './DDLGenerator.js';
@@ -80,9 +80,6 @@ class RSUConfig {
80
80
  get IsAuditLogEnabled() {
81
81
  return process.env.RSU_AUDIT_LOG_ENABLED !== '0';
82
82
  }
83
- get PendingWorkPath() {
84
- return process.env.RSU_PENDING_WORK_PATH || '.rsu_pending';
85
- }
86
83
  get ProtectedSchemas() {
87
84
  const envSchemas = process.env.RSU_PROTECTED_SCHEMAS;
88
85
  return envSchemas ? envSchemas.split(',').map((s) => s.trim()) : [];
@@ -145,9 +142,20 @@ export class RuntimeSchemaManager extends BaseSingleton {
145
142
  this._outOfSyncSince = null;
146
143
  this._lastRunAt = null;
147
144
  this._lastRunResult = null;
145
+ /** Names of deprecated members already warned about, so a hot path logs once, not per call. */
146
+ this.warnedDeprecations = new Set();
148
147
  this._currentStepName = null;
149
148
  this._currentStepIndex = null;
150
149
  this._stepTotal = null;
150
+ // ── Pipeline observer ──────────────────────────────────────────────
151
+ /**
152
+ * Optional observer notified of every step and run boundary. Set once at process startup (see
153
+ * the RSU progress bridge in MJServer) — a single observer is sufficient because RSU runs are
154
+ * serialized by the pipeline lock, so events can never interleave between runs.
155
+ *
156
+ * Never awaited, never allowed to fail the pipeline. See {@link RSUPipelineObserver}.
157
+ */
158
+ this.PipelineObserver = null;
151
159
  /** Waiters queued behind the current lock holder. */
152
160
  this._lockWaiters = [];
153
161
  this._dbLockId = null;
@@ -202,41 +210,164 @@ export class RuntimeSchemaManager extends BaseSingleton {
202
210
  return this._additionalSchemaInfoPath ?? rsuConfig.AdditionalSchemaInfoPath;
203
211
  }
204
212
  // ─── Pending Work (post-restart tasks) ─────────────────────────
205
- /** Write pending work to disk so it can be processed after restart. */
206
- async WritePendingWork(data) {
207
- const { writeFileSync, mkdirSync } = await import('node:fs');
208
- const { join } = await import('node:path');
209
- const dir = join(rsuConfig.WorkDir, rsuConfig.PendingWorkPath);
210
- mkdirSync(dir, { recursive: true });
211
- const filePath = join(dir, `${Date.now()}.json`);
212
- writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
213
- this.rsuLog(`Wrote pending work to ${filePath}`);
214
- return filePath;
213
+ /**
214
+ * Register pending work durably in `MJ: RSU Pending Works` so it survives the
215
+ * post-migration restart. Returns the new row's ID.
216
+ *
217
+ * Unlike the `.rsu_pending` files this replaces, the row is NOT consumed on read —
218
+ * it stays Pending until the consumer explicitly calls {@link CompletePendingWork}
219
+ * or {@link FailPendingWork}, so a crash mid-consumption leaves visible, resumable work.
220
+ */
221
+ async WritePendingWork(data, contextUser, provider) {
222
+ // `contextUser` is optional only to keep the published 1-arg signature compiling
223
+ // (see the deprecation note above the class). A durable row cannot be written
224
+ // without a user, and silently pretending to have persisted the work would defeat
225
+ // the entire point of this queue — so the 1-arg form fails loudly instead.
226
+ if (!contextUser) {
227
+ throw new Error('RuntimeSchemaManager.WritePendingWork now persists to `MJ: RSU Pending Works` and requires a contextUser. ' +
228
+ 'Call WritePendingWork(data, contextUser) — the previous 1-argument form wrote a .rsu_pending file and no longer exists.');
229
+ }
230
+ const md = provider ?? new Metadata();
231
+ const row = await md.GetEntityObject('MJ: RSU Pending Works', contextUser);
232
+ row.NewRecord();
233
+ row.CompanyIntegrationID = data.CompanyIntegrationID;
234
+ row.PayloadJSON = JSON.stringify(data);
235
+ row.Status = 'Pending';
236
+ if (!(await row.Save())) {
237
+ throw new Error(`Failed to register RSU pending work: ${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
238
+ }
239
+ this.rsuLog(`Registered pending work ${row.ID} for company integration ${data.CompanyIntegrationID}`);
240
+ return row.ID;
215
241
  }
216
- /** Read all pending work files, return them, and delete them. */
217
- async ReadAndClearPendingWork() {
218
- const { readdirSync, readFileSync, unlinkSync, existsSync } = await import('node:fs');
219
- const { join } = await import('node:path');
220
- const dir = join(rsuConfig.WorkDir, rsuConfig.PendingWorkPath);
221
- if (!existsSync(dir))
242
+ /**
243
+ * Read every still-Pending work row. Never deletes or mutates — the caller marks each
244
+ * row Completed/Failed once it has actually finished processing it. Rows whose payload
245
+ * fails to parse are reported as Failed so they stop being retried forever.
246
+ *
247
+ * @param staleAfterMinutes when set, rows created more than this many minutes ago are
248
+ * logged as stale so an operator can see work that a previous process abandoned.
249
+ */
250
+ async ReadPendingWork(contextUser, staleAfterMinutes, provider) {
251
+ // Read through the SAME provider the caller writes through. Defaulting to the global
252
+ // provider here would read a different connection's rows than WritePendingWork just
253
+ // wrote, which is the whole reason the provider is threaded through these methods.
254
+ const rv = new RunView(provider);
255
+ const result = await rv.RunView({
256
+ EntityName: 'MJ: RSU Pending Works',
257
+ ExtraFilter: `Status = 'Pending'`,
258
+ OrderBy: '__mj_CreatedAt ASC',
259
+ ResultType: 'entity_object',
260
+ BypassCache: true,
261
+ }, contextUser);
262
+ if (!result.Success) {
263
+ LogError(`[RSU] Failed to read pending work: ${result.ErrorMessage}`);
222
264
  return [];
223
- const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
224
- const results = [];
225
- for (const file of files) {
226
- const filePath = join(dir, file);
265
+ }
266
+ const items = [];
267
+ for (const row of result.Results) {
268
+ const createdAt = row.__mj_CreatedAt ?? new Date();
227
269
  try {
228
- const data = JSON.parse(readFileSync(filePath, 'utf-8'));
229
- results.push(data);
230
- unlinkSync(filePath);
270
+ items.push({ ID: row.ID, Work: JSON.parse(row.PayloadJSON), CreatedAt: createdAt });
231
271
  }
232
- catch {
233
- /* skip corrupt files */
272
+ catch (err) {
273
+ await this.FailPendingWork(row.ID, `Corrupt PayloadJSON: ${err instanceof Error ? err.message : String(err)}`, contextUser, provider);
234
274
  }
235
275
  }
236
- if (results.length > 0) {
237
- this.rsuLog(`Found ${results.length} pending work item(s)`);
276
+ if (staleAfterMinutes !== undefined) {
277
+ const cutoff = Date.now() - staleAfterMinutes * 60_000;
278
+ const stale = items.filter((i) => i.CreatedAt.getTime() < cutoff);
279
+ if (stale.length > 0) {
280
+ LogError(`[RSU] ${stale.length} pending work item(s) older than ${staleAfterMinutes} minute(s) are still unprocessed: ${stale.map((s) => s.ID).join(', ')}`);
281
+ }
238
282
  }
239
- return results;
283
+ if (items.length > 0)
284
+ this.rsuLog(`Found ${items.length} pending work item(s)`);
285
+ return items;
286
+ }
287
+ /**
288
+ * @deprecated Use {@link ReadPendingWork} instead, and mark each item terminal with
289
+ * {@link CompletePendingWork} / {@link FailPendingWork} once you have processed it.
290
+ *
291
+ * Retained with its original signature so consumers taking this minor upgrade still
292
+ * compile. It is NO LONGER FUNCTIONAL: pending work lives in `MJ: RSU Pending Works`
293
+ * rather than in `.rsu_pending` files, and reading it requires a context user this
294
+ * signature has no way to supply. Returns an empty array — the value that previously
295
+ * meant "no pending work" — rather than pretending to have drained a queue.
296
+ */
297
+ async ReadAndClearPendingWork() {
298
+ this.warnDeprecatedOnce('ReadAndClearPendingWork', 'RuntimeSchemaManager.ReadAndClearPendingWork() is deprecated and no longer functional. ' +
299
+ 'Pending work is now durable in `MJ: RSU Pending Works`; call ReadPendingWork(contextUser) and then ' +
300
+ 'CompletePendingWork/FailPendingWork per item. Returning an empty array.');
301
+ return [];
302
+ }
303
+ warnDeprecatedOnce(member, message) {
304
+ if (this.warnedDeprecations.has(member))
305
+ return;
306
+ this.warnedDeprecations.add(member);
307
+ LogError(`[RSU] ${message}`);
308
+ }
309
+ /** Mark a pending work row Completed. Call ONLY after the work actually succeeded. */
310
+ async CompletePendingWork(id, contextUser, provider) {
311
+ return this.setPendingWorkTerminalStatus(id, 'Completed', undefined, contextUser, provider);
312
+ }
313
+ /** Mark a pending work row Failed, recording why. */
314
+ async FailPendingWork(id, errorMessage, contextUser, provider) {
315
+ return this.setPendingWorkTerminalStatus(id, 'Failed', errorMessage, contextUser, provider);
316
+ }
317
+ async setPendingWorkTerminalStatus(id, status, errorMessage, contextUser, provider) {
318
+ const md = provider ?? new Metadata();
319
+ const row = await md.GetEntityObject('MJ: RSU Pending Works', contextUser);
320
+ if (!(await row.Load(id))) {
321
+ LogError(`[RSU] Pending work ${id} not found when marking ${status}`);
322
+ return false;
323
+ }
324
+ row.Status = status;
325
+ row.ErrorMessage = errorMessage ?? null;
326
+ row.ProcessedAt = new Date();
327
+ if (!(await row.Save())) {
328
+ LogError(`[RSU] Failed to mark pending work ${id} as ${status}: ${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
329
+ return false;
330
+ }
331
+ return true;
332
+ }
333
+ /**
334
+ * Register every input's PendingWork rows. Called for successful migrations only,
335
+ * before the restart. Returns row IDs keyed by the input's index within `inputs`.
336
+ *
337
+ * Registration failures are collected, NOT swallowed. This step is the durability
338
+ * boundary: once the restart fires, work that never became a row is gone, and the
339
+ * post-restart consumer has nothing to find. A run whose rows failed to register
340
+ * therefore reports the failure on its result instead of appearing to have succeeded.
341
+ */
342
+ async registerPendingWork(inputs) {
343
+ const registered = new Map();
344
+ const errors = new Map();
345
+ const addError = (input, message) => {
346
+ LogError(`[RSU] ${message}`);
347
+ const list = errors.get(input) ?? [];
348
+ list.push(message);
349
+ errors.set(input, list);
350
+ };
351
+ for (const input of inputs) {
352
+ if (!input.PendingWork?.length)
353
+ continue;
354
+ if (!input.ContextUser) {
355
+ addError(input, `PendingWork supplied without ContextUser for "${input.Description}" — nothing was registered, so this work will NOT run after the restart`);
356
+ continue;
357
+ }
358
+ const ids = [];
359
+ for (const work of input.PendingWork) {
360
+ try {
361
+ ids.push(await this.WritePendingWork(work, input.ContextUser));
362
+ }
363
+ catch (err) {
364
+ addError(input, `Failed to register post-restart work for company integration ${work.CompanyIntegrationID}: ` +
365
+ `${err instanceof Error ? err.message : String(err)} — this work will NOT run after the restart`);
366
+ }
367
+ }
368
+ registered.set(input, ids);
369
+ }
370
+ return { IDs: registered, Errors: errors };
240
371
  }
241
372
  // ─── Generic Post-Restart File Injection ──────────────────────
242
373
  /**
@@ -322,6 +453,70 @@ export class RuntimeSchemaManager extends BaseSingleton {
322
453
  this._currentStepIndex = null;
323
454
  this._stepTotal = null;
324
455
  }
456
+ /** Delivers an event to {@link PipelineObserver}, swallowing (but logging) any throw. */
457
+ notifyObserver(event) {
458
+ const observer = this.PipelineObserver;
459
+ if (!observer)
460
+ return;
461
+ try {
462
+ observer(event);
463
+ }
464
+ catch (error) {
465
+ // Progress reporting must never break a schema migration.
466
+ this.rsuLog(`Pipeline observer threw on ${event.Kind} (ignored): ${error instanceof Error ? error.message : String(error)}`);
467
+ }
468
+ }
469
+ /**
470
+ * Records a completed step onto `steps` AND publishes it to the observer, so a step is never
471
+ * visible in the result but absent from the event stream. Every step recording goes through
472
+ * here — including the ones computed inline rather than via {@link runStep}.
473
+ */
474
+ recordStep(steps, step) {
475
+ steps.push(step);
476
+ this.notifyObserver({
477
+ Kind: 'step.end',
478
+ Name: step.Name,
479
+ Status: step.Status,
480
+ DurationMs: step.DurationMs,
481
+ Message: step.Message,
482
+ StepIndex: step.StepIndex,
483
+ StepTotal: step.StepTotal,
484
+ });
485
+ }
486
+ /**
487
+ * Maps a finished batch onto its terminal `run.end` event.
488
+ *
489
+ * `result` is undefined when a throw escaped the pipeline before a result existed — that case
490
+ * must still produce a FAILED run.end, otherwise an observer's run would hang in-flight forever.
491
+ *
492
+ * Public + static so the mapping is unit-testable without a live pipeline.
493
+ */
494
+ static BuildRunEndEvent(result, totalCount) {
495
+ if (!result) {
496
+ return {
497
+ Kind: 'run.end',
498
+ Success: false,
499
+ SuccessCount: 0,
500
+ FailureCount: totalCount,
501
+ TotalCount: totalCount,
502
+ ErrorMessage: 'Pipeline threw before producing a result',
503
+ };
504
+ }
505
+ const firstFailure = result.Results.find((r) => !r.Success);
506
+ return {
507
+ Kind: 'run.end',
508
+ Success: result.FailureCount === 0,
509
+ SuccessCount: result.SuccessCount,
510
+ FailureCount: result.FailureCount,
511
+ TotalCount: result.TotalCount,
512
+ ErrorMessage: firstFailure?.ErrorMessage,
513
+ ErrorStep: firstFailure?.ErrorStep,
514
+ };
515
+ }
516
+ /** Publishes the terminal run boundary. `result` is undefined when a throw escaped the pipeline. */
517
+ notifyRunEnd(result, totalCount) {
518
+ this.notifyObserver(RuntimeSchemaManager.BuildRunEndEvent(result, totalCount));
519
+ }
325
520
  // ─── Pipeline ────────────────────────────────────────────────────
326
521
  /**
327
522
  * Execute the RSU pipeline for a single input. Convenience wrapper
@@ -360,21 +555,35 @@ export class RuntimeSchemaManager extends BaseSingleton {
360
555
  const sharedSteps = [];
361
556
  // U11 — arm the determinate step counter (index of expected total) for this run.
362
557
  this.beginStepTracking(inputs.length);
558
+ this.notifyObserver({
559
+ Kind: 'run.start',
560
+ ItemCount: inputs.length,
561
+ Descriptions: inputs.map((i) => i.Description),
562
+ AffectedTables: [...new Set(inputs.flatMap((i) => i.AffectedTables))],
563
+ StepTotal: this._stepTotal ?? 0,
564
+ });
565
+ // Captured so the `finally` can publish the terminal run boundary on EVERY exit path —
566
+ // early validation failure, normal completion, or a throw (which leaves it undefined).
567
+ let batchResult;
363
568
  try {
364
569
  // Phase 1: Validate
365
570
  const validationFailure = await this.validateBatch(inputs, sharedSteps);
366
- if (validationFailure)
367
- return validationFailure;
571
+ if (validationFailure) {
572
+ batchResult = validationFailure;
573
+ return batchResult;
574
+ }
368
575
  // Phase 2: Execute migrations under lock
369
576
  const itemResults = await this.executeMigrations(inputs, sharedSteps);
370
577
  // Phase 3: Post-migration pipeline (CodeGen, compile, restart, git)
371
578
  const successfulItems = itemResults.filter((r) => r.Success);
372
579
  const postResult = await this.runPostMigrationPipeline(inputs, successfulItems, sharedSteps);
373
580
  // Phase 4: Build per-caller results
374
- return this.buildPerCallerResults(itemResults, successfulItems, sharedSteps, postResult);
581
+ batchResult = this.buildPerCallerResults(itemResults, successfulItems, sharedSteps, postResult);
582
+ return batchResult;
375
583
  }
376
584
  finally {
377
585
  this.endStepTracking();
586
+ this.notifyRunEnd(batchResult, inputs.length);
378
587
  }
379
588
  }
380
589
  /** Phase 1: Validate environment and all migration SQL. Returns a batch failure result if validation fails, null on success. */
@@ -386,11 +595,11 @@ export class RuntimeSchemaManager extends BaseSingleton {
386
595
  for (const input of inputs) {
387
596
  const validation = ValidateMigrationSQL(input.MigrationSQL, this.getProtectedSchemas());
388
597
  if (!validation.Valid) {
389
- sharedSteps.push({ Name: 'ValidateSQL', Status: 'failed', DurationMs: 0, Message: validation.Errors.join('; ') });
598
+ this.recordStep(sharedSteps, { Name: 'ValidateSQL', Status: 'failed', DurationMs: 0, Message: validation.Errors.join('; ') });
390
599
  return this.buildBatchResult(inputs.map((i) => this.buildFailedResult(i, 'ValidateSQL', sharedSteps)));
391
600
  }
392
601
  }
393
- sharedSteps.push({ Name: 'ValidateSQL', Status: 'success', DurationMs: 0, Message: `Validated ${inputs.length} migration(s)` });
602
+ this.recordStep(sharedSteps, { Name: 'ValidateSQL', Status: 'success', DurationMs: 0, Message: `Validated ${inputs.length} migration(s)` });
394
603
  return null;
395
604
  }
396
605
  /** Phase 2: Acquire lock, write and execute each migration, release lock. */
@@ -426,7 +635,7 @@ export class RuntimeSchemaManager extends BaseSingleton {
426
635
  }
427
636
  if (itemResults.some((r) => r.Success)) {
428
637
  this.MarkOutOfSync();
429
- sharedSteps.push({ Name: 'MarkOutOfSync', Status: 'success', DurationMs: 0, Message: 'DB changed, API out-of-sync until CodeGen completes' });
638
+ this.recordStep(sharedSteps, { Name: 'MarkOutOfSync', Status: 'success', DurationMs: 0, Message: 'DB changed, API out-of-sync until CodeGen completes' });
430
639
  }
431
640
  }
432
641
  finally {
@@ -473,6 +682,12 @@ export class RuntimeSchemaManager extends BaseSingleton {
473
682
  // Failed migrations should not trigger post-restart entity maps or syncs.
474
683
  const successfulInputs = successfulItems.map(r => r.Input);
475
684
  await this.writePostRestartFiles(successfulInputs);
685
+ // Register durable pending work for successful migrations. Rows stay Pending
686
+ // until the post-restart consumer completes them, so a crash between here and
687
+ // consumption leaves the work visible instead of losing it.
688
+ const pendingWork = await this.registerPendingWork(successfulInputs);
689
+ result.PendingWorkIDs = pendingWork.IDs;
690
+ result.PendingWorkErrors = pendingWork.Errors;
476
691
  // Restart LAST — PM2 restart kills this process, nothing runs after this
477
692
  if (!inputs.every((i) => i.SkipRestart)) {
478
693
  const restartOk = await this.runStep('RestartMJAPI', () => this.restartMJAPI(), sharedSteps);
@@ -493,19 +708,31 @@ export class RuntimeSchemaManager extends BaseSingleton {
493
708
  // A migration that executed but whose run-wide CodeGen failed is NOT a success —
494
709
  // the entity may have no spCreate/spUpdate procs and would silently skip on sync.
495
710
  const codeGenFailedThisCaller = codeGenFailed && item.Success;
711
+ // Same reasoning as the CodeGen case above: the migration ran, but work the caller
712
+ // asked to happen after the restart was never persisted, so the restart drops it.
713
+ // Reporting success here would tell the caller their sync is coming when it is not.
714
+ const pendingWorkErrors = postResult.PendingWorkErrors?.get(item.Input) ?? [];
715
+ const pendingWorkFailedThisCaller = pendingWorkErrors.length > 0 && item.Success;
496
716
  const result = {
497
- Success: item.Success && successfulItems.length > 0 && !codeGenFailed,
717
+ Success: item.Success && successfulItems.length > 0 && !codeGenFailed && !pendingWorkFailedThisCaller,
498
718
  MigrationFilePath: item.FilePath,
499
719
  APIRestarted: postResult.ApiRestarted,
500
720
  GitCommitSuccess: postResult.GitCommitSuccess,
501
721
  BranchName: postResult.BranchName,
502
722
  Steps: allSteps,
503
- ErrorMessage: codeGenFailedThisCaller ? postResult.CodeGenError ?? item.Error : item.Error,
723
+ ErrorMessage: codeGenFailedThisCaller
724
+ ? postResult.CodeGenError ?? item.Error
725
+ : pendingWorkFailedThisCaller
726
+ ? pendingWorkErrors.join('; ')
727
+ : item.Error,
504
728
  ErrorStep: codeGenFailedThisCaller
505
729
  ? 'RunCodeGen'
506
- : item.Error
507
- ? item.Steps.find((s) => s.Status === 'failed')?.Name
508
- : undefined,
730
+ : pendingWorkFailedThisCaller
731
+ ? 'RegisterPendingWork'
732
+ : item.Error
733
+ ? item.Steps.find((s) => s.Status === 'failed')?.Name
734
+ : undefined,
735
+ PendingWorkIDs: postResult.PendingWorkIDs?.get(item.Input),
509
736
  };
510
737
  this.writeAuditLog(item.Input, result).catch((err) => LogError(`[RSU] Audit log failed: ${err instanceof Error ? err.message : String(err)}`));
511
738
  this.recordMetrics(item, allSteps, result);
@@ -665,7 +892,6 @@ export class RuntimeSchemaManager extends BaseSingleton {
665
892
  * Verify ALL filesystem paths RSU may write to are actually writable.
666
893
  * Paths checked:
667
894
  * - MigrationsPath (migration SQL files)
668
- * - PendingWorkPath (post-restart task JSONs)
669
895
  * - AdditionalSchemaInfoPath parent dir (soft FK / soft PK config)
670
896
  * - CodeGenDir (temp codegen script)
671
897
  * - WorkDir (pipeline log file)
@@ -676,7 +902,6 @@ export class RuntimeSchemaManager extends BaseSingleton {
676
902
  const workDir = rsuConfig.WorkDir;
677
903
  const directoriesToCheck = [
678
904
  nodePath.join(workDir, rsuConfig.MigrationsPath),
679
- nodePath.join(workDir, rsuConfig.PendingWorkPath),
680
905
  dirname(nodePath.join(workDir, this.additionalSchemaInfoPath)),
681
906
  rsuConfig.CodeGenDir,
682
907
  workDir, // pipeline log
@@ -1022,8 +1247,9 @@ export class RuntimeSchemaManager extends BaseSingleton {
1022
1247
  const processName = rsuConfig.PM2ProcessName;
1023
1248
  // When running inside the MJAPI process that PM2 manages, `pm2 restart`
1024
1249
  // sends SIGINT to THIS process. We will die before execAsync resolves.
1025
- // That's fine — the pending-work file (.rsu_pending/) was already written
1026
- // before this step, and the newly restarted process picks it up on boot.
1250
+ // That's fine — the pending-work rows were already committed to
1251
+ // `MJ: RSU Pending Works` before this step, and the newly restarted process
1252
+ // picks them up on boot.
1027
1253
  //
1028
1254
  // Strategy: fire the restart command, catch the inevitable rejection
1029
1255
  // (our process gets killed mid-flight), and return true. If the process
@@ -1579,18 +1805,19 @@ export class RuntimeSchemaManager extends BaseSingleton {
1579
1805
  const stepIndex = this._currentStepIndex ?? undefined;
1580
1806
  const stepTotal = this._stepTotal ?? undefined;
1581
1807
  this.rsuLog(`▶ Starting step${stepIndex && stepTotal ? ` ${stepIndex}/${stepTotal}` : ''}: ${name}`);
1808
+ this.notifyObserver({ Kind: 'step.start', Name: name, StepIndex: stepIndex, StepTotal: stepTotal });
1582
1809
  try {
1583
1810
  const result = await fn();
1584
1811
  const durationMs = Date.now() - start;
1585
1812
  const msg = `${name} completed successfully`;
1586
- steps.push({ Name: name, Status: 'success', DurationMs: durationMs, Message: msg, StepIndex: stepIndex, StepTotal: stepTotal });
1813
+ this.recordStep(steps, { Name: name, Status: 'success', DurationMs: durationMs, Message: msg, StepIndex: stepIndex, StepTotal: stepTotal });
1587
1814
  this.rsuLog(`✓ ${name} — ${durationMs}ms`);
1588
1815
  return result;
1589
1816
  }
1590
1817
  catch (error) {
1591
1818
  const durationMs = Date.now() - start;
1592
1819
  const msg = error instanceof Error ? error.message : String(error);
1593
- steps.push({ Name: name, Status: 'failed', DurationMs: durationMs, Message: msg, StepIndex: stepIndex, StepTotal: stepTotal });
1820
+ this.recordStep(steps, { Name: name, Status: 'failed', DurationMs: durationMs, Message: msg, StepIndex: stepIndex, StepTotal: stepTotal });
1594
1821
  this.rsuLog(`✗ ${name} — FAILED after ${durationMs}ms: ${msg}`);
1595
1822
  return undefined;
1596
1823
  }