@checkstack/automation-backend 0.2.0 → 0.3.0
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/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createDefaultFilterRegistry } from "@checkstack/template-engine";
|
|
3
|
+
import {
|
|
4
|
+
ConditionSchema,
|
|
5
|
+
type Condition,
|
|
6
|
+
} from "@checkstack/automation-common";
|
|
7
|
+
import { evaluateCondition } from "./condition";
|
|
8
|
+
import { evaluateTimeCondition } from "./structured-conditions";
|
|
9
|
+
|
|
10
|
+
const filters = createDefaultFilterRegistry();
|
|
11
|
+
|
|
12
|
+
function evalCond(
|
|
13
|
+
condition: Condition,
|
|
14
|
+
scope: Record<string, unknown>,
|
|
15
|
+
): boolean {
|
|
16
|
+
return evaluateCondition(condition, scope, filters);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Scope mirroring the engine's pre-resolved `health.*` namespace.
|
|
20
|
+
function healthScope(
|
|
21
|
+
systems: Record<
|
|
22
|
+
string,
|
|
23
|
+
{ status: string; in_status_for_ms?: number }
|
|
24
|
+
>,
|
|
25
|
+
): Record<string, unknown> {
|
|
26
|
+
return { health: { systems, system: undefined } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("numeric_state condition", () => {
|
|
30
|
+
it("compares a literal number value", () => {
|
|
31
|
+
expect(
|
|
32
|
+
evalCond({ numeric_state: { value: 600, above: 500 } }, {}),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
evalCond({ numeric_state: { value: 400, above: 500 } }, {}),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("resolves a path/template string against scope", () => {
|
|
40
|
+
const scope = healthScope({ "sys-1": { status: "unhealthy" } });
|
|
41
|
+
(scope.health as Record<string, unknown>).system = {
|
|
42
|
+
p95_latency_ms: 700,
|
|
43
|
+
};
|
|
44
|
+
expect(
|
|
45
|
+
evalCond(
|
|
46
|
+
{ numeric_state: { value: "health.system.p95_latency_ms", below: 800 } },
|
|
47
|
+
scope,
|
|
48
|
+
),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
expect(
|
|
51
|
+
evalCond(
|
|
52
|
+
{ numeric_state: { value: "health.system.p95_latency_ms", below: 500 } },
|
|
53
|
+
scope,
|
|
54
|
+
),
|
|
55
|
+
).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is false when the value resolves to non-numeric", () => {
|
|
59
|
+
expect(
|
|
60
|
+
evalCond({ numeric_state: { value: "missing.path", above: 1 } }, {}),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("state condition", () => {
|
|
66
|
+
it("true when the entity is in the status (no dwell)", () => {
|
|
67
|
+
const scope = healthScope({ "sys-1": { status: "unhealthy" } });
|
|
68
|
+
expect(
|
|
69
|
+
evalCond({ state: { entity: "sys-1", status: "unhealthy" } }, scope),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("false when the entity is in a different status", () => {
|
|
74
|
+
const scope = healthScope({ "sys-1": { status: "healthy" } });
|
|
75
|
+
expect(
|
|
76
|
+
evalCond({ state: { entity: "sys-1", status: "unhealthy" } }, scope),
|
|
77
|
+
).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("respects the dwell (in_status_for_ms >= for)", () => {
|
|
81
|
+
const scope = healthScope({
|
|
82
|
+
"sys-1": { status: "unhealthy", in_status_for_ms: 31 * 60_000 },
|
|
83
|
+
});
|
|
84
|
+
expect(
|
|
85
|
+
evalCond(
|
|
86
|
+
{ state: { entity: "sys-1", status: "unhealthy", for: { minutes: 30 } } },
|
|
87
|
+
scope,
|
|
88
|
+
),
|
|
89
|
+
).toBe(true);
|
|
90
|
+
|
|
91
|
+
const shortScope = healthScope({
|
|
92
|
+
"sys-1": { status: "unhealthy", in_status_for_ms: 10 * 60_000 },
|
|
93
|
+
});
|
|
94
|
+
expect(
|
|
95
|
+
evalCond(
|
|
96
|
+
{ state: { entity: "sys-1", status: "unhealthy", for: { minutes: 30 } } },
|
|
97
|
+
shortScope,
|
|
98
|
+
),
|
|
99
|
+
).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("false when the entity is absent from scope", () => {
|
|
103
|
+
expect(
|
|
104
|
+
evalCond(
|
|
105
|
+
{ state: { entity: "nope", status: "unhealthy" } },
|
|
106
|
+
healthScope({}),
|
|
107
|
+
),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("time condition", () => {
|
|
113
|
+
// 2026-05-30 is a Saturday. 12:00 UTC.
|
|
114
|
+
const noonUtc = new Date("2026-05-30T12:00:00.000Z");
|
|
115
|
+
|
|
116
|
+
it("after/before same-day window", () => {
|
|
117
|
+
expect(
|
|
118
|
+
evaluateTimeCondition(
|
|
119
|
+
{ time: { after: "09:00", before: "17:00", timezone: "UTC" } },
|
|
120
|
+
noonUtc,
|
|
121
|
+
),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
expect(
|
|
124
|
+
evaluateTimeCondition(
|
|
125
|
+
{ time: { after: "13:00", before: "17:00", timezone: "UTC" } },
|
|
126
|
+
noonUtc,
|
|
127
|
+
),
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("overnight window wrapping midnight (22:00 → 06:00)", () => {
|
|
132
|
+
const at23 = new Date("2026-05-30T23:30:00.000Z");
|
|
133
|
+
const at03 = new Date("2026-05-30T03:30:00.000Z");
|
|
134
|
+
const at12 = noonUtc;
|
|
135
|
+
const window = {
|
|
136
|
+
time: { after: "22:00", before: "06:00", timezone: "UTC" },
|
|
137
|
+
} as const;
|
|
138
|
+
expect(evaluateTimeCondition(window, at23)).toBe(true);
|
|
139
|
+
expect(evaluateTimeCondition(window, at03)).toBe(true);
|
|
140
|
+
expect(evaluateTimeCondition(window, at12)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("weekday filter (Saturday = 6)", () => {
|
|
144
|
+
expect(
|
|
145
|
+
evaluateTimeCondition({ time: { weekday: [6], timezone: "UTC" } }, noonUtc),
|
|
146
|
+
).toBe(true);
|
|
147
|
+
expect(
|
|
148
|
+
evaluateTimeCondition(
|
|
149
|
+
{ time: { weekday: [1, 2, 3, 4, 5], timezone: "UTC" } },
|
|
150
|
+
noonUtc,
|
|
151
|
+
),
|
|
152
|
+
).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("respects timezone (12:00 UTC = 14:00 Europe/Berlin in summer)", () => {
|
|
156
|
+
// 12:00 UTC is 14:00 in Berlin (CEST). after 13:00 Berlin → true.
|
|
157
|
+
expect(
|
|
158
|
+
evaluateTimeCondition(
|
|
159
|
+
{ time: { after: "13:00", before: "15:00", timezone: "Europe/Berlin" } },
|
|
160
|
+
noonUtc,
|
|
161
|
+
),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
// In UTC the same instant is 12:00, before 13:00 → false.
|
|
164
|
+
expect(
|
|
165
|
+
evaluateTimeCondition(
|
|
166
|
+
{ time: { after: "13:00", before: "15:00", timezone: "UTC" } },
|
|
167
|
+
noonUtc,
|
|
168
|
+
),
|
|
169
|
+
).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("weekday can differ across timezones at the day boundary", () => {
|
|
173
|
+
// 2026-05-30T23:30 UTC is still Saturday(6) in UTC but Sunday(0) in Tokyo.
|
|
174
|
+
const lateUtc = new Date("2026-05-30T23:30:00.000Z");
|
|
175
|
+
expect(
|
|
176
|
+
evaluateTimeCondition({ time: { weekday: [6], timezone: "UTC" } }, lateUtc),
|
|
177
|
+
).toBe(true);
|
|
178
|
+
expect(
|
|
179
|
+
evaluateTimeCondition(
|
|
180
|
+
{ time: { weekday: [0], timezone: "Asia/Tokyo" } },
|
|
181
|
+
lateUtc,
|
|
182
|
+
),
|
|
183
|
+
).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("structured conditions compose with and/or/not", () => {
|
|
188
|
+
it("and combines a state + numeric_state", () => {
|
|
189
|
+
const scope = healthScope({
|
|
190
|
+
"sys-1": { status: "unhealthy", in_status_for_ms: 40 * 60_000 },
|
|
191
|
+
});
|
|
192
|
+
(scope.health as Record<string, unknown>).system = { p95_latency_ms: 900 };
|
|
193
|
+
const condition: Condition = {
|
|
194
|
+
and: [
|
|
195
|
+
{ state: { entity: "sys-1", status: "unhealthy", for: { minutes: 30 } } },
|
|
196
|
+
{ numeric_state: { value: "health.system.p95_latency_ms", above: 500 } },
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
expect(evalCond(condition, scope)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("not negates a structured variant", () => {
|
|
203
|
+
const scope = healthScope({ "sys-1": { status: "healthy" } });
|
|
204
|
+
expect(
|
|
205
|
+
evalCond(
|
|
206
|
+
{ not: { state: { entity: "sys-1", status: "unhealthy" } } },
|
|
207
|
+
scope,
|
|
208
|
+
),
|
|
209
|
+
).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("condition grammar round-trips through zod", () => {
|
|
214
|
+
const cases: Condition[] = [
|
|
215
|
+
"trigger.payload.x == 1",
|
|
216
|
+
{ and: ["a == 1", { numeric_state: { value: "v", above: 5 } }] },
|
|
217
|
+
{ or: [{ time: { after: "09:00", weekday: [1, 2] } }, "b == 2"] },
|
|
218
|
+
{ not: { state: { entity: "sys-1", status: "degraded", for: { hours: 2 } } } },
|
|
219
|
+
{ numeric_state: { value: 10, above: 1, below: 100 } },
|
|
220
|
+
{ time: { after: "22:00", before: "06:00", timezone: "Europe/Berlin" } },
|
|
221
|
+
{ state: { entity: "sys-9", status: "unhealthy" } },
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
for (const [i, c] of cases.entries()) {
|
|
225
|
+
it(`parses + preserves case ${i}`, () => {
|
|
226
|
+
const parsed = ConditionSchema.parse(c);
|
|
227
|
+
expect(parsed).toEqual(c);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
it("rejects numeric_state with no bounds", () => {
|
|
232
|
+
expect(() =>
|
|
233
|
+
ConditionSchema.parse({ numeric_state: { value: 1 } }),
|
|
234
|
+
).toThrow();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("rejects time with no fields", () => {
|
|
238
|
+
expect(() => ConditionSchema.parse({ time: {} })).toThrow();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("rejects a malformed HH:mm", () => {
|
|
242
|
+
expect(() =>
|
|
243
|
+
ConditionSchema.parse({ time: { after: "9am" } }),
|
|
244
|
+
).toThrow();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluators for the structured condition variants (Wave 2 Phase 16):
|
|
3
|
+
* `numeric_state`, `time`, and `state`. All pure + synchronous; they read
|
|
4
|
+
* the pre-resolved scope (`health.*`) and compute a FRESH `now` per call
|
|
5
|
+
* (constraint 7 — never the frozen scope `now`).
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
evaluate,
|
|
9
|
+
parseCondition,
|
|
10
|
+
type FilterRegistry,
|
|
11
|
+
type TemplateContext,
|
|
12
|
+
} from "@checkstack/template-engine";
|
|
13
|
+
import {
|
|
14
|
+
durationToMs,
|
|
15
|
+
type NumericStateCondition,
|
|
16
|
+
type StateCondition,
|
|
17
|
+
type TimeCondition,
|
|
18
|
+
} from "@checkstack/automation-common";
|
|
19
|
+
|
|
20
|
+
import { matchesThreshold, toNumberOrNull } from "./numeric";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `numeric_state`: resolve `value` (literal number, or a template/path
|
|
24
|
+
* string rendered against scope) and compare to `above` / `below`.
|
|
25
|
+
*/
|
|
26
|
+
export function evaluateNumericStateCondition(
|
|
27
|
+
condition: NumericStateCondition,
|
|
28
|
+
context: TemplateContext,
|
|
29
|
+
filters: FilterRegistry,
|
|
30
|
+
): boolean {
|
|
31
|
+
const { value, above, below } = condition.numeric_state;
|
|
32
|
+
let resolved: number | null;
|
|
33
|
+
if (typeof value === "number") {
|
|
34
|
+
resolved = Number.isFinite(value) ? value : null;
|
|
35
|
+
} else {
|
|
36
|
+
// Treat the string as a template expression so `{{ }}`-free paths
|
|
37
|
+
// (e.g. `health.system.p95_latency_ms`) resolve to the raw value.
|
|
38
|
+
const raw = evaluate(parseCondition(value), context, { filters });
|
|
39
|
+
resolved = toNumberOrNull(raw);
|
|
40
|
+
}
|
|
41
|
+
return matchesThreshold({ value: resolved, above, below });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `time`: gate on time-of-day / weekday in a given timezone. Uses a fresh
|
|
46
|
+
* `now` (passed in) and `Intl` to resolve wall-clock fields in `timezone`.
|
|
47
|
+
*/
|
|
48
|
+
export function evaluateTimeCondition(
|
|
49
|
+
condition: TimeCondition,
|
|
50
|
+
now: Date,
|
|
51
|
+
): boolean {
|
|
52
|
+
const { after, before, weekday, timezone } = condition.time;
|
|
53
|
+
const tz = timezone ?? "UTC";
|
|
54
|
+
|
|
55
|
+
const { minutes, weekday: dow } = wallClock(now, tz);
|
|
56
|
+
|
|
57
|
+
if (weekday && !weekday.includes(dow)) return false;
|
|
58
|
+
|
|
59
|
+
const afterMin = after ? hhmmToMinutes(after) : undefined;
|
|
60
|
+
const beforeMin = before ? hhmmToMinutes(before) : undefined;
|
|
61
|
+
|
|
62
|
+
if (afterMin !== undefined && beforeMin !== undefined) {
|
|
63
|
+
if (afterMin <= beforeMin) {
|
|
64
|
+
// Same-day window: [after, before).
|
|
65
|
+
return minutes >= afterMin && minutes < beforeMin;
|
|
66
|
+
}
|
|
67
|
+
// Overnight window wrapping midnight (e.g. 22:00 → 06:00): match when
|
|
68
|
+
// at/after `after` OR before `before`.
|
|
69
|
+
return minutes >= afterMin || minutes < beforeMin;
|
|
70
|
+
}
|
|
71
|
+
if (afterMin !== undefined && minutes < afterMin) return false;
|
|
72
|
+
if (beforeMin !== undefined && minutes >= beforeMin) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `state`: read the entity's pre-resolved health from scope and check it
|
|
78
|
+
* is in `status` for at least `for` (from `in_status_for_ms`). No timer —
|
|
79
|
+
* it reads the duration the provider already computed.
|
|
80
|
+
*/
|
|
81
|
+
export function evaluateStateCondition(
|
|
82
|
+
condition: StateCondition,
|
|
83
|
+
context: TemplateContext,
|
|
84
|
+
): boolean {
|
|
85
|
+
const { entity, status, for: dwell } = condition.state;
|
|
86
|
+
const health = (context as Record<string, unknown>).health;
|
|
87
|
+
if (!isRecord(health)) return false;
|
|
88
|
+
const systems = isRecord(health.systems) ? health.systems : undefined;
|
|
89
|
+
const entityState = systems && isRecord(systems[entity]) ? systems[entity] : undefined;
|
|
90
|
+
if (!entityState) return false;
|
|
91
|
+
|
|
92
|
+
if (entityState.status !== status) return false;
|
|
93
|
+
|
|
94
|
+
if (dwell) {
|
|
95
|
+
const requiredMs = durationToMs(dwell);
|
|
96
|
+
if (requiredMs === null) return false;
|
|
97
|
+
const heldMs = toNumberOrNull(entityState.in_status_for_ms) ?? 0;
|
|
98
|
+
if (heldMs < requiredMs) return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
106
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hhmmToMinutes(hhmm: string): number {
|
|
110
|
+
const [h, m] = hhmm.split(":");
|
|
111
|
+
return Number(h) * 60 + Number(m);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve wall-clock minutes-since-midnight + weekday (0 = Sunday) for a
|
|
116
|
+
* given instant in a given IANA timezone, using Intl (no external tz lib).
|
|
117
|
+
*/
|
|
118
|
+
function wallClock(
|
|
119
|
+
now: Date,
|
|
120
|
+
timezone: string,
|
|
121
|
+
): { minutes: number; weekday: number } {
|
|
122
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
123
|
+
timeZone: timezone,
|
|
124
|
+
hour: "2-digit",
|
|
125
|
+
minute: "2-digit",
|
|
126
|
+
weekday: "short",
|
|
127
|
+
hour12: false,
|
|
128
|
+
});
|
|
129
|
+
const parts = fmt.formatToParts(now);
|
|
130
|
+
const get = (type: string) =>
|
|
131
|
+
parts.find((p) => p.type === type)?.value ?? "";
|
|
132
|
+
// hour can come back as "24" at midnight in some engines — normalise.
|
|
133
|
+
const hour = Number(get("hour")) % 24;
|
|
134
|
+
const minute = Number(get("minute"));
|
|
135
|
+
const weekdayMap: Record<string, number> = {
|
|
136
|
+
Sun: 0,
|
|
137
|
+
Mon: 1,
|
|
138
|
+
Tue: 2,
|
|
139
|
+
Wed: 3,
|
|
140
|
+
Thu: 4,
|
|
141
|
+
Fri: 5,
|
|
142
|
+
Sat: 6,
|
|
143
|
+
};
|
|
144
|
+
const weekday = weekdayMap[get("weekday")] ?? 0;
|
|
145
|
+
return { minutes: hour * 60 + minute, weekday };
|
|
146
|
+
}
|