@camstack/addon-post-analysis 1.2.322 → 1.2.323

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.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-BFbP9U7z.js → clip-model-registry-8IMzj7jJ.js} +5674 -5032
  2. package/dist/{clip-model-registry-CwK2SLQD.mjs → clip-model-registry-BENGs4rw.mjs} +5441 -4835
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Dmt5eh93.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CGo6RmK3.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CBG_lEGV.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bnb-yu_h.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-DtKO_7UT.mjs → hostInit-D5UUhDKa.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +673 -106
  11. package/dist/pipeline-analytics/index.mjs +673 -106
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-mzYK6V9S.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CuOcvcK7.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-BFbP9U7z.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-8IMzj7jJ.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -945,7 +945,7 @@ function grantMeta(grant) {
945
945
  ...common,
946
946
  ruleId: grant.ruleId,
947
947
  rule: grant.ruleName,
948
- sequence: grant.sequence
948
+ sequenceId: grant.sequenceId
949
949
  };
950
950
  case "snooze": return {
951
951
  ...common,
@@ -5068,6 +5068,27 @@ function planDenseRanges(input) {
5068
5068
  }
5069
5069
  //#endregion
5070
5070
  //#region src/notification-center/text-compose.ts
5071
+ /**
5072
+ * The COMPOSITE variables — phrases assembled from catalog fragments before
5073
+ * the one and only render pass.
5074
+ *
5075
+ * A notification body is not a sentence with slots. "person (John) on Front
5076
+ * door in gate" contains three sub-phrases whose SHAPE differs per language
5077
+ * and whose presence is conditional: the label parentheses, the zone clause,
5078
+ * the plural noun. Expressing that with `{{class}}{{label}}{{zones}}` and
5079
+ * three scalars pushes the grammar into the template, where a translator
5080
+ * cannot fix it. So each becomes ONE variable — `{{subject}}`, `{{inZones}}`,
5081
+ * `{{op}}`, `{{detectionSummary}}` — composed here from its own keys and handed
5082
+ * to {@link renderTemplate} as a finished string.
5083
+ *
5084
+ * **Fragments see the SCALARS only.** Never the composites. That is what makes
5085
+ * the depth exactly one and not "one so far": a fragment key edited to say
5086
+ * `{{inZones}}` renders empty rather than starting a second round, and
5087
+ * `renderTemplate` never re-scans what it inserted, so a composite's own value
5088
+ * is printed verbatim whatever it contains.
5089
+ *
5090
+ * Pure: a catalog in, a string out. No clock, no store, no I/O.
5091
+ */
5071
5092
  /** One normalisation, used on both sides of every class lookup — a pipeline
5072
5093
  * that emitted `Person` must find the key written `class.person`. */
5073
5094
  function normaliseClass(className) {
@@ -5076,24 +5097,35 @@ function normaliseClass(className) {
5076
5097
  /**
5077
5098
  * The noun for a class, in the count's plural category.
5078
5099
  *
5079
- * A class no locale names falls through to `class.other`, which prints the RAW
5080
- * name: a custom model's class must appear in the sentence rather than vanish
5081
- * out of it, and a missing translation is a smaller defect than a missing
5082
- * detection.
5100
+ * Lookup order: the NC text catalog (`class.*`, plural-aware) → the shared
5101
+ * {@link eventKindLabel} map (viewer filters / EVENT_TAXONOMY) → `class.other`
5102
+ * with the RAW wire id. A custom model class must still appear rather than
5103
+ * vanish; a missing translation is a smaller defect than a missing detection.
5083
5104
  */
5084
5105
  function ncClassNoun(texts, className, count) {
5085
- const key = `class.${normaliseClass(className)}`;
5106
+ const normalised = normaliseClass(className);
5107
+ const key = `class.${normalised}`;
5086
5108
  if (texts.has(key)) return texts.text({
5087
5109
  key,
5088
5110
  count,
5089
5111
  vars: { name: className }
5090
5112
  });
5113
+ if (require_clip_model_registry.isEventKindLabelKey(normalised)) return require_clip_model_registry.eventKindLabel(normalised, texts.language);
5091
5114
  return texts.text({
5092
5115
  key: "class.other",
5093
5116
  count,
5094
5117
  vars: { name: className }
5095
5118
  });
5096
5119
  }
5120
+ /**
5121
+ * The parenthetical half of a labelled subject: a recognised person or plate
5122
+ * is printed verbatim; a classification (species, COCO sub, …) goes through
5123
+ * the shared event-kind map so "animal (cat)" becomes "animale (Gatto)".
5124
+ */
5125
+ function ncSubjectLabelText(texts, label, labelKind) {
5126
+ if (labelKind === "identity" || labelKind === "plate") return label;
5127
+ return require_clip_model_registry.eventKindLabel(label, texts.language);
5128
+ }
5097
5129
  /** `person (John)` / `persona (John)` — the thing the notification is about. */
5098
5130
  function ncSubjectText(texts, input) {
5099
5131
  const classNoun = ncClassNoun(texts, input.className, 1);
@@ -5105,7 +5137,7 @@ function ncSubjectText(texts, input) {
5105
5137
  key: subjectLabelKey(input.labelKind),
5106
5138
  vars: {
5107
5139
  classNoun,
5108
- label: input.label
5140
+ label: ncSubjectLabelText(texts, input.label, input.labelKind)
5109
5141
  }
5110
5142
  });
5111
5143
  }
@@ -8675,31 +8707,48 @@ function replaceConditionReferences(monitor, condition, reference) {
8675
8707
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
8676
8708
  var ALARM_PANEL_CAP$1 = "alarm-panel";
8677
8709
  var ALARM_KINDS = new Set(require_clip_model_registry.NC_ALARM_SYSTEM_EVENT_KINDS);
8678
- function actuatesAlarmPanel(sequences) {
8679
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
8710
+ function stepActuatesAlarm(step) {
8711
+ return step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1;
8712
+ }
8713
+ function bindingActuatesAlarm(binding, sequenceActionsOf) {
8714
+ if (binding.kind === "do") return stepActuatesAlarm(binding.action);
8715
+ const steps = sequenceActionsOf?.(binding.sequenceId);
8716
+ if (steps === void 0) return false;
8717
+ return steps.some(stepActuatesAlarm);
8718
+ }
8719
+ function actionsActuateAlarm(rule, sequenceActionsOf) {
8720
+ const actions = rule.actions;
8721
+ if (actions === void 0) return false;
8722
+ const hooks = [
8723
+ actions.onActivation,
8724
+ actions.onDeactivation,
8725
+ actions.onTrigger,
8726
+ actions.onTriggerOff
8727
+ ];
8728
+ for (const bindings of hooks) for (const binding of bindings ?? []) if (bindingActuatesAlarm(binding, sequenceActionsOf)) return true;
8729
+ for (const button of actions.buttons ?? []) {
8730
+ const steps = sequenceActionsOf?.(button.sequenceId);
8731
+ if (steps !== void 0 && steps.some(stepActuatesAlarm)) return true;
8732
+ }
8680
8733
  return false;
8681
8734
  }
8682
8735
  /**
8683
8736
  * May a snooze window silence this rule?
8684
8737
  *
8685
- * `true` = never. Deliberately answered from the rule alone: the gate reads it
8686
- * on the delivery path, where a fallible lookup would be a read that can
8687
- * DESTROY the exemption (D49) — and the failure direction of this one is
8688
- * silencing an alarm.
8738
+ * `true` = never. Deliberately answered from the rule alone (plus an optional
8739
+ * sequence lookup): the gate reads it on the delivery path, where a fallible
8740
+ * lookup would be a read that can DESTROY the exemption (D49) — and the
8741
+ * failure direction of this one is silencing an alarm. A missing sequence
8742
+ * lookup means sequence-ref bindings cannot prove exemption; Do bindings and
8743
+ * system-event kinds still can.
8689
8744
  */
8690
- function isSnoozeExempt(rule) {
8745
+ function isSnoozeExempt(rule, sequenceActionsOf) {
8691
8746
  const kinds = rule.conditions.systemEvent?.kinds ?? [];
8692
8747
  for (const kind of kinds) if (ALARM_KINDS.has(kind)) return true;
8693
- return actuatesAlarmPanel(rule.actions?.onTrigger ?? []);
8748
+ return actionsActuateAlarm(rule, sequenceActionsOf);
8694
8749
  }
8695
8750
  //#endregion
8696
8751
  //#region src/notification-center/action-buttons.ts
8697
- /** Sequence names the rule actually declares. A button may only name one. */
8698
- function declaredSequences(actions) {
8699
- const names = /* @__PURE__ */ new Set();
8700
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
8701
- return names;
8702
- }
8703
8752
  /**
8704
8753
  * Build the notification's buttons.
8705
8754
  *
@@ -8712,18 +8761,18 @@ function buildActionButtons(input) {
8712
8761
  if (actions === void 0) return [];
8713
8762
  const buttons = actions.buttons ?? [];
8714
8763
  if (buttons.length === 0) return [];
8715
- const known = declaredSequences(actions);
8764
+ const known = input.knownSequenceIds;
8716
8765
  const seen = /* @__PURE__ */ new Set();
8717
8766
  const out = [];
8718
8767
  for (const button of buttons) {
8719
- if (!known.has(button.sequence)) continue;
8768
+ if (!known.has(button.sequenceId)) continue;
8720
8769
  if (seen.has(button.id)) continue;
8721
8770
  seen.add(button.id);
8722
8771
  out.push({
8723
8772
  id: button.id,
8724
8773
  label: button.label,
8725
8774
  url: input.mintUrl({
8726
- sequence: button.sequence,
8775
+ sequenceId: button.sequenceId,
8727
8776
  actionId: button.id
8728
8777
  }),
8729
8778
  ...button.icon !== void 0 ? { icon: button.icon } : {},
@@ -8733,18 +8782,17 @@ function buildActionButtons(input) {
8733
8782
  return out;
8734
8783
  }
8735
8784
  /**
8736
- * Which of a rule's buttons name a sequence it does not have.
8785
+ * Which of a rule's buttons name a sequence that is not known.
8737
8786
  *
8738
8787
  * Exported so the caller can LOG the drop. A button silently missing from a
8739
8788
  * notification is the exact shape of failure this repo keeps paying for — the
8740
8789
  * operator authored it, it never appeared, and nothing said why.
8741
8790
  */
8742
- function unresolvableButtons(actions) {
8791
+ function unresolvableButtons(actions, knownSequenceIds) {
8743
8792
  if (actions === void 0) return [];
8744
8793
  const buttons = actions.buttons ?? [];
8745
8794
  if (buttons.length === 0) return [];
8746
- const known = declaredSequences(actions);
8747
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
8795
+ return buttons.filter((b) => !knownSequenceIds.has(b.sequenceId)).map((b) => `${b.id}→${b.sequenceId}`);
8748
8796
  }
8749
8797
  /** How many snooze choices one notification may carry. The schema caps the
8750
8798
  * authored list at 4; this is the same bound applied to a stored rule that
@@ -8795,7 +8843,7 @@ function snoozeChoices(options) {
8795
8843
  * attention than the notification did.
8796
8844
  */
8797
8845
  function buildSnoozeButtons(input) {
8798
- if (isSnoozeExempt(input.rule)) return [];
8846
+ if (isSnoozeExempt(input.rule, input.sequenceActionsOf)) return [];
8799
8847
  return snoozeChoices(input.rule.snoozeOptions).map((minutes) => {
8800
8848
  const actionId = snoozeButtonId(minutes);
8801
8849
  return {
@@ -11084,10 +11132,11 @@ function matchesIdentityScope(subject, scope) {
11084
11132
  * Derived, never a stored flag: both rule editors prune condition keys to the
11085
11133
  * catalog on save, so a new key would be dropped by the first edit.
11086
11134
  */
11087
- function ruleBelongsToAlarm(rule) {
11135
+ function ruleBelongsToAlarm(rule, sequenceActionsOf) {
11088
11136
  return require_clip_model_registry.ruleBelongsToAlarm({
11089
11137
  conditions: rule.conditions,
11090
- ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
11138
+ ...rule.actions !== void 0 ? { actions: rule.actions } : {},
11139
+ ...sequenceActionsOf !== void 0 ? { sequenceActionsOf } : {}
11091
11140
  });
11092
11141
  }
11093
11142
  /**
@@ -12975,7 +13024,7 @@ function deriveTankBand(reading) {
12975
13024
  case null: return null;
12976
13025
  }
12977
13026
  }
12978
- function isRecord$7(value) {
13027
+ function isRecord$9(value) {
12979
13028
  return typeof value === "object" && value !== null && !Array.isArray(value);
12980
13029
  }
12981
13030
  function tankStatusOf(value) {
@@ -12991,11 +13040,11 @@ function vacuumTanksOf(data) {
12991
13040
  if (data["capName"] !== "vacuum-control") return null;
12992
13041
  const deviceId = data["deviceId"];
12993
13042
  const slice = data["slice"];
12994
- if (typeof deviceId !== "number" || !isRecord$7(slice)) return null;
13043
+ if (typeof deviceId !== "number" || !isRecord$9(slice)) return null;
12995
13044
  const tanks = [];
12996
13045
  for (const key of NC_TANK_KEYS) {
12997
13046
  const raw = slice[key];
12998
- if (!isRecord$7(raw)) continue;
13047
+ if (!isRecord$9(raw)) continue;
12999
13048
  const level = raw["level"];
13000
13049
  tanks.push({
13001
13050
  key,
@@ -20333,6 +20382,350 @@ var NcDeviceMuteStore = class {
20333
20382
  }
20334
20383
  };
20335
20384
  //#endregion
20385
+ //#region src/notification-center/migrate-legacy-actions.ts
20386
+ function isRecord$8(value) {
20387
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20388
+ }
20389
+ /** A legacy inline sequence row — detected by `name` + `actions` + `enabled`. */
20390
+ function asLegacySequence(value) {
20391
+ const parsed = require_clip_model_registry.NcLegacyRuleActionSequenceSchema.safeParse(value);
20392
+ return parsed.success ? parsed.data : null;
20393
+ }
20394
+ /**
20395
+ * True when `onTrigger` still looks like the pre-shared-sequence array of
20396
+ * named sequences (not an array of `{ kind: 'sequence' | 'do', … }` bindings).
20397
+ */
20398
+ function onTriggerLooksLegacy(onTrigger) {
20399
+ if (!Array.isArray(onTrigger) || onTrigger.length === 0) return false;
20400
+ const first = onTrigger[0];
20401
+ if (!isRecord$8(first)) return false;
20402
+ if (first["kind"] === "sequence" || first["kind"] === "do") return false;
20403
+ return asLegacySequence(first) !== null;
20404
+ }
20405
+ function legacyButtonSequenceName(button) {
20406
+ if (!isRecord$8(button)) return null;
20407
+ const name = button["sequence"];
20408
+ return typeof name === "string" && name.length > 0 ? name : null;
20409
+ }
20410
+ /**
20411
+ * Rewrite a raw `actions` block. Returns the (possibly unchanged) new-shape
20412
+ * actions and whether anything was materialised into the sequence store.
20413
+ */
20414
+ async function migrateLegacyRuleActions(actions, createSequence) {
20415
+ if (actions === void 0 || actions === null) return {
20416
+ actions: void 0,
20417
+ migrated: false
20418
+ };
20419
+ if (!isRecord$8(actions)) return {
20420
+ actions: void 0,
20421
+ migrated: false
20422
+ };
20423
+ if (!onTriggerLooksLegacy(actions["onTrigger"]) && actions["sequence"] === void 0) {
20424
+ const buttons = actions["buttons"];
20425
+ if (!(Array.isArray(buttons) && buttons.some((b) => legacyButtonSequenceName(b) !== null))) {
20426
+ const parsed = require_clip_model_registry.NcRuleActionsSchema.safeParse(actions);
20427
+ if (parsed.success) return {
20428
+ actions: parsed.data,
20429
+ migrated: false
20430
+ };
20431
+ return {
20432
+ actions: void 0,
20433
+ migrated: false
20434
+ };
20435
+ }
20436
+ }
20437
+ const legacySequences = [];
20438
+ const onTriggerRaw = actions["onTrigger"];
20439
+ if (Array.isArray(onTriggerRaw)) for (const entry of onTriggerRaw) {
20440
+ const seq = asLegacySequence(entry);
20441
+ if (seq !== null) legacySequences.push(seq);
20442
+ }
20443
+ const nameToId = /* @__PURE__ */ new Map();
20444
+ for (const sequence of legacySequences) {
20445
+ const key = sequence.name.trim();
20446
+ if (key === "" || nameToId.has(key)) continue;
20447
+ const created = await createSequence({
20448
+ name: sequence.name,
20449
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20450
+ actions: [...sequence.actions]
20451
+ });
20452
+ nameToId.set(key, created.id);
20453
+ }
20454
+ const onTriggerBindings = [];
20455
+ for (const sequence of legacySequences) {
20456
+ if (!sequence.enabled) continue;
20457
+ const id = nameToId.get(sequence.name.trim());
20458
+ if (id === void 0) continue;
20459
+ onTriggerBindings.push({
20460
+ kind: "sequence",
20461
+ sequenceId: id
20462
+ });
20463
+ }
20464
+ const buttonsRaw = actions["buttons"];
20465
+ const buttons = [];
20466
+ if (Array.isArray(buttonsRaw)) for (const button of buttonsRaw) {
20467
+ if (!isRecord$8(button)) continue;
20468
+ const id = typeof button["id"] === "string" ? button["id"] : "";
20469
+ const label = typeof button["label"] === "string" ? button["label"] : "";
20470
+ if (id === "" || label === "") continue;
20471
+ const existingId = typeof button["sequenceId"] === "string" && button["sequenceId"].length > 0 ? button["sequenceId"] : void 0;
20472
+ const legacyName = legacyButtonSequenceName(button);
20473
+ const sequenceId = existingId ?? (legacyName !== null ? nameToId.get(legacyName.trim()) : void 0);
20474
+ if (sequenceId === void 0) continue;
20475
+ buttons.push({
20476
+ id,
20477
+ label,
20478
+ sequenceId,
20479
+ ...typeof button["icon"] === "string" ? { icon: button["icon"] } : {},
20480
+ ...typeof button["destructive"] === "boolean" ? { destructive: button["destructive"] } : {}
20481
+ });
20482
+ }
20483
+ const next = {
20484
+ ...Array.isArray(actions["onActivation"]) ? { onActivation: actions["onActivation"] } : {},
20485
+ ...Array.isArray(actions["onDeactivation"]) ? { onDeactivation: actions["onDeactivation"] } : {},
20486
+ ...onTriggerBindings.length > 0 ? { onTrigger: onTriggerBindings } : {},
20487
+ ...Array.isArray(actions["onTriggerOff"]) ? { onTriggerOff: actions["onTriggerOff"] } : {},
20488
+ ...buttons.length > 0 ? { buttons } : {}
20489
+ };
20490
+ const parsed = require_clip_model_registry.NcRuleActionsSchema.safeParse(next);
20491
+ if (!parsed.success) return {
20492
+ actions: void 0,
20493
+ migrated: true
20494
+ };
20495
+ return {
20496
+ actions: (parsed.data.onActivation?.length ?? 0) === 0 && (parsed.data.onDeactivation?.length ?? 0) === 0 && (parsed.data.onTrigger?.length ?? 0) === 0 && (parsed.data.onTriggerOff?.length ?? 0) === 0 && (parsed.data.buttons?.length ?? 0) === 0 ? void 0 : parsed.data,
20497
+ migrated: true
20498
+ };
20499
+ }
20500
+ //#endregion
20501
+ //#region src/notification-center/sequence-store.ts
20502
+ /**
20503
+ * Shared action sequences for the notification centre.
20504
+ *
20505
+ * A sequence is a named, ordered run of steps (`wait` | `cap`) with its own
20506
+ * throttle. Rules and notification buttons bind to it by id — never by copying
20507
+ * the steps inline — so two rules that open the same gate share one budget and
20508
+ * one authoring surface.
20509
+ *
20510
+ * Mirrors {@link NcDeviceMuteStore}: declare the collection, mirror into RAM,
20511
+ * write-through so a failed persist never leaves a phantom sequence the next
20512
+ * boot will not know.
20513
+ */
20514
+ /**
20515
+ * @durable class=config owner=notification-center
20516
+ * write="an admin creates/edits/deletes a shared sequence via
20517
+ * `notificationRules.createSequence` / `updateSequence` / `deleteSequence`,
20518
+ * or the one-shot boot migration that materialises legacy inline sequences"
20519
+ * retention="none — a row goes only when an admin deletes it. Bounded by the
20520
+ * sequence set a human is willing to author."
20521
+ */
20522
+ var NC_SEQUENCES_COLLECTION = "notification-center:sequences";
20523
+ var NC_SEQUENCES_COLUMNS = [
20524
+ {
20525
+ name: "id",
20526
+ type: "TEXT",
20527
+ primaryKey: true,
20528
+ notNull: true
20529
+ },
20530
+ {
20531
+ name: "name",
20532
+ type: "TEXT",
20533
+ notNull: true
20534
+ },
20535
+ {
20536
+ name: "minDelaySec",
20537
+ type: "INTEGER"
20538
+ },
20539
+ {
20540
+ name: "actions",
20541
+ type: "JSON",
20542
+ notNull: true
20543
+ },
20544
+ {
20545
+ name: "createdAt",
20546
+ type: "INTEGER",
20547
+ notNull: true
20548
+ },
20549
+ {
20550
+ name: "updatedAt",
20551
+ type: "INTEGER",
20552
+ notNull: true
20553
+ }
20554
+ ];
20555
+ function isRecord$7(value) {
20556
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20557
+ }
20558
+ function parseActions(raw) {
20559
+ if (!Array.isArray(raw) || raw.length === 0) return null;
20560
+ const out = [];
20561
+ for (const entry of raw) {
20562
+ const parsed = require_clip_model_registry.NcRuleActionSchema.safeParse(entry);
20563
+ if (!parsed.success) return null;
20564
+ out.push(parsed.data);
20565
+ }
20566
+ return out;
20567
+ }
20568
+ function rowToSequence(data) {
20569
+ const id = data["id"];
20570
+ const name = data["name"];
20571
+ if (typeof id !== "string" || id.length === 0) return null;
20572
+ if (typeof name !== "string" || name.length === 0) return null;
20573
+ const actions = parseActions(data["actions"]);
20574
+ if (actions === null) return null;
20575
+ const minDelayRaw = data["minDelaySec"];
20576
+ const minDelaySec = typeof minDelayRaw === "number" && Number.isInteger(minDelayRaw) ? minDelayRaw : void 0;
20577
+ const candidate = {
20578
+ id,
20579
+ name,
20580
+ ...minDelaySec !== void 0 ? { minDelaySec } : {},
20581
+ actions: [...actions]
20582
+ };
20583
+ const parsed = require_clip_model_registry.NcSharedSequenceSchema.safeParse(candidate);
20584
+ return parsed.success ? parsed.data : null;
20585
+ }
20586
+ var NcSequenceStore = class {
20587
+ byId = /* @__PURE__ */ new Map();
20588
+ store;
20589
+ logger;
20590
+ now;
20591
+ constructor(deps) {
20592
+ this.store = deps.store;
20593
+ this.logger = deps.logger;
20594
+ this.now = deps.now ?? (() => Date.now());
20595
+ }
20596
+ static async declare(store) {
20597
+ await store.declareCollection.mutate({
20598
+ collection: NC_SEQUENCES_COLLECTION,
20599
+ columns: [...NC_SEQUENCES_COLUMNS]
20600
+ });
20601
+ }
20602
+ /**
20603
+ * (Re)hydrate. A failed load KEEPS what is already in memory — dropping every
20604
+ * sequence on a transient store error would leave every button and binding
20605
+ * pointing at nothing until the next successful load.
20606
+ */
20607
+ async load() {
20608
+ try {
20609
+ const rows = await this.store.query.query({
20610
+ collection: NC_SEQUENCES_COLLECTION,
20611
+ filter: { limit: 1e4 }
20612
+ });
20613
+ const next = /* @__PURE__ */ new Map();
20614
+ let skipped = 0;
20615
+ for (const row of rows) {
20616
+ if (!isRecord$7(row.data)) {
20617
+ skipped += 1;
20618
+ continue;
20619
+ }
20620
+ const sequence = rowToSequence(row.data);
20621
+ if (sequence === null) {
20622
+ skipped += 1;
20623
+ continue;
20624
+ }
20625
+ next.set(sequence.id, sequence);
20626
+ }
20627
+ this.byId.clear();
20628
+ for (const [id, sequence] of next) this.byId.set(id, sequence);
20629
+ if (skipped > 0) this.logger.warn("sequence rows skipped as malformed — bindings to them will miss", { meta: { skipped } });
20630
+ } catch (err) {
20631
+ this.logger.warn("sequence load failed — keeping the sequences already in memory", { meta: {
20632
+ error: String(err),
20633
+ sequences: this.byId.size
20634
+ } });
20635
+ }
20636
+ }
20637
+ list() {
20638
+ return [...this.byId.values()].toSorted((a, b) => a.name.localeCompare(b.name));
20639
+ }
20640
+ get(sequenceId) {
20641
+ return this.byId.get(sequenceId) ?? null;
20642
+ }
20643
+ /** Hot-path existence check for button minting. */
20644
+ has(sequenceId) {
20645
+ return this.byId.has(sequenceId);
20646
+ }
20647
+ knownIds() {
20648
+ return new Set(this.byId.keys());
20649
+ }
20650
+ async create(input) {
20651
+ const now = this.now();
20652
+ const sequence = {
20653
+ id: (0, node_crypto.randomUUID)(),
20654
+ name: input.name,
20655
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20656
+ actions: [...input.actions]
20657
+ };
20658
+ await this.persist(sequence, now, now);
20659
+ this.byId.set(sequence.id, sequence);
20660
+ return sequence;
20661
+ }
20662
+ /**
20663
+ * Create with a CALLER-chosen id — used by the legacy migration so a rewrite
20664
+ * can name the sequence before rewriting the rule that points at it.
20665
+ */
20666
+ async createWithId(sequence) {
20667
+ const now = this.now();
20668
+ const stored = {
20669
+ id: sequence.id,
20670
+ name: sequence.name,
20671
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20672
+ actions: [...sequence.actions]
20673
+ };
20674
+ await this.persist(stored, now, now);
20675
+ this.byId.set(stored.id, stored);
20676
+ return stored;
20677
+ }
20678
+ async update(sequenceId, input) {
20679
+ if (this.byId.get(sequenceId) === void 0) throw new Error(`sequence ${sequenceId} does not exist`);
20680
+ const now = this.now();
20681
+ const sequence = {
20682
+ id: sequenceId,
20683
+ name: input.name,
20684
+ ...input.minDelaySec !== void 0 ? { minDelaySec: input.minDelaySec } : {},
20685
+ actions: [...input.actions]
20686
+ };
20687
+ const createdAt = await this.readCreatedAt(sequenceId, now);
20688
+ await this.persist(sequence, createdAt, now);
20689
+ this.byId.set(sequenceId, sequence);
20690
+ return sequence;
20691
+ }
20692
+ async delete(sequenceId) {
20693
+ await this.store.delete.mutate({
20694
+ collection: NC_SEQUENCES_COLLECTION,
20695
+ key: sequenceId
20696
+ });
20697
+ this.byId.delete(sequenceId);
20698
+ }
20699
+ async readCreatedAt(sequenceId, fallback) {
20700
+ try {
20701
+ const raw = (await this.store.query.query({
20702
+ collection: NC_SEQUENCES_COLLECTION,
20703
+ filter: {
20704
+ where: { id: sequenceId },
20705
+ limit: 1
20706
+ }
20707
+ }))[0]?.data["createdAt"];
20708
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : fallback;
20709
+ } catch {
20710
+ return fallback;
20711
+ }
20712
+ }
20713
+ async persist(sequence, createdAt, updatedAt) {
20714
+ await this.store.set.mutate({
20715
+ collection: NC_SEQUENCES_COLLECTION,
20716
+ key: sequence.id,
20717
+ value: {
20718
+ id: sequence.id,
20719
+ name: sequence.name,
20720
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
20721
+ actions: sequence.actions,
20722
+ createdAt,
20723
+ updatedAt
20724
+ }
20725
+ });
20726
+ }
20727
+ };
20728
+ //#endregion
20336
20729
  //#region src/notification-center/device-prune.ts
20337
20730
  function judgeList(list, isDead) {
20338
20731
  const dead = list.filter(isDead);
@@ -20446,58 +20839,49 @@ function pruneRuleConditions(conditions, isDead, notes) {
20446
20839
  if (sceneDevice !== void 0 && isDead(sceneDevice)) notes.kept("conditions.scene", [String(sceneDevice)]);
20447
20840
  return changed ? next : null;
20448
20841
  }
20449
- /** A sequence's device steps, dead ones dropped. `null` when none was dead. */
20450
- function pruneSequence(sequence, isDead) {
20451
- const dead = [];
20452
- const kept = sequence.actions.filter((step) => {
20453
- if (step.kind !== "cap" || !isDead(step.deviceId)) return true;
20454
- dead.push(step.deviceId);
20455
- return false;
20456
- });
20457
- if (dead.length === 0) return null;
20458
- const anyDeviceStep = kept.some((step) => step.kind === "cap");
20459
- const [first, ...rest] = kept;
20460
- if (!anyDeviceStep || first === void 0) return {
20461
- next: "removed",
20462
- dead
20463
- };
20464
- return {
20465
- next: {
20466
- ...sequence,
20467
- actions: [first, ...rest]
20468
- },
20469
- dead
20842
+ /** A Do binding whose device is dead is dropped; sequence refs are left alone
20843
+ * (shared sequences are pruned on their own store, not per-rule). */
20844
+ function pruneHookBindings(bindings, field, isDead, notes) {
20845
+ if (bindings === void 0) return {
20846
+ next: void 0,
20847
+ changed: false
20470
20848
  };
20471
- }
20472
- /** The actions after a device prune — `null` when no step moved. */
20473
- function pruneRuleActions(actions, isDead, notes) {
20474
- const sequences = actions.onTrigger;
20475
- if (sequences === void 0) return null;
20849
+ const kept = [];
20476
20850
  let changed = false;
20477
- const removedNames = /* @__PURE__ */ new Set();
20478
- const nextSequences = [];
20479
- for (const sequence of sequences) {
20480
- const pruned = pruneSequence(sequence, isDead);
20481
- if (pruned === null) {
20482
- nextSequences.push(sequence);
20851
+ for (const binding of bindings) {
20852
+ if (binding.kind === "sequence") {
20853
+ kept.push(binding);
20483
20854
  continue;
20484
20855
  }
20485
- changed = true;
20486
- const field = `actions.onTrigger.${sequence.name}`;
20487
- if (pruned.next === "removed") {
20488
- removedNames.add(sequence.name);
20489
- notes.removed(field, deviceSubjects(pruned.dead), "sequence-removed");
20490
- } else {
20491
- nextSequences.push(pruned.next);
20492
- notes.removed(field, deviceSubjects(pruned.dead));
20856
+ if (binding.action.kind === "cap" && isDead(binding.action.deviceId)) {
20857
+ changed = true;
20858
+ notes.removed(field, deviceSubjects([binding.action.deviceId]), "binding-removed");
20859
+ continue;
20493
20860
  }
20861
+ kept.push(binding);
20494
20862
  }
20495
- if (!changed) return null;
20496
- const buttons = actions.buttons;
20863
+ if (!changed) return {
20864
+ next: bindings,
20865
+ changed: false
20866
+ };
20867
+ return {
20868
+ next: kept.length > 0 ? kept : void 0,
20869
+ changed: true
20870
+ };
20871
+ }
20872
+ /** The actions after a device prune — `null` when no binding moved. */
20873
+ function pruneRuleActions(actions, isDead, notes) {
20874
+ const onActivation = pruneHookBindings(actions.onActivation, "actions.onActivation", isDead, notes);
20875
+ const onDeactivation = pruneHookBindings(actions.onDeactivation, "actions.onDeactivation", isDead, notes);
20876
+ const onTrigger = pruneHookBindings(actions.onTrigger, "actions.onTrigger", isDead, notes);
20877
+ const onTriggerOff = pruneHookBindings(actions.onTriggerOff, "actions.onTriggerOff", isDead, notes);
20878
+ if (!onActivation.changed && !onDeactivation.changed && !onTrigger.changed && !onTriggerOff.changed) return null;
20497
20879
  return {
20498
- ...actions,
20499
- onTrigger: nextSequences,
20500
- ...buttons !== void 0 ? { buttons: buttons.filter((button) => !removedNames.has(button.sequence)) } : {}
20880
+ ...onActivation.next !== void 0 ? { onActivation: [...onActivation.next] } : {},
20881
+ ...onDeactivation.next !== void 0 ? { onDeactivation: [...onDeactivation.next] } : {},
20882
+ ...onTrigger.next !== void 0 ? { onTrigger: [...onTrigger.next] } : {},
20883
+ ...onTriggerOff.next !== void 0 ? { onTriggerOff: [...onTriggerOff.next] } : {},
20884
+ ...actions.buttons !== void 0 ? { buttons: actions.buttons } : {}
20501
20885
  };
20502
20886
  }
20503
20887
  function planRuleDevices(rule, isDead, guard) {
@@ -24732,6 +25116,10 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
24732
25116
  return {
24733
25117
  camera: deviceName,
24734
25118
  class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
25119
+ classLabel: (() => {
25120
+ const wire = detection?.className ?? (systemEvent !== void 0 ? "" : subject.className);
25121
+ return wire.length === 0 ? "" : require_clip_model_registry.eventKindLabel(wire, texts.language);
25122
+ })(),
24735
25123
  label: detection?.label ?? subject.label ?? "",
24736
25124
  zones: zoneLabels.join(", "),
24737
25125
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -28873,9 +29261,32 @@ function lastRunRow(data) {
28873
29261
  }
28874
29262
  //#endregion
28875
29263
  //#region src/notification-center/rule-actions.ts
29264
+ function wrapDo(action) {
29265
+ const name = action.kind === "wait" ? `wait ${String(action.seconds)}s` : `${action.cap}.${action.method}`;
29266
+ return {
29267
+ id: `do:${name}`,
29268
+ name,
29269
+ enabled: true,
29270
+ actions: [action],
29271
+ throttle: false
29272
+ };
29273
+ }
29274
+ function resolveBinding(binding, lookup) {
29275
+ if (binding.kind === "do") return wrapDo(binding.action);
29276
+ const sequence = lookup(binding.sequenceId);
29277
+ if (sequence === null) return { missing: binding.sequenceId };
29278
+ return {
29279
+ id: sequence.id,
29280
+ name: sequence.name,
29281
+ enabled: true,
29282
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29283
+ actions: sequence.actions,
29284
+ throttle: true
29285
+ };
29286
+ }
28876
29287
  var NcRuleActionRunner = class {
28877
29288
  deps;
28878
- /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
29289
+ /** Last run per `sequenceId` — the throttle's memory (shared across rules). */
28879
29290
  lastRunAt = /* @__PURE__ */ new Map();
28880
29291
  now;
28881
29292
  sleep;
@@ -28885,11 +29296,49 @@ var NcRuleActionRunner = class {
28885
29296
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28886
29297
  }
28887
29298
  /**
28888
- * Run every enabled sequence, in order.
29299
+ * Resolve bindings via the sequence store and run each, in order.
28889
29300
  *
28890
29301
  * Never throws: an actuation that fails must not cost the NOTIFICATION about
28891
- * the very same event. The operator finds out from the log and from the
28892
- * device not having moved — not from silence on both.
29302
+ * the very same event.
29303
+ */
29304
+ async runBindings(input) {
29305
+ const sequences = [];
29306
+ const missing = [];
29307
+ for (const binding of input.bindings) {
29308
+ const resolved = resolveBinding(binding, input.lookup);
29309
+ if ("missing" in resolved) {
29310
+ this.deps.logger.warn("rule action binding skipped — sequence missing", {
29311
+ tags: { deviceId: input.deviceId },
29312
+ meta: {
29313
+ ruleId: input.ruleId,
29314
+ sequenceId: resolved.missing
29315
+ }
29316
+ });
29317
+ missing.push({
29318
+ id: resolved.missing,
29319
+ name: resolved.missing,
29320
+ ran: false,
29321
+ stepsRun: 0,
29322
+ skippedReason: "missing"
29323
+ });
29324
+ continue;
29325
+ }
29326
+ sequences.push(resolved);
29327
+ }
29328
+ const ran = await this.run({
29329
+ ruleId: input.ruleId,
29330
+ ruleName: input.ruleName,
29331
+ deviceId: input.deviceId,
29332
+ sequences,
29333
+ ...input.origin !== void 0 ? { origin: input.origin } : {}
29334
+ });
29335
+ return [...missing, ...ran];
29336
+ }
29337
+ /**
29338
+ * Run every enabled sequence, in order.
29339
+ *
29340
+ * Prefer {@link runBindings} at call sites that hold bindings. This remains
29341
+ * for button grants (force-enabled shared sequence) and tests.
28893
29342
  */
28894
29343
  async run(input) {
28895
29344
  const out = [];
@@ -28898,38 +29347,41 @@ var NcRuleActionRunner = class {
28898
29347
  }
28899
29348
  async runOne(input, sequence) {
28900
29349
  if (!sequence.enabled) return {
29350
+ id: sequence.id,
28901
29351
  name: sequence.name,
28902
29352
  ran: false,
28903
29353
  stepsRun: 0,
28904
29354
  skippedReason: "disabled"
28905
29355
  };
28906
29356
  if (sequence.actions.length === 0) return {
29357
+ id: sequence.id,
28907
29358
  name: sequence.name,
28908
29359
  ran: false,
28909
29360
  stepsRun: 0,
28910
29361
  skippedReason: "empty"
28911
29362
  };
28912
- const key = `${input.ruleId}:${sequence.name}`;
28913
29363
  const now = this.now();
28914
- const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
28915
- const last = this.lastRunAt.get(key);
29364
+ const minDelayMs = sequence.throttle ? (sequence.minDelaySec ?? 0) * 1e3 : 0;
29365
+ const last = this.lastRunAt.get(sequence.id);
28916
29366
  if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
28917
29367
  this.deps.logger.debug("rule action sequence throttled", {
28918
29368
  tags: { deviceId: input.deviceId },
28919
29369
  meta: {
28920
29370
  ruleId: input.ruleId,
29371
+ sequenceId: sequence.id,
28921
29372
  sequence: sequence.name,
28922
29373
  sinceLastMs: now - last
28923
29374
  }
28924
29375
  });
28925
29376
  return {
29377
+ id: sequence.id,
28926
29378
  name: sequence.name,
28927
29379
  ran: false,
28928
29380
  stepsRun: 0,
28929
29381
  skippedReason: "throttled"
28930
29382
  };
28931
29383
  }
28932
- this.lastRunAt.set(key, now);
29384
+ if (sequence.throttle) this.lastRunAt.set(sequence.id, now);
28933
29385
  let stepsRun = 0;
28934
29386
  for (const [index, action] of sequence.actions.entries()) try {
28935
29387
  await this.step(action, input.origin);
@@ -28940,6 +29392,7 @@ var NcRuleActionRunner = class {
28940
29392
  meta: {
28941
29393
  ruleId: input.ruleId,
28942
29394
  rule: input.ruleName,
29395
+ sequenceId: sequence.id,
28943
29396
  sequence: sequence.name,
28944
29397
  step: index,
28945
29398
  action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
@@ -28947,6 +29400,7 @@ var NcRuleActionRunner = class {
28947
29400
  }
28948
29401
  });
28949
29402
  return {
29403
+ id: sequence.id,
28950
29404
  name: sequence.name,
28951
29405
  ran: true,
28952
29406
  stepsRun,
@@ -28958,11 +29412,13 @@ var NcRuleActionRunner = class {
28958
29412
  meta: {
28959
29413
  ruleId: input.ruleId,
28960
29414
  rule: input.ruleName,
29415
+ sequenceId: sequence.id,
28961
29416
  sequence: sequence.name,
28962
29417
  steps: stepsRun
28963
29418
  }
28964
29419
  });
28965
29420
  return {
29421
+ id: sequence.id,
28966
29422
  name: sequence.name,
28967
29423
  ran: true,
28968
29424
  stepsRun
@@ -28983,6 +29439,17 @@ var NcRuleActionRunner = class {
28983
29439
  });
28984
29440
  }
28985
29441
  };
29442
+ /** Force-enable a shared sequence for a button tap (always runs, still throttled). */
29443
+ function asForcedSequence(sequence) {
29444
+ return {
29445
+ id: sequence.id,
29446
+ name: sequence.name,
29447
+ enabled: true,
29448
+ ...sequence.minDelaySec !== void 0 ? { minDelaySec: sequence.minDelaySec } : {},
29449
+ actions: sequence.actions,
29450
+ throttle: true
29451
+ };
29452
+ }
28986
29453
  //#endregion
28987
29454
  //#region src/notification-center/rule-ai-analyst.ts
28988
29455
  /**
@@ -30829,7 +31296,7 @@ function snoozeVerdict(input, deps) {
30829
31296
  });
30830
31297
  if (active === null) return { kind: "clear" };
30831
31298
  const rule = deps.ruleFor(input.ruleId);
30832
- if (rule !== null && isSnoozeExempt(rule)) return {
31299
+ if (rule !== null && isSnoozeExempt(rule, deps.sequenceActionsOf)) return {
30833
31300
  kind: "exempt",
30834
31301
  snooze: active
30835
31302
  };
@@ -36941,6 +37408,8 @@ var NotificationCenter = class {
36941
37408
  snoozes;
36942
37409
  /** The permanent per-camera notification off switch — see `device-mute-store.ts`. */
36943
37410
  deviceMutes;
37411
+ /** Shared action sequences rules and buttons bind to by id. */
37412
+ sequences;
36944
37413
  /** The durable episode rows behind the sustained-state alerts. */
36945
37414
  alertStore;
36946
37415
  /** Battery band → notification, and the reminder sweep. */
@@ -37179,7 +37648,8 @@ var NotificationCenter = class {
37179
37648
  const drafts = [];
37180
37649
  const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
37181
37650
  actions: stored.actions,
37182
- mintUrl: ({ sequence, actionId }) => {
37651
+ knownSequenceIds: this.sequences.knownIds(),
37652
+ mintUrl: ({ sequenceId, actionId }) => {
37183
37653
  const id = (0, node_crypto.randomUUID)();
37184
37654
  drafts.push({
37185
37655
  id,
@@ -37189,7 +37659,7 @@ var NotificationCenter = class {
37189
37659
  id,
37190
37660
  ruleId: input.ruleId,
37191
37661
  ruleName: input.ruleName,
37192
- sequence,
37662
+ sequenceId,
37193
37663
  deviceId: input.deviceId,
37194
37664
  actionId,
37195
37665
  targetId: input.targetId,
@@ -37201,6 +37671,7 @@ var NotificationCenter = class {
37201
37671
  });
37202
37672
  const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
37203
37673
  rule: stored,
37674
+ sequenceActionsOf: this.sequenceActionsOf,
37204
37675
  label: (minutes) => this.snoozeButtonLabel(minutes),
37205
37676
  mintUrl: ({ actionId, minutes }) => {
37206
37677
  const id = (0, node_crypto.randomUUID)();
@@ -37441,7 +37912,7 @@ var NotificationCenter = class {
37441
37912
  */
37442
37913
  async applyGrantedSnooze(grant) {
37443
37914
  const rule = this.rules.get(grant.ruleId);
37444
- if (rule !== null && isSnoozeExempt(rule)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37915
+ if (rule !== null && isSnoozeExempt(rule, this.sequenceActionsOf)) throw new Error(`"${grant.ruleName}" is an alarm rule and cannot be snoozed`);
37445
37916
  const snooze = await this.snoozes.create({
37446
37917
  userId: grant.userId,
37447
37918
  scope: "device",
@@ -37740,16 +38211,13 @@ var NotificationCenter = class {
37740
38211
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
37741
38212
  const rule = this.rules.get(grant.ruleId);
37742
38213
  if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
37743
- const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
37744
- if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
38214
+ const sequence = this.sequences.get(grant.sequenceId);
38215
+ if (sequence === null) throw new Error(`sequence "${grant.sequenceId}" no longer exists`);
37745
38216
  const [outcome] = await this.actionRunner.run({
37746
38217
  ruleId: rule.id,
37747
38218
  ruleName: rule.name,
37748
38219
  deviceId: grant.deviceId,
37749
- sequences: [{
37750
- ...sequence,
37751
- enabled: true
37752
- }]
38220
+ sequences: [asForcedSequence(sequence)]
37753
38221
  });
37754
38222
  if (outcome === void 0) throw new Error("the sequence did not run");
37755
38223
  if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
@@ -37868,6 +38336,11 @@ var NotificationCenter = class {
37868
38336
  logger: this.logger.child("device-mute"),
37869
38337
  ...deps.now !== void 0 ? { now: deps.now } : {}
37870
38338
  });
38339
+ this.sequences = new NcSequenceStore({
38340
+ store: deps.store,
38341
+ logger: this.logger.child("sequences"),
38342
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38343
+ });
37871
38344
  this.alertStore = new NcAlertStore({
37872
38345
  store: deps.store,
37873
38346
  logger: this.logger.child("device-alerts")
@@ -38134,6 +38607,7 @@ var NotificationCenter = class {
38134
38607
  await NcStateSummaryService.declare(store);
38135
38608
  await NcSnoozeStore.declare(store);
38136
38609
  await NcDeviceMuteStore.declare(store);
38610
+ await NcSequenceStore.declare(store);
38137
38611
  await TimelapseStore.declare(store);
38138
38612
  await NcSummaryStore.declare(store);
38139
38613
  await NcCalendarEventsStore.declare(store);
@@ -38195,6 +38669,8 @@ var NotificationCenter = class {
38195
38669
  sinceStartMs: now - t0
38196
38670
  } });
38197
38671
  };
38672
+ await timed("sequences", () => this.sequences.load());
38673
+ await timed("migrateLegacyActions", () => this.migrateLegacyRuleActions());
38198
38674
  await Promise.all([
38199
38675
  timed("texts", () => this.reloadTexts()),
38200
38676
  timed("rules", () => this.rules.load()),
@@ -39494,7 +39970,32 @@ var NotificationCenter = class {
39494
39970
  return { success: true };
39495
39971
  },
39496
39972
  setRuleEnabled: async ({ ruleId, enabled }) => {
39497
- await this.rules.setEnabled(ruleId, enabled);
39973
+ const updated = await this.rules.setEnabled(ruleId, enabled);
39974
+ const bindings = enabled ? updated.actions?.onActivation : updated.actions?.onDeactivation;
39975
+ if (bindings !== void 0 && bindings.length > 0 && this.actionRunner !== null) this.actionRunner.runBindings({
39976
+ ruleId: updated.id,
39977
+ ruleName: updated.name,
39978
+ deviceId: this.deviceIdForRuleActions(updated),
39979
+ bindings,
39980
+ lookup: (id) => this.sequences.get(id)
39981
+ });
39982
+ return { success: true };
39983
+ },
39984
+ listSequences: async () => ({ sequences: [...this.sequences.list()] }),
39985
+ getSequence: async ({ sequenceId }) => ({ sequence: this.sequences.get(sequenceId) }),
39986
+ createSequence: async ({ sequence }) => {
39987
+ const created = await this.sequences.create(sequence);
39988
+ this.logger.info("notification sequence created", { meta: { sequenceId: created.id } });
39989
+ return { sequence: created };
39990
+ },
39991
+ updateSequence: async ({ sequenceId, sequence }) => {
39992
+ const updated = await this.sequences.update(sequenceId, sequence);
39993
+ this.logger.info("notification sequence updated", { meta: { sequenceId } });
39994
+ return { sequence: updated };
39995
+ },
39996
+ deleteSequence: async ({ sequenceId }) => {
39997
+ await this.sequences.delete(sequenceId);
39998
+ this.logger.info("notification sequence deleted", { meta: { sequenceId } });
39498
39999
  return { success: true };
39499
40000
  },
39500
40001
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
@@ -40991,7 +41492,7 @@ var NotificationCenter = class {
40991
41492
  } : {}
40992
41493
  }
40993
41494
  });
40994
- const unresolvable = unresolvableButtons(rule.actions);
41495
+ const unresolvable = unresolvableButtons(rule.actions, this.sequences.knownIds());
40995
41496
  if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
40996
41497
  tags: { deviceId: subject.deviceId },
40997
41498
  meta: {
@@ -41118,20 +41619,85 @@ var NotificationCenter = class {
41118
41619
  return this.groupStore.list(query);
41119
41620
  }
41120
41621
  /**
41121
- * Run this rule's `onTrigger` sequences for this match.
41622
+ * Materialise legacy inline `onTrigger` sequences into the shared store and
41623
+ * rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
41624
+ * rows before {@link NcRuleStore.load} so Zod never skips them.
41625
+ */
41626
+ async migrateLegacyRuleActions() {
41627
+ let rows;
41628
+ try {
41629
+ rows = await this.deps.store.query.query({
41630
+ collection: NC_RULES_COLLECTION,
41631
+ filter: { limit: 1e4 }
41632
+ });
41633
+ } catch (err) {
41634
+ this.logger.warn("legacy actions migration skipped — could not read rules", { meta: { error: String(err) } });
41635
+ return;
41636
+ }
41637
+ let migrated = 0;
41638
+ for (const row of rows) {
41639
+ const ruleBlob = row.data["rule"];
41640
+ if (typeof ruleBlob !== "object" || ruleBlob === null || Array.isArray(ruleBlob)) continue;
41641
+ const rule = { ...ruleBlob };
41642
+ const ruleId = typeof rule["id"] === "string" ? rule["id"] : void 0;
41643
+ if (ruleId === void 0) continue;
41644
+ const result = await migrateLegacyRuleActions(rule["actions"], async (input) => {
41645
+ return { id: (await this.sequences.create(input)).id };
41646
+ });
41647
+ if (!result.migrated) continue;
41648
+ if (result.actions !== void 0) rule["actions"] = result.actions;
41649
+ else delete rule["actions"];
41650
+ const { id: _rowId, ...columns } = row.data;
41651
+ await this.deps.store.set.mutate({
41652
+ collection: NC_RULES_COLLECTION,
41653
+ key: ruleId,
41654
+ value: {
41655
+ ...columns,
41656
+ rule
41657
+ }
41658
+ });
41659
+ migrated += 1;
41660
+ }
41661
+ if (migrated > 0) this.logger.info("migrated legacy inline rule actions into shared sequences", { meta: {
41662
+ rules: migrated,
41663
+ sequences: this.sequences.list().length
41664
+ } });
41665
+ }
41666
+ /**
41667
+ * True when this match is a clear / leave edge — the subject for
41668
+ * `onTriggerOff`. Raise / enter / ordinary matches use `onTrigger`.
41669
+ */
41670
+ isTriggerOffSubject(subject) {
41671
+ if (subject.sensorRaised === false) return true;
41672
+ if (subject.occupancy !== void 0 && subject.occupancy.occupied === false) return true;
41673
+ return false;
41674
+ }
41675
+ /** Device id for activation/deactivation log tags — first scoped device, else 0. */
41676
+ deviceIdForRuleActions(rule) {
41677
+ const devices = rule.conditions.devices;
41678
+ if (devices !== void 0 && devices.length > 0) {
41679
+ const first = devices[0];
41680
+ if (first !== void 0) return first;
41681
+ }
41682
+ return 0;
41683
+ }
41684
+ sequenceActionsOf = (sequenceId) => this.sequences.get(sequenceId)?.actions;
41685
+ /**
41686
+ * Run this rule's onTrigger / onTriggerOff bindings for this match.
41122
41687
  *
41123
41688
  * Called BEFORE the cooldown gate — see the call site for why. Everything
41124
41689
  * here is fire-and-forget and nothing may throw into the evaluation.
41125
41690
  */
41126
41691
  runRuleActions(rule, subject, kind, controls) {
41127
- const sequences = rule.actions?.onTrigger;
41128
- if (sequences === void 0 || sequences.length === 0) return;
41129
41692
  if (this.actionRunner === null || controls.runActions === false) return;
41130
- this.actionRunner.run({
41693
+ const bindings = this.isTriggerOffSubject(subject) ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41694
+ if (bindings === void 0 || bindings.length === 0) return;
41695
+ this.actionRunner.runBindings({
41131
41696
  ruleId: rule.id,
41132
41697
  ruleName: rule.name,
41133
41698
  deviceId: subject.deviceId,
41134
- sequences,
41699
+ bindings,
41700
+ lookup: (id) => this.sequences.get(id),
41135
41701
  origin: fireRefFor(rule, subject, kind)
41136
41702
  });
41137
41703
  }
@@ -42035,7 +42601,8 @@ var NotificationCenter = class {
42035
42601
  snoozeDecision(input) {
42036
42602
  const verdict = snoozeVerdict(input, {
42037
42603
  activeFor: (query) => this.snoozes.activeFor(query),
42038
- ruleFor: (ruleId) => this.rules.get(ruleId)
42604
+ ruleFor: (ruleId) => this.rules.get(ruleId),
42605
+ sequenceActionsOf: this.sequenceActionsOf
42039
42606
  });
42040
42607
  if (verdict.kind === "exempt") this.logger.info("snooze does not apply — alarm rules are never silenced", {
42041
42608
  tags: { deviceId: input.deviceId },