@attraccess/plugin-wago 0.1.0-nightly.1081.1 → 0.1.0-nightly.1086.1

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.
package/dist/index.js CHANGED
@@ -3190,17 +3190,19 @@ function validEventPayload(data, kind, canonical) {
3190
3190
  return true;
3191
3191
  }
3192
3192
  function validStatePayload(data, kind, canonical) {
3193
- if (kind === "state" && data.manualOutputChannelIds !== void 0 && (!Array.isArray(data.manualOutputChannelIds) || data.manualOutputChannelIds.length > MAX_CHANNELS || !data.manualOutputChannelIds.every(identifier)))
3193
+ if (kind !== "state") return true;
3194
+ if (data.manualOutputChannelIds !== void 0 && (!Array.isArray(data.manualOutputChannelIds) || data.manualOutputChannelIds.length > MAX_CHANNELS || !data.manualOutputChannelIds.every(identifier)))
3194
3195
  return false;
3195
- if (canonical && kind === "state" && data.readiness !== void 0 && (!isObject(data.readiness) || data.readiness.hardwareAvailable !== void 0 && typeof data.readiness.hardwareAvailable !== "boolean"))
3196
+ return !canonical || validCanonicalState(data);
3197
+ }
3198
+ function validCanonicalState(data) {
3199
+ if (data.readiness !== void 0 && (!isObject(data.readiness) || data.readiness.hardwareAvailable !== void 0 && typeof data.readiness.hardwareAvailable !== "boolean"))
3196
3200
  return false;
3197
- if (canonical && kind === "state" && !(data.contentHash === null || typeof data.contentHash === "string" && /^[0-9a-f]{64}$/i.test(data.contentHash)))
3201
+ if (!(data.contentHash === null || typeof data.contentHash === "string" && /^[0-9a-f]{64}$/i.test(data.contentHash)))
3198
3202
  return false;
3199
- if (canonical && kind === "state" && (typeof data.connected !== "boolean" || !(data.revision === null || Number.isSafeInteger(data.revision) && data.revision >= 0) || !isObject(data.outputs) || data.inputs !== void 0 && !isObject(data.inputs) || ![...Object.values(data.outputs), ...Object.values(isObject(data.inputs) ? data.inputs : {})].every(
3203
+ return typeof data.connected === "boolean" && (data.revision === null || Number.isSafeInteger(data.revision) && data.revision >= 0) && isObject(data.outputs) && (data.inputs === void 0 || isObject(data.inputs)) && [...Object.values(data.outputs), ...Object.values(isObject(data.inputs) ? data.inputs : {})].every(
3200
3204
  (value) => typeof value === "boolean"
3201
- )))
3202
- return false;
3203
- return true;
3205
+ );
3204
3206
  }
3205
3207
  function freshness(timestamp, now = Date.now(), maxAge = 9e4) {
3206
3208
  if (!timestamp) return "missing";
@@ -3785,27 +3787,13 @@ function wagoFlowPreview(config, kind, controller, snapshot, names) {
3785
3787
  function localizedPreview(config, kind, controller, snapshot, names, german) {
3786
3788
  const text = (english, translated) => german ? translated : english;
3787
3789
  const channel = snapshot?.logicalChannels.find((item) => item.id === config.channelId);
3788
- const point = snapshot?.physicalPoints?.find((item) => item.id === channel?.physicalPointId);
3789
- let channelName = channel ? typeof names[channel.id] === "string" ? String(names[channel.id]) : channel.id : typeof config.channelId === "string" ? `${config.channelId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt");
3790
- if (point?.modbus) {
3791
- const device = snapshot?.modbus?.devices.find((item) => item.id === point.modbus?.deviceId);
3792
- if (device) channelName = `${channelName} \xB7 ${device.name}`;
3793
- } else if (point?.hardwareProfile === "751-9301") {
3794
- const terminal = DIGITAL_TERMINALS.find((item) => item.channel === point.channel)?.label;
3795
- if (terminal && !channelName.includes(terminal)) channelName += ` \xB7 ${terminal}`;
3796
- }
3797
3790
  const rows = [
3798
- {
3799
- label: text("Device", "Ger\xE4t"),
3800
- value: controller?.name || controller?.hardwareId || (typeof config.controllerId === "number" ? `Controller ${config.controllerId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt"))
3801
- },
3802
- { label: text("Channel", "Kanal"), value: channelName }
3791
+ { label: text("Device", "Ger\xE4t"), value: deviceLabel(config, controller, text) },
3792
+ { label: text("Channel", "Kanal"), value: channelLabel(config, channel, snapshot, names, text) }
3803
3793
  ];
3804
- if (kind === "command") {
3805
- const action = config.action === "set" ? config.value === true ? text("Turn ON", "Einschalten") : config.value === false ? text("Turn OFF", "Ausschalten") : text("Select ON/OFF", "EIN/AUS ausw\xE4hlen") : config.action === "pulse" ? `${text("Pulse", "Impuls")}${channel?.pulse ? ` \xB7 ${duration(channel.pulse.durationMs)}` : ""}` : text("Not selected", "Nicht ausgew\xE4hlt");
3806
- return [...rows, { label: text("Action", "Aktion"), value: action }];
3807
- }
3808
- const category = config.category === "measurement" ? text("Measurement", "Messwert") : config.category === "fault" ? text("Fault", "Fehler") : config.category === "state" ? channel?.capabilities.includes("input") ? text("Input state", "Eingangszustand") : text("Output state", "Ausgangszustand") : void 0;
3794
+ if (kind === "command")
3795
+ return [...rows, { label: text("Action", "Aktion"), value: commandAction(config, channel, text) }];
3796
+ const category = categoryLabel(config, channel, text);
3809
3797
  if (kind === "event")
3810
3798
  return [
3811
3799
  ...rows,
@@ -3816,12 +3804,11 @@ function localizedPreview(config, kind, controller, snapshot, names, german) {
3816
3804
  ];
3817
3805
  if (kind === "read")
3818
3806
  return [...rows, { label: text("Read", "Lesen"), value: category ?? text("Not selected", "Nicht ausgew\xE4hlt") }];
3819
- const expected = typeof config.equals === "boolean" ? config.equals ? text("ON", "EIN") : text("OFF", "AUS") : typeof config.equals === "number" && Number.isFinite(config.equals) ? `${config.equals} (${text("wire value", "Rohwert")})` : text("Not set", "Nicht festgelegt");
3820
3807
  return [
3821
3808
  ...rows,
3822
3809
  {
3823
3810
  label: text("Wait for", "Warten auf"),
3824
- value: `${category ?? text("Not selected", "Nicht ausgew\xE4hlt")} = ${expected}`
3811
+ value: `${category ?? text("Not selected", "Nicht ausgew\xE4hlt")} = ${expectedValue(config, text)}`
3825
3812
  },
3826
3813
  {
3827
3814
  label: text("Timeout", "Zeitlimit"),
@@ -3829,6 +3816,38 @@ function localizedPreview(config, kind, controller, snapshot, names, german) {
3829
3816
  }
3830
3817
  ];
3831
3818
  }
3819
+ function deviceLabel(config, controller, text) {
3820
+ return controller?.name || controller?.hardwareId || (typeof config.controllerId === "number" ? `Controller ${config.controllerId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt"));
3821
+ }
3822
+ function channelLabel(config, channel, snapshot, names, text) {
3823
+ const point = snapshot?.physicalPoints?.find((item) => item.id === channel?.physicalPointId);
3824
+ let channelName = channel ? typeof names[channel.id] === "string" ? String(names[channel.id]) : channel.id : typeof config.channelId === "string" ? `${config.channelId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt");
3825
+ if (point?.modbus) {
3826
+ const device = snapshot?.modbus?.devices.find((item) => item.id === point.modbus?.deviceId);
3827
+ if (device) channelName = `${channelName} \xB7 ${device.name}`;
3828
+ } else if (point?.hardwareProfile === "751-9301") {
3829
+ const terminal = DIGITAL_TERMINALS.find((item) => item.channel === point.channel)?.label;
3830
+ if (terminal && !channelName.includes(terminal)) channelName += ` \xB7 ${terminal}`;
3831
+ }
3832
+ return channelName;
3833
+ }
3834
+ function commandAction(config, channel, text) {
3835
+ if (config.action === "set")
3836
+ return config.value === true ? text("Turn ON", "Einschalten") : config.value === false ? text("Turn OFF", "Ausschalten") : text("Select ON/OFF", "EIN/AUS ausw\xE4hlen");
3837
+ if (config.action === "pulse")
3838
+ return `${text("Pulse", "Impuls")}${channel?.pulse ? ` \xB7 ${duration(channel.pulse.durationMs)}` : ""}`;
3839
+ return text("Not selected", "Nicht ausgew\xE4hlt");
3840
+ }
3841
+ function categoryLabel(config, channel, text) {
3842
+ if (config.category === "measurement") return text("Measurement", "Messwert");
3843
+ if (config.category === "fault") return text("Fault", "Fehler");
3844
+ if (config.category !== "state") return void 0;
3845
+ return channel?.capabilities.includes("input") ? text("Input state", "Eingangszustand") : text("Output state", "Ausgangszustand");
3846
+ }
3847
+ function expectedValue(config, text) {
3848
+ if (typeof config.equals === "boolean") return config.equals ? text("ON", "EIN") : text("OFF", "AUS");
3849
+ return typeof config.equals === "number" && Number.isFinite(config.equals) ? `${config.equals} (${text("wire value", "Rohwert")})` : text("Not set", "Nicht festgelegt");
3850
+ }
3832
3851
  function duration(milliseconds) {
3833
3852
  return milliseconds < 1e3 ? `${milliseconds} ms` : `${milliseconds / 1e3} s`;
3834
3853
  }
@@ -5441,22 +5460,9 @@ var WagoControllerEnrollment = class extends WagoRevisions {
5441
5460
  // backend/controllers/subscriptions.ts
5442
5461
  var WagoSubscriptions = class extends WagoControllerEnrollment {
5443
5462
  async onHeartbeat(hardwareId, payload) {
5444
- let heartbeat;
5445
- try {
5446
- heartbeat = parseHeartbeat(payload);
5447
- } catch (error) {
5448
- this.context.logger.warn(
5449
- `Ignoring invalid WAGO heartbeat: ${error instanceof Error ? error.message : String(error)}`
5450
- );
5451
- return;
5452
- }
5453
- if (heartbeat.hardwareId !== hardwareId) return;
5454
- let rawHeartbeat;
5455
- try {
5456
- rawHeartbeat = JSON.parse(payload.toString("utf8"));
5457
- } catch {
5458
- return;
5459
- }
5463
+ const parsed = this.parseHeartbeatPayload(hardwareId, payload);
5464
+ if (!parsed) return;
5465
+ const { heartbeat, rawHeartbeat } = parsed;
5460
5466
  const canonical = canonicalEnvelope(rawHeartbeat, "heartbeat");
5461
5467
  const controller = await this.controllers.findOneBy({ hardwareId });
5462
5468
  if (!controller || controller.trustState !== "claimed" || !canonical && heartbeat.sequence !== void 0 && heartbeat.sequence < controller.lastSequence)
@@ -5466,23 +5472,47 @@ var WagoSubscriptions = class extends WagoControllerEnrollment {
5466
5472
  const admitted = this.diagnostics.ingest(controller.id, "heartbeat", payload);
5467
5473
  if (canTrackDiagnostics && !admitted) return;
5468
5474
  const heartbeatAt = admitted ? this.diagnostics.read(controller.id).heartbeatAt : typeof rawHeartbeat.timestamp === "string" ? rawHeartbeat.timestamp : void 0;
5469
- const persistedHeartbeatAt = sourceTime(controller.lastHeartbeatAt);
5470
- if (canonical && (!validEnvelope(rawHeartbeat, Date.now()) || !heartbeatAt || persistedHeartbeatAt !== null && sourceTime(heartbeatAt) < persistedHeartbeatAt))
5475
+ if (canonical && !acceptedCanonicalHeartbeat(rawHeartbeat, heartbeatAt, controller.lastHeartbeatAt)) return;
5476
+ if (canonical && (admitted || !canTrackDiagnostics))
5477
+ this.reportRuntimeStatus(controller.id, heartbeat, rawHeartbeat);
5478
+ if (controller.lastHeartbeatAt && freshness(controller.lastSeenAt, Date.now(), 3e4) === "fresh" && !heartbeatMetadataChanged(controller, heartbeat))
5471
5479
  return;
5472
- if (canonical && (admitted || !canTrackDiagnostics) && typeof rawHeartbeat.streamId === "string" && typeof rawHeartbeat.timestamp === "string") {
5473
- this.runtimeStatusHandler?.(controller.id, {
5474
- imageId: heartbeat.runtimeImageId ?? "",
5475
- runtimeVersion: heartbeat.runtimeVersion,
5476
- streamId: rawHeartbeat.streamId,
5477
- timestamp: Date.parse(rawHeartbeat.timestamp),
5478
- receivedAt: Date.now(),
5479
- sequence: rawHeartbeat.sequence,
5480
- ...heartbeat.runtimePolicyToken ? { runtimePolicyToken: heartbeat.runtimePolicyToken } : {}
5481
- });
5480
+ await this.checkpointHeartbeat(controller, heartbeat, canonical, heartbeatAt, now);
5481
+ }
5482
+ /** Parse a heartbeat for the expected controller; malformed packets are ignored. */
5483
+ parseHeartbeatPayload(hardwareId, payload) {
5484
+ let heartbeat;
5485
+ try {
5486
+ heartbeat = parseHeartbeat(payload);
5487
+ } catch (error) {
5488
+ this.context.logger.warn(
5489
+ `Ignoring invalid WAGO heartbeat: ${error instanceof Error ? error.message : String(error)}`
5490
+ );
5491
+ return void 0;
5482
5492
  }
5483
- const metadataChanged = controller.protocolVersion !== heartbeat.protocolVersion || controller.runtimeVersion !== heartbeat.runtimeVersion || controller.capabilities !== JSON.stringify(heartbeat.capabilities) || controller.compatibilityError !== compatibilityError(heartbeat);
5484
- if (controller.lastHeartbeatAt && freshness(controller.lastSeenAt, Date.now(), 3e4) === "fresh" && !metadataChanged)
5485
- return;
5493
+ if (heartbeat.hardwareId !== hardwareId) return void 0;
5494
+ try {
5495
+ return { heartbeat, rawHeartbeat: JSON.parse(payload.toString("utf8")) };
5496
+ } catch {
5497
+ return void 0;
5498
+ }
5499
+ }
5500
+ reportRuntimeStatus(controllerId, heartbeat, rawHeartbeat) {
5501
+ if (typeof rawHeartbeat.streamId !== "string" || typeof rawHeartbeat.timestamp !== "string") return;
5502
+ this.runtimeStatusHandler?.(controllerId, {
5503
+ imageId: heartbeat.runtimeImageId ?? "",
5504
+ runtimeVersion: heartbeat.runtimeVersion,
5505
+ streamId: rawHeartbeat.streamId,
5506
+ timestamp: Date.parse(rawHeartbeat.timestamp),
5507
+ receivedAt: Date.now(),
5508
+ sequence: rawHeartbeat.sequence,
5509
+ ...heartbeat.runtimePolicyToken ? { runtimePolicyToken: heartbeat.runtimePolicyToken } : {}
5510
+ });
5511
+ }
5512
+ /** Persist a heartbeat checkpoint. Canonical heartbeats record their source
5513
+ * time; legacy heartbeats advance the sequence and use receipt time.
5514
+ */
5515
+ async checkpointHeartbeat(controller, heartbeat, canonical, heartbeatAt, now) {
5486
5516
  controller.protocolVersion = heartbeat.protocolVersion;
5487
5517
  controller.runtimeVersion = heartbeat.runtimeVersion;
5488
5518
  controller.capabilities = JSON.stringify(heartbeat.capabilities);
@@ -5717,6 +5747,14 @@ var WagoSubscriptions = class extends WagoControllerEnrollment {
5717
5747
  return rebuild;
5718
5748
  }
5719
5749
  };
5750
+ function acceptedCanonicalHeartbeat(rawHeartbeat, heartbeatAt, lastHeartbeatAt) {
5751
+ if (!validEnvelope(rawHeartbeat, Date.now()) || !heartbeatAt) return false;
5752
+ const persistedHeartbeatAt = sourceTime(lastHeartbeatAt);
5753
+ return persistedHeartbeatAt === null || (sourceTime(heartbeatAt) ?? 0) >= persistedHeartbeatAt;
5754
+ }
5755
+ function heartbeatMetadataChanged(controller, heartbeat) {
5756
+ return controller.protocolVersion !== heartbeat.protocolVersion || controller.runtimeVersion !== heartbeat.runtimeVersion || controller.capabilities !== JSON.stringify(heartbeat.capabilities) || controller.compatibilityError !== compatibilityError(heartbeat);
5757
+ }
5720
5758
 
5721
5759
  // backend/controllers/service.ts
5722
5760
  var WagoService = class extends WagoSubscriptions {
@@ -9223,6 +9261,11 @@ var RuntimeUpdateError = class extends Error {
9223
9261
  var LEASE_MS = 30 * 6e4;
9224
9262
  var CURRENT_RECHECK_MS = 5 * 6e4;
9225
9263
  var OPERATION_MS = 25 * 6e4;
9264
+ function inspectionBlocker(inspection) {
9265
+ if (!inspection.claimed || !inspection.managed) return "management_required";
9266
+ if (!inspection.online) return "offline";
9267
+ return inspection.compatible ? null : "incompatible";
9268
+ }
9226
9269
  var WagoRuntimeUpdateCoordinator = class {
9227
9270
  constructor(store, desired, host, audit, now = Date.now, concurrency = 2) {
9228
9271
  __publicField(this, "store", store);
@@ -9245,133 +9288,12 @@ var WagoRuntimeUpdateCoordinator = class {
9245
9288
  if (this.stopped || this.running.has(controllerId) || this.running.size >= this.concurrency) return "busy";
9246
9289
  this.running.add(controllerId);
9247
9290
  let acquired = false;
9248
- const { owner, operation, timer, assertOwned, persist, acknowledge } = this.createLease(controllerId);
9291
+ const lease = this.createLease(controllerId);
9292
+ const { owner, operation, timer } = lease;
9249
9293
  try {
9250
9294
  acquired = await this.store.acquire(controllerId, owner, this.now(), this.now() + LEASE_MS);
9251
9295
  if (!acquired) return "busy";
9252
- let record2 = await this.store.load(controllerId);
9253
- await advanceRuntimeRetry(record2, retry, persist);
9254
- if (record2 && ["current", "failed"].includes(record2.phase) && record2.token) {
9255
- if (!await acknowledge(record2)) return "deferred";
9256
- }
9257
- if (record2 && active.has(record2.phase)) {
9258
- if (record2.phase === "recovery_required" && record2.retryAt > this.now()) return "deferred";
9259
- if (!record2.token || !record2.previousImageId) throw new RuntimeUpdateError("recovery");
9260
- record2.phase = "recovering";
9261
- await persist(record2);
9262
- try {
9263
- await this.host.recover(controllerId, record2.token, record2.previousImageId, operation.signal);
9264
- assertOwned();
9265
- } catch {
9266
- record2.phase = "recovery_required";
9267
- record2.failure = "recovery";
9268
- record2.retryAt = this.now() + 6e4;
9269
- await persist(record2);
9270
- return "settled";
9271
- }
9272
- record2.phase = "failed";
9273
- record2.failure = "interrupted";
9274
- record2.retryAt = this.now();
9275
- await persist(record2);
9276
- if (!await acknowledge(record2)) return "deferred";
9277
- return "settled";
9278
- }
9279
- const desired = await this.desired();
9280
- assertOwned();
9281
- const contradictedCurrent = record2?.phase === "current" && observedImageId !== void 0 && observedImageId !== (record2.currentImageId ?? record2.desiredImageId);
9282
- if (record2 && !contradictedCurrent && record2.desiredImageId === desired.imageId && record2.retryAt > this.now())
9283
- return "deferred";
9284
- const attempt = (record2?.desiredImageId === desired.imageId ? record2.attempt : 0) + 1;
9285
- let inspection;
9286
- try {
9287
- inspection = await this.host.inspect(controllerId, operation.signal);
9288
- assertOwned();
9289
- } catch (error) {
9290
- await persist({
9291
- controllerId,
9292
- phase: "blocked",
9293
- token: null,
9294
- desiredImageId: desired.imageId,
9295
- desiredRuntimeVersion: desired.manifest.runtimeVersion,
9296
- previousRuntimeVersion: record2?.desiredImageId === desired.imageId ? record2.previousRuntimeVersion ?? null : null,
9297
- desiredDigest: desired.digest,
9298
- buildId: desired.buildId,
9299
- previousImageId: record2?.desiredImageId === desired.imageId ? record2.previousImageId : null,
9300
- attempt,
9301
- startedAt: this.now(),
9302
- updatedAt: this.now(),
9303
- retryAt: this.retryAt(attempt),
9304
- failure: error instanceof RuntimeUpdateError ? error.failure : "offline"
9305
- });
9306
- return "settled";
9307
- }
9308
- const previous = record2;
9309
- const retainPrevious = previous?.desiredImageId === desired.imageId && inspection.imageId === desired.imageId;
9310
- record2 = {
9311
- controllerId,
9312
- phase: "blocked",
9313
- token: null,
9314
- desiredImageId: desired.imageId,
9315
- desiredRuntimeVersion: desired.manifest.runtimeVersion,
9316
- previousRuntimeVersion: retainPrevious ? previous.previousRuntimeVersion ?? inspection.runtimeVersion ?? null : inspection.runtimeVersion ?? null,
9317
- desiredDigest: desired.digest,
9318
- buildId: desired.buildId,
9319
- ...desired.installerSha256 ? { installerSha256: desired.installerSha256 } : {},
9320
- previousImageId: retainPrevious ? previous.previousImageId ?? inspection.imageId : inspection.imageId,
9321
- attempt,
9322
- startedAt: this.now(),
9323
- updatedAt: this.now(),
9324
- retryAt: 0,
9325
- failure: null,
9326
- cleanupAttempt: 0,
9327
- cleanupRetryAt: 0
9328
- };
9329
- const blocker = !inspection.claimed || !inspection.managed ? "management_required" : !inspection.online ? "offline" : !inspection.compatible ? "incompatible" : null;
9330
- if (blocker) {
9331
- record2.failure = blocker;
9332
- record2.retryAt = this.retryAt(attempt);
9333
- await persist(record2);
9334
- return "settled";
9335
- }
9336
- if (this.host.prepare) {
9337
- record2.phase = "preparing";
9338
- await persist(record2);
9339
- try {
9340
- await this.host.prepare(controllerId, desired, operation.signal);
9341
- await this.assertCurrent(desired.imageId);
9342
- assertOwned();
9343
- } catch (error) {
9344
- assertOwned();
9345
- record2.phase = "blocked";
9346
- record2.failure = error instanceof RuntimeUpdateError ? error.failure : "incompatible";
9347
- record2.retryAt = this.retryAt(attempt);
9348
- await persist(record2);
9349
- return "settled";
9350
- }
9351
- }
9352
- const targetImageId = runtimeTargetImageId(desired, inspection);
9353
- if (inspection.imageId === targetImageId)
9354
- return await this.verifyExisting(
9355
- controllerId,
9356
- desired,
9357
- record2,
9358
- operation,
9359
- assertOwned,
9360
- persist,
9361
- attempt,
9362
- targetImageId
9363
- );
9364
- return await this.applyRollout(
9365
- controllerId,
9366
- desired,
9367
- record2,
9368
- inspection,
9369
- operation,
9370
- assertOwned,
9371
- persist,
9372
- acknowledge,
9373
- attempt
9374
- );
9296
+ return await this.reconcileOwned(controllerId, retry, observedImageId, lease);
9375
9297
  } finally {
9376
9298
  clearTimeout(timer);
9377
9299
  operation.abort();
@@ -9384,6 +9306,146 @@ var WagoRuntimeUpdateCoordinator = class {
9384
9306
  }
9385
9307
  }
9386
9308
  }
9309
+ async reconcileOwned(controllerId, retry, observedImageId, lease) {
9310
+ const { operation, assertOwned, persist, acknowledge } = lease;
9311
+ let record2 = await this.store.load(controllerId);
9312
+ await advanceRuntimeRetry(record2, retry, persist);
9313
+ if (record2 && ["current", "failed"].includes(record2.phase) && record2.token) {
9314
+ if (!await acknowledge(record2)) return "deferred";
9315
+ }
9316
+ if (record2 && active.has(record2.phase)) return await this.recoverInterrupted(controllerId, record2, lease);
9317
+ const desired = await this.desired();
9318
+ assertOwned();
9319
+ const contradictedCurrent = record2?.phase === "current" && observedImageId !== void 0 && observedImageId !== (record2.currentImageId ?? record2.desiredImageId);
9320
+ if (record2 && !contradictedCurrent && record2.desiredImageId === desired.imageId && record2.retryAt > this.now())
9321
+ return "deferred";
9322
+ const attempt = (record2?.desiredImageId === desired.imageId ? record2.attempt : 0) + 1;
9323
+ let inspection;
9324
+ try {
9325
+ inspection = await this.host.inspect(controllerId, operation.signal);
9326
+ assertOwned();
9327
+ } catch (error) {
9328
+ await persist(this.uninspectedRecord(controllerId, desired, record2, attempt, error));
9329
+ return "settled";
9330
+ }
9331
+ record2 = this.plannedRecord(controllerId, desired, record2, inspection, attempt);
9332
+ const blocker = inspectionBlocker(inspection);
9333
+ if (blocker) {
9334
+ record2.failure = blocker;
9335
+ record2.retryAt = this.retryAt(attempt);
9336
+ await persist(record2);
9337
+ return "settled";
9338
+ }
9339
+ if (!await this.prepareHost(controllerId, desired, record2, lease, attempt)) return "settled";
9340
+ const targetImageId = runtimeTargetImageId(desired, inspection);
9341
+ if (inspection.imageId === targetImageId)
9342
+ return await this.verifyExisting(
9343
+ controllerId,
9344
+ desired,
9345
+ record2,
9346
+ operation,
9347
+ assertOwned,
9348
+ persist,
9349
+ attempt,
9350
+ targetImageId
9351
+ );
9352
+ return await this.applyRollout(
9353
+ controllerId,
9354
+ desired,
9355
+ record2,
9356
+ inspection,
9357
+ operation,
9358
+ assertOwned,
9359
+ persist,
9360
+ acknowledge,
9361
+ attempt
9362
+ );
9363
+ }
9364
+ /** Resumes an interrupted rollout found in an active phase by asking the host to recover. */
9365
+ async recoverInterrupted(controllerId, record2, { operation, assertOwned, persist, acknowledge }) {
9366
+ if (record2.phase === "recovery_required" && record2.retryAt > this.now()) return "deferred";
9367
+ if (!record2.token || !record2.previousImageId) throw new RuntimeUpdateError("recovery");
9368
+ record2.phase = "recovering";
9369
+ await persist(record2);
9370
+ try {
9371
+ await this.host.recover(controllerId, record2.token, record2.previousImageId, operation.signal);
9372
+ assertOwned();
9373
+ } catch {
9374
+ record2.phase = "recovery_required";
9375
+ record2.failure = "recovery";
9376
+ record2.retryAt = this.now() + 6e4;
9377
+ await persist(record2);
9378
+ return "settled";
9379
+ }
9380
+ record2.phase = "failed";
9381
+ record2.failure = "interrupted";
9382
+ record2.retryAt = this.now();
9383
+ await persist(record2);
9384
+ if (!await acknowledge(record2)) return "deferred";
9385
+ return "settled";
9386
+ }
9387
+ /** Blocked record for a controller whose host inspection failed. */
9388
+ uninspectedRecord(controllerId, desired, record2, attempt, error) {
9389
+ const sameTarget = record2?.desiredImageId === desired.imageId;
9390
+ return {
9391
+ controllerId,
9392
+ phase: "blocked",
9393
+ token: null,
9394
+ desiredImageId: desired.imageId,
9395
+ desiredRuntimeVersion: desired.manifest.runtimeVersion,
9396
+ previousRuntimeVersion: sameTarget ? record2.previousRuntimeVersion ?? null : null,
9397
+ desiredDigest: desired.digest,
9398
+ buildId: desired.buildId,
9399
+ previousImageId: sameTarget ? record2.previousImageId : null,
9400
+ attempt,
9401
+ startedAt: this.now(),
9402
+ updatedAt: this.now(),
9403
+ retryAt: this.retryAt(attempt),
9404
+ failure: error instanceof RuntimeUpdateError ? error.failure : "offline"
9405
+ };
9406
+ }
9407
+ /** Fresh blocked record for a new attempt, retaining the rollback target of a repeated one. */
9408
+ plannedRecord(controllerId, desired, previous, inspection, attempt) {
9409
+ const retainPrevious = previous?.desiredImageId === desired.imageId && inspection.imageId === desired.imageId;
9410
+ return {
9411
+ controllerId,
9412
+ phase: "blocked",
9413
+ token: null,
9414
+ desiredImageId: desired.imageId,
9415
+ desiredRuntimeVersion: desired.manifest.runtimeVersion,
9416
+ previousRuntimeVersion: retainPrevious ? previous.previousRuntimeVersion ?? inspection.runtimeVersion ?? null : inspection.runtimeVersion ?? null,
9417
+ desiredDigest: desired.digest,
9418
+ buildId: desired.buildId,
9419
+ ...desired.installerSha256 ? { installerSha256: desired.installerSha256 } : {},
9420
+ previousImageId: retainPrevious ? previous.previousImageId ?? inspection.imageId : inspection.imageId,
9421
+ attempt,
9422
+ startedAt: this.now(),
9423
+ updatedAt: this.now(),
9424
+ retryAt: 0,
9425
+ failure: null,
9426
+ cleanupAttempt: 0,
9427
+ cleanupRetryAt: 0
9428
+ };
9429
+ }
9430
+ /** Runs the optional host preparation; false once a blocked record has been persisted. */
9431
+ async prepareHost(controllerId, desired, record2, { operation, assertOwned, persist }, attempt) {
9432
+ if (!this.host.prepare) return true;
9433
+ record2.phase = "preparing";
9434
+ await persist(record2);
9435
+ try {
9436
+ await this.host.prepare(controllerId, desired, operation.signal);
9437
+ await this.assertCurrent(desired.imageId);
9438
+ assertOwned();
9439
+ } catch (error) {
9440
+ assertOwned();
9441
+ record2.phase = "blocked";
9442
+ record2.failure = error instanceof RuntimeUpdateError ? error.failure : "incompatible";
9443
+ record2.retryAt = this.retryAt(attempt);
9444
+ await persist(record2);
9445
+ return false;
9446
+ }
9447
+ return true;
9448
+ }
9387
9449
  async verifyExisting(controllerId, desired, record2, operation, assertOwned, persist, attempt, targetImageId) {
9388
9450
  const since = record2.startedAt;
9389
9451
  try {
@@ -10302,27 +10364,12 @@ var WagoManagedRuntimeEnrollment = class extends WagoManagedRuntimeServiceState
10302
10364
  }
10303
10365
  }
10304
10366
  async publicStatus(access, requestedControllerId) {
10305
- if (access && !["retiring", "retired"].includes(access.state)) {
10306
- try {
10307
- const stored = await this.loadSession(access.sessionId);
10308
- if (!stored) throw new Error();
10309
- this.credentials(stored);
10310
- } catch {
10311
- await this.setActiveState(access.sessionId, "recovery_required").catch(() => void 0);
10312
- access = await this.access.findOneBy({ sessionId: access.sessionId });
10313
- }
10314
- }
10367
+ access = await this.revalidatedAccess(access);
10315
10368
  const row = access?.controllerId ? await this.updates.findOneBy({ controllerId: access.controllerId }) : null;
10316
10369
  const controllerId = requestedControllerId ?? access?.controllerId;
10317
10370
  const controller = controllerId ? await this.controllers.findOneBy({ id: controllerId }) : null;
10318
10371
  const desired = controller ? await this.artifacts.current().catch(() => null) : null;
10319
- const managementSetup = access?.state === "verified" ? this.enrolmentProgress.get(controllerId ?? 0) ?? {
10320
- state: "waiting",
10321
- reason: controller ? await this.enrolmentWaitReason(
10322
- controller,
10323
- await this.sessions.findOneBy({ id: access.sessionId })
10324
- ) ?? "scheduled" : "connection"
10325
- } : void 0;
10372
+ const managementSetup = access?.state === "verified" ? await this.managementSetup(access, controller, controllerId) : void 0;
10326
10373
  const managementFailure = access && ["verified", "recovery_required"].includes(access.state) ? (await this.sessions.findOneBy({ id: access.sessionId }))?.failureReason : null;
10327
10374
  return {
10328
10375
  ...managementFailure ? { managementFailure } : {},
@@ -10331,22 +10378,51 @@ var WagoManagedRuntimeEnrollment = class extends WagoManagedRuntimeServiceState
10331
10378
  keyFingerprint: access?.keyFingerprint ?? null,
10332
10379
  update: row?.metadata ? JSON.parse(row.metadata) : null,
10333
10380
  ...managementSetup ? { managementSetup } : {},
10334
- ...controller ? {
10335
- runtime: {
10336
- runningVersion: controller.runtimeVersion,
10337
- runningImageId: this.heartbeats.get(controller.id)?.imageId || null,
10338
- desiredVersion: desired?.manifest.runtimeVersion ?? null,
10339
- desiredImageId: desired && "imageId" in desired ? runtimeTargetImageId(desired, {
10340
- imageId: this.heartbeats.get(controller.id)?.imageId ?? "",
10341
- runtimeVersion: this.heartbeats.get(controller.id)?.runtimeVersion ?? controller.runtimeVersion
10342
- }) : null
10343
- }
10344
- } : {},
10345
- ...access?.controllerId && this.wago.isRuntimeUpdateRequired ? { runtimeUpdateRequired: this.wago.isRuntimeUpdateRequired(access.controllerId) } : {},
10346
- ...access?.controllerId && this.reconciliationFailures.has(access.controllerId) ? { blocker: this.reconciliationFailures.get(access.controllerId) } : {},
10381
+ ...controller ? { runtime: this.runtimeStatus(controller, desired) } : {},
10382
+ ...this.runtimeAttention(access?.controllerId),
10347
10383
  physicalQualification: "unverified"
10348
10384
  };
10349
10385
  }
10386
+ /** Marks active access whose stored envelope no longer verifies as needing recovery. */
10387
+ async revalidatedAccess(access) {
10388
+ if (!access || ["retiring", "retired"].includes(access.state)) return access;
10389
+ try {
10390
+ const stored = await this.loadSession(access.sessionId);
10391
+ if (!stored) throw new Error();
10392
+ this.credentials(stored);
10393
+ return access;
10394
+ } catch {
10395
+ await this.setActiveState(access.sessionId, "recovery_required").catch(() => void 0);
10396
+ return this.access.findOneBy({ sessionId: access.sessionId });
10397
+ }
10398
+ }
10399
+ /** Live automatic SSH-completion progress, or the prerequisite it is still waiting for. */
10400
+ async managementSetup(access, controller, controllerId) {
10401
+ return this.enrolmentProgress.get(controllerId ?? 0) ?? {
10402
+ state: "waiting",
10403
+ reason: controller ? await this.enrolmentWaitReason(controller, await this.sessions.findOneBy({ id: access.sessionId })) ?? "scheduled" : "connection"
10404
+ };
10405
+ }
10406
+ /** Running versus desired runtime image for the controller's latest heartbeat. */
10407
+ runtimeStatus(controller, desired) {
10408
+ const heartbeat = this.heartbeats.get(controller.id);
10409
+ return {
10410
+ runningVersion: controller.runtimeVersion,
10411
+ runningImageId: heartbeat?.imageId || null,
10412
+ desiredVersion: desired?.manifest.runtimeVersion ?? null,
10413
+ desiredImageId: desired && "imageId" in desired ? runtimeTargetImageId(desired, {
10414
+ imageId: heartbeat?.imageId ?? "",
10415
+ runtimeVersion: heartbeat?.runtimeVersion ?? controller.runtimeVersion
10416
+ }) : null
10417
+ };
10418
+ }
10419
+ /** Runtime update demand and the last reconciliation blocker for a bound controller. */
10420
+ runtimeAttention(controllerId) {
10421
+ return {
10422
+ ...controllerId && this.wago.isRuntimeUpdateRequired ? { runtimeUpdateRequired: this.wago.isRuntimeUpdateRequired(controllerId) } : {},
10423
+ ...controllerId && this.reconciliationFailures.has(controllerId) ? { blocker: this.reconciliationFailures.get(controllerId) } : {}
10424
+ };
10425
+ }
10350
10426
  async sessionStatus(sessionId) {
10351
10427
  return this.publicStatus(await this.access.findOneBy({ sessionId }));
10352
10428
  }
@@ -10797,14 +10873,9 @@ var WagoManagedRuntimeService = class extends WagoManagedRuntimeReconciliation {
10797
10873
  super(context, wago, artifacts, readiness);
10798
10874
  }
10799
10875
  async completeEnrolment(controller) {
10800
- if (!controller.mqttServerId) return false;
10801
- const bound = await this.access.findOne({ where: { controllerId: controller.id }, order: { sessionId: "DESC" } });
10802
- if (!bound) return false;
10803
- const session = await this.sessions.findOneBy({ id: bound.sessionId });
10804
- if (!session || !["awaiting_verification", "completed"].includes(session.state)) return false;
10805
- if (session.hardwareId !== controller.hardwareId || session.mqttServerId !== controller.mqttServerId) return false;
10806
- let access = await this.loadSession(session.id);
10807
- if (!access || ["retiring", "retired", "pending"].includes(access.state)) return false;
10876
+ const candidate = await this.enrolmentCandidate(controller);
10877
+ if (!candidate) return false;
10878
+ const { bound, session, access } = candidate;
10808
10879
  try {
10809
10880
  if (this.credentials(access).hardwareId !== controller.hardwareId) throw new Error();
10810
10881
  } catch {
@@ -10820,129 +10891,172 @@ var WagoManagedRuntimeService = class extends WagoManagedRuntimeReconciliation {
10820
10891
  const owner = (0, import_node_crypto17.randomBytes)(16).toString("hex");
10821
10892
  if (!await this.operations.acquire(access.fingerprint, owner, Date.now(), Date.now() + 22 * 6e4)) return false;
10822
10893
  const operation = new AbortController();
10823
- let timer = setTimeout(() => operation.abort(), 18 * 6e4).unref();
10824
- let stage = "status";
10825
- const operationId = (0, import_node_crypto17.randomUUID)();
10826
- let principal = null;
10827
- let attempted = false;
10894
+ const attempt = {
10895
+ controller,
10896
+ session,
10897
+ owner,
10898
+ operation,
10899
+ // Key confirmation, acceptance and cutover can each wait 310s for a
10900
+ // supervisor gate. The rollback window starts only once SSH actually changes.
10901
+ timer: setTimeout(() => operation.abort(), 18 * 6e4).unref(),
10902
+ stage: "status",
10903
+ operationId: (0, import_node_crypto17.randomUUID)(),
10904
+ principal: null,
10905
+ attempted: false
10906
+ };
10828
10907
  this.enrolmentProgress.set(controller.id, { state: "running", reason: "preparation" });
10829
10908
  try {
10830
- await this.operations.assertOwned(access.fingerprint, owner);
10831
- access = await this.loadSession(session.id);
10832
- if (!access || ["retiring", "retired", "pending"].includes(access.state)) return false;
10833
- const remoteStatus = await this.connection(access, `access-status ${access.token}`, operation.signal);
10834
- if (remoteStatus === "cutover\n" && access.state !== "recovery_required") return false;
10835
- if (!["open\n", "committed\n", "cutover\n"].includes(remoteStatus)) throw new Error("Unknown management receipt");
10836
- const committed = remoteStatus === "committed\n";
10837
- if (committed) {
10838
- clearTimeout(timer);
10839
- timer = setTimeout(() => operation.abort(), 18e4).unref();
10840
- }
10841
- stage = "audit";
10842
- principal = JSON.parse(session.initiatingPrincipal ?? "null");
10843
- if (!principal) throw new Error("Management audit initiator unavailable");
10844
- await this.securityAudit(session.id, "security_apply", principal, operationId, "attempted");
10845
- attempted = true;
10846
- await this.sessions.update(session.id, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
10847
- await this.access.update(access.sessionId, { controllerId: controller.id });
10848
- if (remoteStatus === "cutover\n") {
10849
- stage = "rollback";
10850
- clearTimeout(timer);
10851
- timer = setTimeout(() => operation.abort(), 5e4).unref();
10852
- await this.connection(access, `access-restore ${access.token}`, operation.signal);
10853
- await this.securityAudit(session.id, "security_apply", principal, operationId, "failed");
10854
- return false;
10855
- }
10856
- if (!committed) {
10857
- stage = "ownership";
10858
- if (!session.deliveryToken || !session.dockerProvisionToken)
10859
- throw new Error("Commissioning ownership unavailable");
10860
- stage = "proof";
10861
- await this.prove(access, operation.signal);
10862
- stage = "recovery_login";
10863
- if (!await this.rootProbe(access.host, access.fingerprint, this.credentials(access).recoveryPassword))
10864
- throw new Error("Root recovery path is unverified");
10865
- await this.setActiveState(access.sessionId, "verified");
10866
- stage = "key_commit";
10867
- await this.connection(access, `access-key-commit ${access.token}`, operation.signal);
10868
- stage = "proof";
10869
- await this.prove(access, operation.signal);
10870
- stage = "acceptance";
10871
- if (this.rootAcceptance) {
10872
- await this.rootAcceptance(
10873
- access.host,
10874
- access.fingerprint,
10875
- this.credentials(access).recoveryPassword,
10876
- session.deliveryToken,
10877
- {
10878
- assertOwned: () => this.operations.assertOwned(access.fingerprint, owner),
10879
- signal: operation.signal,
10880
- deadline: Date.now() + 12 * 6e4
10881
- },
10882
- { token: access.token, helper: managedHostHelper(await this.desired()), watchdog: managedWatchdogScript() }
10883
- );
10884
- } else await this.connection(access, `commissioning-accept ${session.deliveryToken}`, operation.signal);
10885
- stage = "cutover";
10886
- this.enrolmentProgress.set(controller.id, { state: "running", reason: "ssh_cutover" });
10887
- await this.connection(access, `access-cutover ${access.token}`, operation.signal);
10888
- clearTimeout(timer);
10889
- timer = setTimeout(() => operation.abort(), 18e4).unref();
10890
- await new Promise((resolve2) => setTimeout(resolve2, 3e3));
10891
- }
10892
- stage = "policy";
10893
- await this.prove(access, operation.signal);
10894
- if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
10895
- throw new Error("Managed SSH policy is unverified");
10896
- stage = "root_login";
10897
- if (await this.rootProbe(access.host, access.fingerprint, this.credentials(access).recoveryPassword))
10898
- throw new Error("Root SSH remains enabled");
10899
- await this.prove(access, operation.signal);
10900
- if (!committed) {
10901
- this.enrolmentProgress.set(controller.id, { state: "running", reason: "reboot" });
10902
- stage = "boot";
10903
- const boot = await this.connection(access, `access-boot ${access.token}`, operation.signal);
10904
- if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(boot))
10905
- throw new Error("Controller boot identity unavailable");
10906
- stage = "reboot";
10907
- await this.connection(access, `access-reboot ${access.token}`, operation.signal).catch(() => void 0);
10908
- let rebootVerified = false;
10909
- for (let attempt = 0; attempt < 60; attempt++) {
10910
- operation.signal.throwIfAborted();
10911
- await new Promise((resolve2) => setTimeout(resolve2, 1e3));
10912
- try {
10913
- const next = await this.connection(access, `access-boot ${access.token}`, operation.signal);
10914
- if (next !== boot && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(next)) {
10915
- await this.prove(access, operation.signal);
10916
- if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
10917
- throw new Error("Post-reboot SSH policy unverified");
10918
- if (await this.rootProbe(access.host, access.fingerprint, this.credentials(access).recoveryPassword))
10919
- throw new Error("Root SSH enabled after reboot");
10920
- await this.prove(access, operation.signal);
10921
- rebootVerified = true;
10922
- break;
10923
- }
10924
- } catch {
10925
- }
10926
- }
10927
- if (!rebootVerified) throw new Error("Managed reboot verification failed");
10928
- }
10929
- await this.operations.assertOwned(access.fingerprint, owner);
10930
- stage = "confirmation";
10931
- this.enrolmentProgress.set(controller.id, { state: "running", reason: "confirmation" });
10932
- if (!committed) await this.connection(access, `access-commit ${access.token}`, operation.signal);
10933
- await this.setActiveState(access.sessionId, "managed");
10934
- await this.finishSession(session.id);
10935
- await this.securityAudit(session.id, "security_apply", principal, operationId, "succeeded");
10936
- return true;
10909
+ return await this.runEnrolment(attempt, access.fingerprint);
10937
10910
  } catch (error) {
10938
- return await this.failEnrolment(session, error, stage, attempted, principal, operationId, operation);
10911
+ return await this.failEnrolment(
10912
+ session,
10913
+ error,
10914
+ attempt.stage,
10915
+ attempt.attempted,
10916
+ attempt.principal,
10917
+ attempt.operationId,
10918
+ operation
10919
+ );
10939
10920
  } finally {
10940
10921
  this.enrolmentProgress.delete(controller.id);
10941
- clearTimeout(timer);
10922
+ clearTimeout(attempt.timer);
10942
10923
  operation.abort();
10943
10924
  await this.operations.release(bound.fingerprint, owner);
10944
10925
  }
10945
10926
  }
10927
+ /** Bound, unretired access whose commissioning session still matches this controller. */
10928
+ async enrolmentCandidate(controller) {
10929
+ if (!controller.mqttServerId) return null;
10930
+ const bound = await this.access.findOne({ where: { controllerId: controller.id }, order: { sessionId: "DESC" } });
10931
+ if (!bound) return null;
10932
+ const session = await this.sessions.findOneBy({ id: bound.sessionId });
10933
+ if (!session || !["awaiting_verification", "completed"].includes(session.state)) return null;
10934
+ if (session.hardwareId !== controller.hardwareId || session.mqttServerId !== controller.mqttServerId) return null;
10935
+ const access = await this.loadSession(session.id);
10936
+ if (!access || ["retiring", "retired", "pending"].includes(access.state)) return null;
10937
+ return { bound, session, access };
10938
+ }
10939
+ /** Probes the pinned bootstrap root login with the encrypted recovery password. completeEnrolment
10940
+ * only starts an attempt once a probe is registered; probes are never unregistered. */
10941
+ async rootLoginAccepted(access) {
10942
+ const probe = this.rootProbe;
10943
+ if (!probe) throw new Error("Root recovery probe unavailable");
10944
+ return probe(access.host, access.fingerprint, this.credentials(access).recoveryPassword);
10945
+ }
10946
+ /** Replaces the attempt's operation deadline; called whenever a new bounded phase starts. */
10947
+ restartEnrolmentTimer(attempt, ms) {
10948
+ clearTimeout(attempt.timer);
10949
+ attempt.timer = setTimeout(() => attempt.operation.abort(), ms).unref();
10950
+ }
10951
+ /** Runs the leased enrolment attempt; `attempt` records the stage and audit state for failure handling. */
10952
+ async runEnrolment(attempt, fingerprint2) {
10953
+ const { controller, session, owner, operation, operationId } = attempt;
10954
+ await this.operations.assertOwned(fingerprint2, owner);
10955
+ const access = await this.loadSession(session.id);
10956
+ if (!access || ["retiring", "retired", "pending"].includes(access.state)) return false;
10957
+ const remoteStatus = await this.connection(access, `access-status ${access.token}`, operation.signal);
10958
+ if (remoteStatus === "cutover\n" && access.state !== "recovery_required") return false;
10959
+ if (!["open\n", "committed\n", "cutover\n"].includes(remoteStatus)) throw new Error("Unknown management receipt");
10960
+ const committed = remoteStatus === "committed\n";
10961
+ if (committed) this.restartEnrolmentTimer(attempt, 18e4);
10962
+ attempt.stage = "audit";
10963
+ const principal = JSON.parse(session.initiatingPrincipal ?? "null");
10964
+ attempt.principal = principal;
10965
+ if (!principal) throw new Error("Management audit initiator unavailable");
10966
+ await this.securityAudit(session.id, "security_apply", principal, operationId, "attempted");
10967
+ attempt.attempted = true;
10968
+ await this.sessions.update(session.id, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
10969
+ await this.access.update(access.sessionId, { controllerId: controller.id });
10970
+ if (remoteStatus === "cutover\n") {
10971
+ attempt.stage = "rollback";
10972
+ this.restartEnrolmentTimer(attempt, 5e4);
10973
+ await this.connection(access, `access-restore ${access.token}`, operation.signal);
10974
+ await this.securityAudit(session.id, "security_apply", principal, operationId, "failed");
10975
+ return false;
10976
+ }
10977
+ if (!committed) await this.cutOverEnrolment(attempt, access);
10978
+ attempt.stage = "policy";
10979
+ await this.prove(access, operation.signal);
10980
+ if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
10981
+ throw new Error("Managed SSH policy is unverified");
10982
+ attempt.stage = "root_login";
10983
+ if (await this.rootLoginAccepted(access)) throw new Error("Root SSH remains enabled");
10984
+ await this.prove(access, operation.signal);
10985
+ if (!committed) await this.verifyEnrolmentReboot(attempt, access);
10986
+ await this.operations.assertOwned(access.fingerprint, owner);
10987
+ attempt.stage = "confirmation";
10988
+ this.enrolmentProgress.set(controller.id, { state: "running", reason: "confirmation" });
10989
+ if (!committed) await this.connection(access, `access-commit ${access.token}`, operation.signal);
10990
+ await this.setActiveState(access.sessionId, "managed");
10991
+ await this.finishSession(session.id);
10992
+ await this.securityAudit(session.id, "security_apply", principal, operationId, "succeeded");
10993
+ return true;
10994
+ }
10995
+ /** First attempt: prove recovery, commit the key, accept commissioning and switch SSH policy. */
10996
+ async cutOverEnrolment(attempt, access) {
10997
+ const { controller, session, owner, operation } = attempt;
10998
+ attempt.stage = "ownership";
10999
+ if (!session.deliveryToken || !session.dockerProvisionToken) throw new Error("Commissioning ownership unavailable");
11000
+ attempt.stage = "proof";
11001
+ await this.prove(access, operation.signal);
11002
+ attempt.stage = "recovery_login";
11003
+ if (!await this.rootLoginAccepted(access)) throw new Error("Root recovery path is unverified");
11004
+ await this.setActiveState(access.sessionId, "verified");
11005
+ attempt.stage = "key_commit";
11006
+ await this.connection(access, `access-key-commit ${access.token}`, operation.signal);
11007
+ attempt.stage = "proof";
11008
+ await this.prove(access, operation.signal);
11009
+ attempt.stage = "acceptance";
11010
+ if (this.rootAcceptance) {
11011
+ await this.rootAcceptance(
11012
+ access.host,
11013
+ access.fingerprint,
11014
+ this.credentials(access).recoveryPassword,
11015
+ session.deliveryToken,
11016
+ {
11017
+ assertOwned: () => this.operations.assertOwned(access.fingerprint, owner),
11018
+ signal: operation.signal,
11019
+ deadline: Date.now() + 12 * 6e4
11020
+ },
11021
+ { token: access.token, helper: managedHostHelper(await this.desired()), watchdog: managedWatchdogScript() }
11022
+ );
11023
+ } else await this.connection(access, `commissioning-accept ${session.deliveryToken}`, operation.signal);
11024
+ attempt.stage = "cutover";
11025
+ this.enrolmentProgress.set(controller.id, { state: "running", reason: "ssh_cutover" });
11026
+ await this.connection(access, `access-cutover ${access.token}`, operation.signal);
11027
+ this.restartEnrolmentTimer(attempt, 18e4);
11028
+ await new Promise((resolve2) => setTimeout(resolve2, 3e3));
11029
+ }
11030
+ /** First attempt: reboot and require the new boot to keep the hardened policy. */
11031
+ async verifyEnrolmentReboot(attempt, access) {
11032
+ const { controller, operation } = attempt;
11033
+ this.enrolmentProgress.set(controller.id, { state: "running", reason: "reboot" });
11034
+ attempt.stage = "boot";
11035
+ const boot = await this.connection(access, `access-boot ${access.token}`, operation.signal);
11036
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(boot))
11037
+ throw new Error("Controller boot identity unavailable");
11038
+ attempt.stage = "reboot";
11039
+ await this.connection(access, `access-reboot ${access.token}`, operation.signal).catch(() => void 0);
11040
+ let rebootVerified = false;
11041
+ for (let poll = 0; poll < 60; poll++) {
11042
+ operation.signal.throwIfAborted();
11043
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
11044
+ try {
11045
+ const next = await this.connection(access, `access-boot ${access.token}`, operation.signal);
11046
+ if (next !== boot && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(next)) {
11047
+ await this.prove(access, operation.signal);
11048
+ if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
11049
+ throw new Error("Post-reboot SSH policy unverified");
11050
+ if (await this.rootLoginAccepted(access)) throw new Error("Root SSH enabled after reboot");
11051
+ await this.prove(access, operation.signal);
11052
+ rebootVerified = true;
11053
+ break;
11054
+ }
11055
+ } catch {
11056
+ }
11057
+ }
11058
+ if (!rebootVerified) throw new Error("Managed reboot verification failed");
11059
+ }
10946
11060
  async failEnrolment(session, error, stage, attempted, principal, operationId, operation) {
10947
11061
  if (attempted && principal)
10948
11062
  await this.securityAudit(session.id, "security_apply", principal, operationId, "failed").catch(() => void 0);
@@ -15623,6 +15737,14 @@ function networkChangeInput(body) {
15623
15737
  throw new import_common35.BadRequestException("Select a configured MQTT server or update only the address.");
15624
15738
  return { targetHost: input.targetHost, mqttServerId: input.mqttServerId };
15625
15739
  }
15740
+ function assertNoUnfinishedChange(row) {
15741
+ if (row && row.phase !== "completed" && !(row.phase === "connecting" && row.failure))
15742
+ throw new import_common35.ConflictException("Retry the pending MQTT/address change before starting another.");
15743
+ }
15744
+ function assertSameManagedIdentity(row, access) {
15745
+ if (!row || row.sessionId !== access.sessionId || row.fingerprint !== access.fingerprint)
15746
+ throw new import_common35.ConflictException("Pending change belongs to another managed identity.");
15747
+ }
15626
15748
 
15627
15749
  // backend/network/service.ts
15628
15750
  var import_promises10 = require("node:dns/promises");
@@ -15659,31 +15781,10 @@ var WagoNetworkChangeService = class {
15659
15781
  throw new import_common36.ConflictException("Managed enrolled controller required.");
15660
15782
  row = await this.repository.createQueryBuilder("change").addSelect("change.encryptedPayload").where("change.controllerId = :controllerId", { controllerId }).getOne();
15661
15783
  if (retry && (!row || row.phase === "completed")) return this.status(controllerId);
15662
- if (!retry && row && row.phase !== "completed" && !(row.phase === "connecting" && row.failure))
15663
- throw new import_common36.ConflictException("Retry the pending MQTT/address change before starting another.");
15664
- const rotation = await this.context.getRepository(WagoCredentialRotationEntity).findOneBy({ controllerId });
15665
- const update = await this.context.getRepository(WagoRuntimeUpdateEntity).findOneBy({ controllerId });
15666
- if (rotation && rotation.phase !== "completed" || update?.metadata && JSON.parse(update.metadata).token)
15667
- throw new import_common36.ConflictException(
15668
- "Finish credential rotation or runtime update recovery before changing MQTT/address settings."
15669
- );
15670
- if (!retry) {
15671
- if (!input) throw new import_common36.BadRequestException("Invalid MQTT/address change");
15672
- row = this.repository.create({
15673
- controllerId,
15674
- sessionId: access.sessionId,
15675
- fingerprint: access.fingerprint,
15676
- ...input,
15677
- phase: "connecting",
15678
- failure: null,
15679
- encryptedPayload: null,
15680
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
15681
- });
15682
- await assertOwned();
15683
- await this.repository.save(row);
15684
- }
15685
- if (!row || row.sessionId !== access.sessionId || row.fingerprint !== access.fingerprint)
15686
- throw new import_common36.ConflictException("Pending change belongs to another managed identity.");
15784
+ if (!retry) assertNoUnfinishedChange(row);
15785
+ await this.assertNoPendingRecovery(controllerId);
15786
+ if (!retry) row = await this.savePendingChange(controllerId, access, input, assertOwned);
15787
+ assertSameManagedIdentity(row, access);
15687
15788
  await lifecycle.attempt();
15688
15789
  audited = true;
15689
15790
  await assertOwned();
@@ -15695,10 +15796,7 @@ var WagoNetworkChangeService = class {
15695
15796
  if (row.mqttServerId !== null) {
15696
15797
  await this.applyBrokerChange(controllerId, row, controller, host, abort, assertOwned, retry);
15697
15798
  } else {
15698
- if (await host.command(`mqtt-address ${host.managementToken}`) !== "OK\n")
15699
- throw new RuntimeUpdateError("recovery");
15700
- await this.phase(row, "saving", assertOwned);
15701
- await this.saveBindings(row, host.encryptedCredentials, null, assertOwned);
15799
+ await this.applyAddressChange(row, host, assertOwned);
15702
15800
  }
15703
15801
  row.encryptedPayload = null;
15704
15802
  await this.phase(row, "completed", assertOwned);
@@ -15707,10 +15805,7 @@ var WagoNetworkChangeService = class {
15707
15805
  audited = false;
15708
15806
  return this.status(controllerId);
15709
15807
  } catch (error) {
15710
- if (row && row.phase !== "completed") {
15711
- row.failure = error instanceof RuntimeUpdateError || error instanceof NetworkChangeError ? error.failure : "interrupted";
15712
- await assertOwned().then(() => this.repository.save(row)).catch(() => void 0);
15713
- }
15808
+ if (row && row.phase !== "completed") await this.recordFailure(row, error, assertOwned);
15714
15809
  if (audited) await lifecycle.finish("failed");
15715
15810
  throw new import_common36.ConflictException("MQTT/address change is incomplete. Check its status and retry the saved operation.");
15716
15811
  } finally {
@@ -15720,6 +15815,44 @@ var WagoNetworkChangeService = class {
15720
15815
  await operations.release(access.fingerprint, owner);
15721
15816
  }
15722
15817
  }
15818
+ /** Network changes must not race credential rotation or runtime update recovery. */
15819
+ async assertNoPendingRecovery(controllerId) {
15820
+ const rotation = await this.context.getRepository(WagoCredentialRotationEntity).findOneBy({ controllerId });
15821
+ const update = await this.context.getRepository(WagoRuntimeUpdateEntity).findOneBy({ controllerId });
15822
+ if (rotation && rotation.phase !== "completed" || update?.metadata && JSON.parse(update.metadata).token)
15823
+ throw new import_common36.ConflictException(
15824
+ "Finish credential rotation or runtime update recovery before changing MQTT/address settings."
15825
+ );
15826
+ }
15827
+ /** Durably record a new change intent before contacting the controller. */
15828
+ async savePendingChange(controllerId, access, input, assertOwned) {
15829
+ if (!input) throw new import_common36.BadRequestException("Invalid MQTT/address change");
15830
+ const row = this.repository.create({
15831
+ controllerId,
15832
+ sessionId: access.sessionId,
15833
+ fingerprint: access.fingerprint,
15834
+ ...input,
15835
+ phase: "connecting",
15836
+ failure: null,
15837
+ encryptedPayload: null,
15838
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
15839
+ });
15840
+ await assertOwned();
15841
+ await this.repository.save(row);
15842
+ return row;
15843
+ }
15844
+ /** Apply an address-only change; the broker binding stays unchanged. */
15845
+ async applyAddressChange(row, host, assertOwned) {
15846
+ if (await host.command(`mqtt-address ${host.managementToken}`) !== "OK\n")
15847
+ throw new RuntimeUpdateError("recovery");
15848
+ await this.phase(row, "saving", assertOwned);
15849
+ await this.saveBindings(row, host.encryptedCredentials, null, assertOwned);
15850
+ }
15851
+ /** Keep the durable intent for an explicit retry, recording only a safe failure code. */
15852
+ async recordFailure(row, error, assertOwned) {
15853
+ row.failure = error instanceof RuntimeUpdateError || error instanceof NetworkChangeError ? error.failure : "interrupted";
15854
+ await assertOwned().then(() => this.repository.save(row)).catch(() => void 0);
15855
+ }
15723
15856
  async applyBrokerChange(controllerId, row, controller, host, abort, assertOwned, retry) {
15724
15857
  let superseded;
15725
15858
  let connection;