@intentius/chant 0.58.0 → 0.60.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/dist/audit/core.d.ts +17 -1
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +15 -4
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/build-params.d.ts +2 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +13 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts +0 -16
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +22 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +19 -4
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts +8 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
- package/dist/components/verbs/run-agent.d.ts +1 -7
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/detectLexicon.d.ts +13 -0
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lexicon.d.ts +213 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +9 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -4
- package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +134 -2
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/change-signal.d.ts +91 -0
- package/dist/op/change-signal.d.ts.map +1 -0
- package/dist/op/composites/apply-op.d.ts +7 -2
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +40 -0
- package/dist/op/gate-name.d.ts.map +1 -0
- package/dist/op/gate-summary.d.ts +55 -0
- package/dist/op/gate-summary.d.ts.map +1 -0
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +63 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/types.d.ts +18 -3
- package/dist/op/types.d.ts.map +1 -1
- package/dist/params.d.ts +1 -1
- package/dist/project-root.d.ts +2 -2
- package/dist/terraform/bridge.d.ts +26 -7
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +28 -2
- package/dist/terraform/carve-provider.d.ts.map +1 -1
- package/dist/terraform/data-source-shape.d.ts +77 -0
- package/dist/terraform/data-source-shape.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/providers/kubernetes.d.ts +5 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +16 -4
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +8 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +25 -3
- package/src/audit/discover.ts +122 -14
- package/src/build-params.ts +2 -2
- package/src/cli/commands/audit.test.ts +5 -3
- package/src/cli/commands/audit.ts +5 -1
- package/src/cli/commands/build.test.ts +39 -1
- package/src/cli/commands/build.ts +22 -8
- package/src/cli/commands/carve-bridge.test.ts +237 -14
- package/src/cli/commands/carve-bridge.ts +21 -17
- package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
- package/src/cli/commands/lint.test.ts +297 -1
- package/src/cli/commands/lint.ts +119 -11
- package/src/cli/handlers/graph.test.ts +4 -4
- package/src/cli/handlers/graph.ts +11 -11
- package/src/cli/handlers/lint.test.ts +107 -0
- package/src/cli/handlers/lint.ts +30 -0
- package/src/cli/handlers/operator.ts +81 -0
- package/src/cli/handlers/run.test.ts +132 -3
- package/src/cli/handlers/run.ts +87 -3
- package/src/cli/main.test.ts +9 -11
- package/src/cli/main.ts +5 -27
- package/src/cli/plugins.test.ts +68 -2
- package/src/cli/plugins.ts +39 -0
- package/src/cli/registry.ts +14 -0
- package/src/components/README.md +2 -2
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/__fixtures__/neo4j-fanout.json +1 -1
- package/src/components/cli-support.test.ts +18 -7
- package/src/components/cli-support.ts +8 -3
- package/src/components/component-schema.test.ts +18 -2
- package/src/components/component.schema.json +17 -4
- package/src/components/component.test.ts +2 -2
- package/src/components/component.ts +25 -5
- package/src/components/config-defaults.test.ts +2 -2
- package/src/components/driver.test.ts +20 -1
- package/src/components/driver.ts +12 -5
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +2 -2
- package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
- package/src/components/verbs/run-agent.test.ts +19 -0
- package/src/components/verbs/run-agent.ts +1 -7
- package/src/config.ts +4 -4
- package/src/detectLexicon.ts +18 -1
- package/src/discovery/fold-import.test.ts +2 -2
- package/src/discovery/fold-import.ts +3 -3
- package/src/fold/foldable-helpers.ts +1 -1
- package/src/graph-ops.test.ts +1 -1
- package/src/lexicon.ts +221 -3
- package/src/lifecycle/gate-ledger.ts +12 -1
- package/src/lifecycle/observe.test.ts +2 -2
- package/src/lifecycle/observe.ts +8 -8
- package/src/lifecycle/release-ledger.test.ts +2 -2
- package/src/lint/pipeline-change-gate.test.ts +2 -2
- package/src/lint/rules/comp/comp.test.ts +26 -0
- package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
- package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
- package/src/op/activities/index.ts +9 -2
- package/src/op/activities/reconcile.test.ts +320 -2
- package/src/op/activities/reconcile.ts +423 -2
- package/src/op/builders.ts +3 -3
- package/src/op/change-signal.test.ts +117 -0
- package/src/op/change-signal.ts +169 -0
- package/src/op/composites/apply-op.ts +14 -4
- package/src/op/composites/composites.test.ts +17 -4
- package/src/op/composites/reconcile-op.ts +7 -4
- package/src/op/effect-step.test.ts +3 -3
- package/src/op/gate-name.test.ts +65 -0
- package/src/op/gate-name.ts +60 -0
- package/src/op/gate-summary.test.ts +62 -0
- package/src/op/gate-summary.ts +96 -0
- package/src/op/index.ts +9 -2
- package/src/op/local-executor.test.ts +16 -1
- package/src/op/local-executor.ts +4 -3
- package/src/op/op-ir.test.ts +12 -1
- package/src/op/op-ir.ts +5 -3
- package/src/op/op-verb-class.test.ts +2 -2
- package/src/op/op.test.ts +2 -2
- package/src/op/operator.test.ts +368 -0
- package/src/op/operator.ts +141 -5
- package/src/op/runtimes/local.test.ts +1 -1
- package/src/op/types.ts +23 -3
- package/src/params.ts +1 -1
- package/src/project-root.ts +2 -2
- package/src/terraform/aws-resources.test.ts +13 -4
- package/src/terraform/bridge.test.ts +22 -9
- package/src/terraform/bridge.ts +89 -28
- package/src/terraform/carve-provider.ts +38 -2
- package/src/terraform/data-source-shape.ts +95 -0
- package/src/terraform/graph.ts +35 -7
- package/src/terraform/providers/kubernetes.ts +48 -2
- package/src/terraform/tier-map.ts +21 -6
- package/src/terraform/types.ts +8 -0
package/src/op/local-executor.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { ReceiptReadResult } from "./receipt-store";
|
|
|
26
26
|
import { isStepOutputRef } from "./step-output-ref";
|
|
27
27
|
import { parseDuration } from "./duration";
|
|
28
28
|
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
29
|
+
import { gateName } from "./gate-name";
|
|
29
30
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
30
31
|
import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
|
|
31
32
|
import type { OpRunRecord } from "./runtime";
|
|
@@ -342,7 +343,7 @@ function pushRecord(sink: StepRecord[], ctx: GateContext, ...recs: StepRecord[])
|
|
|
342
343
|
|
|
343
344
|
/** The record name a gate step lands under, so a reader (and a JSON consumer) can pick it out of `records`. */
|
|
344
345
|
function gateFn(step: GateStep): string {
|
|
345
|
-
return `gate:${step
|
|
346
|
+
return `gate:${gateName(step)}`;
|
|
346
347
|
}
|
|
347
348
|
|
|
348
349
|
/**
|
|
@@ -359,7 +360,7 @@ async function runGateStep(
|
|
|
359
360
|
const start = Date.now();
|
|
360
361
|
const check = await evaluateGate(gates.port, {
|
|
361
362
|
op: gates.op,
|
|
362
|
-
gate: step
|
|
363
|
+
gate: gateName(step),
|
|
363
364
|
...(step.description ? { description: step.description } : {}),
|
|
364
365
|
...(step.timeout ? { timeout: step.timeout } : {}),
|
|
365
366
|
...(gates.runId ? { runId: gates.runId } : {}),
|
|
@@ -376,7 +377,7 @@ async function runGateStep(
|
|
|
376
377
|
status: "ok",
|
|
377
378
|
durationMs: Date.now() - start,
|
|
378
379
|
approval: {
|
|
379
|
-
gate: step
|
|
380
|
+
gate: gateName(step),
|
|
380
381
|
resolvedBy: resolution.resolvedBy,
|
|
381
382
|
timestamp: resolution.timestamp,
|
|
382
383
|
...(resolution.url ? { url: resolution.url } : {}),
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -126,7 +126,7 @@ describe("op.json IR", () => {
|
|
|
126
126
|
expect(build.steps[0]).toMatchObject({ kind: "activity", fn: "shellCmd", profile: "fastIdempotent" });
|
|
127
127
|
expect(approve.steps[0]).toMatchObject({
|
|
128
128
|
kind: "gate",
|
|
129
|
-
|
|
129
|
+
gate: "approve-deploy",
|
|
130
130
|
timeout: "24h",
|
|
131
131
|
description: "Release manager sign-off",
|
|
132
132
|
});
|
|
@@ -141,6 +141,17 @@ describe("op.json IR", () => {
|
|
|
141
141
|
expect(ir.onFailure[0].name).toBe("Rollback");
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
// #2202: whichever key an authored gate step used, the IR writes `gate`, so
|
|
145
|
+
// a foreign consumer of op.json never sees the deprecated spelling.
|
|
146
|
+
it("normalizes a gate step's deprecated `signalName` key onto `gate`", () => {
|
|
147
|
+
const config = representativeOp();
|
|
148
|
+
const approve = config.phases.find((p) => p.name === "Approve")!;
|
|
149
|
+
approve.steps[0] = { kind: "gate", signalName: "approve-deploy", timeout: "24h" };
|
|
150
|
+
const ir = buildOpIR(config);
|
|
151
|
+
expect(ir.phases[1].steps[0]).toMatchObject({ kind: "gate", gate: "approve-deploy", timeout: "24h" });
|
|
152
|
+
expect(serializeOpIR(config)).not.toContain("signalName");
|
|
153
|
+
});
|
|
154
|
+
|
|
144
155
|
it("contracts are the injected registry's: empty until a caller supplies one", () => {
|
|
145
156
|
const bare = buildOpIR(representativeOp());
|
|
146
157
|
expect(bare.activityContracts).toEqual({});
|
package/src/op/op-ir.ts
CHANGED
|
@@ -95,6 +95,7 @@ import type {
|
|
|
95
95
|
import type { EffectReceiptRef } from "./receipt-store";
|
|
96
96
|
import type { ActivityContract } from "./activity-contract";
|
|
97
97
|
import { ACTIVITY_PROFILES, type ActivityProfile } from "./activity-profiles";
|
|
98
|
+
import { gateName } from "./gate-name";
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
101
|
* The op.json IR schema version. Bumped on a breaking change to this
|
|
@@ -131,7 +132,8 @@ export interface OpIRActivityStep {
|
|
|
131
132
|
|
|
132
133
|
export interface OpIRGateStep {
|
|
133
134
|
kind: "gate";
|
|
134
|
-
signalName
|
|
135
|
+
/** The gate's name — `GateStep.gate`, or its deprecated `signalName` spelling (#2202). */
|
|
136
|
+
gate: string;
|
|
135
137
|
/** Resolved to its effective value — `GateStep.timeout ?? "48h"`. */
|
|
136
138
|
timeout: string;
|
|
137
139
|
description?: string;
|
|
@@ -227,7 +229,7 @@ function irActivityStep(step: ActivityStep, contracts: ReadonlyMap<string, Activ
|
|
|
227
229
|
function irGateStep(step: GateStep): OpIRGateStep {
|
|
228
230
|
return {
|
|
229
231
|
kind: "gate",
|
|
230
|
-
|
|
232
|
+
gate: gateName(step),
|
|
231
233
|
timeout: step.timeout ?? "48h",
|
|
232
234
|
...(step.description ? { description: step.description } : {}),
|
|
233
235
|
};
|
|
@@ -375,7 +377,7 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
375
377
|
if (step.kind === "gate") {
|
|
376
378
|
return {
|
|
377
379
|
kind: "gate",
|
|
378
|
-
|
|
380
|
+
gate: step.gate,
|
|
379
381
|
timeout: step.timeout,
|
|
380
382
|
...(step.description ? { description: step.description } : {}),
|
|
381
383
|
};
|
|
@@ -106,7 +106,7 @@ describe("isGated", () => {
|
|
|
106
106
|
expect(
|
|
107
107
|
isGated(
|
|
108
108
|
cfg([
|
|
109
|
-
{ name: "Approve", steps: [{ kind: "gate",
|
|
109
|
+
{ name: "Approve", steps: [{ kind: "gate", gate: "approve-x" }] },
|
|
110
110
|
{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] },
|
|
111
111
|
]),
|
|
112
112
|
),
|
|
@@ -118,7 +118,7 @@ describe("isGated", () => {
|
|
|
118
118
|
isGated(
|
|
119
119
|
cfg(
|
|
120
120
|
[{ name: "Apply", steps: [{ kind: "activity", fn: "nativeApply", args: {} }] }],
|
|
121
|
-
[{ name: "Rollback", steps: [{ kind: "gate",
|
|
121
|
+
[{ name: "Rollback", steps: [{ kind: "gate", gate: "approve-rollback" }] }],
|
|
122
122
|
),
|
|
123
123
|
),
|
|
124
124
|
).toBe(true);
|
package/src/op/op.test.ts
CHANGED
|
@@ -131,10 +131,10 @@ describe("activity()", () => {
|
|
|
131
131
|
// ── gate() ────────────────────────────────────────────────────────────────────
|
|
132
132
|
|
|
133
133
|
describe("gate()", () => {
|
|
134
|
-
it("returns GateStep with kind 'gate' and
|
|
134
|
+
it("returns GateStep with kind 'gate' and the gate name", () => {
|
|
135
135
|
const g = gate("dns-delegation");
|
|
136
136
|
expect(g.kind).toBe("gate");
|
|
137
|
-
expect(g.
|
|
137
|
+
expect(g.gate).toBe("dns-delegation");
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
it("includes timeout when provided", () => {
|
package/src/op/operator.test.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
runOperatorRound,
|
|
10
10
|
runOperatorForever,
|
|
11
11
|
formatRoundLine,
|
|
12
|
+
formatSignalLine,
|
|
12
13
|
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
14
|
+
type ChangeSubscriber,
|
|
15
|
+
type OperatorSignalEvent,
|
|
16
|
+
type OperatorTickEvent,
|
|
13
17
|
} from "./operator";
|
|
14
18
|
import { readLease } from "../lifecycle/lease";
|
|
15
19
|
import { appendConvergeRecord, readConvergeLedger } from "../lifecycle/converge-ledger";
|
|
@@ -513,3 +517,367 @@ describe("formatRoundLine", () => {
|
|
|
513
517
|
.toContain("not-due:*/10 * * * *");
|
|
514
518
|
});
|
|
515
519
|
});
|
|
520
|
+
|
|
521
|
+
describe("runOperatorForever: a substrate change signal wakes a tick (#1981)", () => {
|
|
522
|
+
async function waitFor(predicate: () => boolean, maxWaitMs = 5_000): Promise<void> {
|
|
523
|
+
const deadline = Date.now() + maxWaitMs;
|
|
524
|
+
while (!predicate() && Date.now() < deadline) {
|
|
525
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A stub `subscribeChanges` seam. Hands the loop's `onChange`/`onError` back
|
|
531
|
+
* to the test so it can fabricate signals and kill the subscription, with no
|
|
532
|
+
* substrate anywhere near it, which is the whole point of the seam being a
|
|
533
|
+
* plain callback pair.
|
|
534
|
+
*/
|
|
535
|
+
function stubSubscriber(lexicon = "stub") {
|
|
536
|
+
const handle = {
|
|
537
|
+
lexicon,
|
|
538
|
+
subscribes: 0,
|
|
539
|
+
closes: 0,
|
|
540
|
+
onChange: undefined as undefined | (() => void),
|
|
541
|
+
onError: undefined as undefined | ((message: string) => void),
|
|
542
|
+
signal: undefined as undefined | AbortSignal,
|
|
543
|
+
failNextSubscribe: undefined as undefined | string,
|
|
544
|
+
};
|
|
545
|
+
const subscriber: ChangeSubscriber = {
|
|
546
|
+
lexicon,
|
|
547
|
+
async subscribe(ctx) {
|
|
548
|
+
handle.subscribes++;
|
|
549
|
+
if (handle.failNextSubscribe) {
|
|
550
|
+
const message = handle.failNextSubscribe;
|
|
551
|
+
handle.failNextSubscribe = undefined;
|
|
552
|
+
throw new Error(message);
|
|
553
|
+
}
|
|
554
|
+
handle.onChange = ctx.onChange;
|
|
555
|
+
handle.onError = ctx.onError;
|
|
556
|
+
handle.signal = ctx.signal;
|
|
557
|
+
return {
|
|
558
|
+
async close() {
|
|
559
|
+
handle.closes++;
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
return { handle, subscriber };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
test("a signal wakes the next round early, well inside the interval", async () => {
|
|
568
|
+
await withTestDir(async (dir) => {
|
|
569
|
+
await initRepo(dir);
|
|
570
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
571
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
572
|
+
const { handle, subscriber } = stubSubscriber("k8s");
|
|
573
|
+
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
const rounds: number[] = [];
|
|
576
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
577
|
+
const loop = runOperatorForever({
|
|
578
|
+
cwd: dir,
|
|
579
|
+
holder: "op-a",
|
|
580
|
+
intervalMs: 60_000, // a full minute: nothing but a signal can produce a second round
|
|
581
|
+
signalFloorMs: 0,
|
|
582
|
+
activities,
|
|
583
|
+
profiles: PROFILES,
|
|
584
|
+
signal: controller.signal,
|
|
585
|
+
subscribers: [subscriber],
|
|
586
|
+
onRound: () => rounds.push(Date.now()),
|
|
587
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await waitFor(() => handle.onChange !== undefined);
|
|
591
|
+
expect(rounds.length).toBe(1);
|
|
592
|
+
handle.onChange!();
|
|
593
|
+
await waitFor(() => rounds.length >= 2);
|
|
594
|
+
expect(rounds.length).toBeGreaterThanOrEqual(2);
|
|
595
|
+
|
|
596
|
+
controller.abort();
|
|
597
|
+
await loop;
|
|
598
|
+
expect(signalEvents.some((e) => e.kind === "subscribed" && e.lexicon === "k8s")).toBe(true);
|
|
599
|
+
expect(signalEvents.some((e) => e.kind === "woken")).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
}, 15_000);
|
|
602
|
+
|
|
603
|
+
test("a fabricated signal cannot change what the tick reports, only when it runs", async () => {
|
|
604
|
+
await withTestDir(async (dir) => {
|
|
605
|
+
await initRepo(dir);
|
|
606
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
607
|
+
|
|
608
|
+
/** One round's events, run to completion on a timer alone. */
|
|
609
|
+
async function roundsWithoutSignal(): Promise<string> {
|
|
610
|
+
const controller = new AbortController();
|
|
611
|
+
const seen: OperatorTickEvent[][] = [];
|
|
612
|
+
const loop = runOperatorForever({
|
|
613
|
+
cwd: dir,
|
|
614
|
+
holder: "op-a",
|
|
615
|
+
intervalMs: 60_000,
|
|
616
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
617
|
+
profiles: PROFILES,
|
|
618
|
+
signal: controller.signal,
|
|
619
|
+
onRound: (events) => {
|
|
620
|
+
seen.push(events);
|
|
621
|
+
controller.abort();
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
await loop;
|
|
625
|
+
return JSON.stringify(seen[0].map((e) => ({ kind: e.kind, op: e.op, env: e.env })));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The same, woken by a signal carrying a fabricated payload. */
|
|
629
|
+
async function roundsWithFabricatedSignal(): Promise<string> {
|
|
630
|
+
const { handle, subscriber } = stubSubscriber("liar");
|
|
631
|
+
const controller = new AbortController();
|
|
632
|
+
const seen: OperatorTickEvent[][] = [];
|
|
633
|
+
const loop = runOperatorForever({
|
|
634
|
+
cwd: dir,
|
|
635
|
+
holder: "op-a",
|
|
636
|
+
intervalMs: 60_000,
|
|
637
|
+
signalFloorMs: 0,
|
|
638
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
639
|
+
profiles: PROFILES,
|
|
640
|
+
signal: controller.signal,
|
|
641
|
+
subscribers: [subscriber],
|
|
642
|
+
onRound: (events) => {
|
|
643
|
+
seen.push(events);
|
|
644
|
+
if (seen.length >= 2) controller.abort();
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
await waitFor(() => handle.onChange !== undefined);
|
|
648
|
+
// `onChange` takes no arguments; a caller that fabricates a resource
|
|
649
|
+
// has nowhere to put it, and the extra arguments go nowhere.
|
|
650
|
+
(handle.onChange as unknown as (...args: unknown[]) => void)(
|
|
651
|
+
{ kind: "Deployment", name: "ghost", status: "Drifted" },
|
|
652
|
+
"fabricated",
|
|
653
|
+
);
|
|
654
|
+
await waitFor(() => seen.length >= 2);
|
|
655
|
+
controller.abort();
|
|
656
|
+
await loop;
|
|
657
|
+
return JSON.stringify(seen[1].map((e) => ({ kind: e.kind, op: e.op, env: e.env })));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const timerOnly = await roundsWithoutSignal();
|
|
661
|
+
const signalled = await roundsWithFabricatedSignal();
|
|
662
|
+
// Byte for byte the same verdict: the signal moved the clock, nothing else.
|
|
663
|
+
expect(signalled).toBe(timerOnly);
|
|
664
|
+
// And the seam itself carries no payload channel to begin with.
|
|
665
|
+
const { handle } = stubSubscriber();
|
|
666
|
+
expect(handle.onChange?.length ?? 0).toBe(0);
|
|
667
|
+
});
|
|
668
|
+
}, 20_000);
|
|
669
|
+
|
|
670
|
+
test("a storm of signals wakes at most one round per floor", async () => {
|
|
671
|
+
await withTestDir(async (dir) => {
|
|
672
|
+
await initRepo(dir);
|
|
673
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
674
|
+
const { handle, subscriber } = stubSubscriber();
|
|
675
|
+
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
const rounds: number[] = [];
|
|
678
|
+
const loop = runOperatorForever({
|
|
679
|
+
cwd: dir,
|
|
680
|
+
holder: "op-a",
|
|
681
|
+
intervalMs: 60_000,
|
|
682
|
+
signalFloorMs: 400,
|
|
683
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
684
|
+
profiles: PROFILES,
|
|
685
|
+
signal: controller.signal,
|
|
686
|
+
subscribers: [subscriber],
|
|
687
|
+
onRound: () => rounds.push(Date.now()),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
await waitFor(() => handle.onChange !== undefined);
|
|
691
|
+
const before = rounds.length;
|
|
692
|
+
for (let i = 0; i < 300; i++) handle.onChange!();
|
|
693
|
+
// Well past a floor, but nowhere near the 60s interval: at most one
|
|
694
|
+
// extra round may have happened, and a per-signal wake would have
|
|
695
|
+
// produced hundreds.
|
|
696
|
+
await waitFor(() => rounds.length > before, 2_000);
|
|
697
|
+
expect(rounds.length - before).toBeLessThanOrEqual(1);
|
|
698
|
+
|
|
699
|
+
controller.abort();
|
|
700
|
+
await loop;
|
|
701
|
+
});
|
|
702
|
+
}, 15_000);
|
|
703
|
+
|
|
704
|
+
test("a killed subscription is reported once, the loop keeps ticking, and the next round re-subscribes", async () => {
|
|
705
|
+
await withTestDir(async (dir) => {
|
|
706
|
+
await initRepo(dir);
|
|
707
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
708
|
+
const { handle, subscriber } = stubSubscriber("k8s");
|
|
709
|
+
|
|
710
|
+
const controller = new AbortController();
|
|
711
|
+
const rounds: number[] = [];
|
|
712
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
713
|
+
const loop = runOperatorForever({
|
|
714
|
+
cwd: dir,
|
|
715
|
+
holder: "op-a",
|
|
716
|
+
intervalMs: 60,
|
|
717
|
+
signalFloorMs: 0,
|
|
718
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
719
|
+
profiles: PROFILES,
|
|
720
|
+
signal: controller.signal,
|
|
721
|
+
subscribers: [subscriber],
|
|
722
|
+
onRound: () => rounds.push(Date.now()),
|
|
723
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
await waitFor(() => handle.onError !== undefined);
|
|
727
|
+
const subscribesBefore = handle.subscribes;
|
|
728
|
+
// The watch dies. Twice, because a dead subscription reports once.
|
|
729
|
+
handle.onError!("connection reset by peer");
|
|
730
|
+
handle.onError!("connection reset by peer");
|
|
731
|
+
|
|
732
|
+
await waitFor(() => handle.subscribes > subscribesBefore);
|
|
733
|
+
const roundsAtDeath = rounds.length;
|
|
734
|
+
await waitFor(() => rounds.length > roundsAtDeath + 1);
|
|
735
|
+
|
|
736
|
+
controller.abort();
|
|
737
|
+
await loop;
|
|
738
|
+
|
|
739
|
+
const lost = signalEvents.filter((e) => e.kind === "subscription-lost");
|
|
740
|
+
expect(lost).toHaveLength(1);
|
|
741
|
+
expect(formatSignalLine(lost[0])).toContain("polling on the timer");
|
|
742
|
+
// The loop kept ticking on the timer, and re-subscribed.
|
|
743
|
+
expect(rounds.length).toBeGreaterThan(roundsAtDeath + 1);
|
|
744
|
+
expect(handle.subscribes).toBeGreaterThan(subscribesBefore);
|
|
745
|
+
});
|
|
746
|
+
}, 15_000);
|
|
747
|
+
|
|
748
|
+
test("a subscription that cannot be established degrades to polling with one line, never a crash", async () => {
|
|
749
|
+
await withTestDir(async (dir) => {
|
|
750
|
+
await initRepo(dir);
|
|
751
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
752
|
+
const failing: ChangeSubscriber = {
|
|
753
|
+
lexicon: "k8s",
|
|
754
|
+
async subscribe() {
|
|
755
|
+
throw new Error("no cluster binding for env staging");
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const controller = new AbortController();
|
|
760
|
+
const rounds: number[] = [];
|
|
761
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
762
|
+
const loop = runOperatorForever({
|
|
763
|
+
cwd: dir,
|
|
764
|
+
holder: "op-a",
|
|
765
|
+
intervalMs: 40,
|
|
766
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
767
|
+
profiles: PROFILES,
|
|
768
|
+
signal: controller.signal,
|
|
769
|
+
subscribers: [failing],
|
|
770
|
+
onRound: () => rounds.push(Date.now()),
|
|
771
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await waitFor(() => rounds.length >= 3);
|
|
775
|
+
controller.abort();
|
|
776
|
+
await loop; // resolves: a failing subscription never rejects the loop
|
|
777
|
+
|
|
778
|
+
const failed = signalEvents.filter((e) => e.kind === "subscribe-failed");
|
|
779
|
+
expect(failed.length).toBeGreaterThanOrEqual(1);
|
|
780
|
+
expect(formatSignalLine(failed[0])).toContain("no cluster binding for env staging");
|
|
781
|
+
expect(rounds.length).toBeGreaterThanOrEqual(3);
|
|
782
|
+
});
|
|
783
|
+
}, 15_000);
|
|
784
|
+
|
|
785
|
+
test("stopping the operator closes every subscription and aborts its signal", async () => {
|
|
786
|
+
await withTestDir(async (dir) => {
|
|
787
|
+
await initRepo(dir);
|
|
788
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
789
|
+
const a = stubSubscriber("k8s");
|
|
790
|
+
const b = stubSubscriber("docker");
|
|
791
|
+
|
|
792
|
+
const controller = new AbortController();
|
|
793
|
+
const loop = runOperatorForever({
|
|
794
|
+
cwd: dir,
|
|
795
|
+
holder: "op-a",
|
|
796
|
+
intervalMs: 60_000,
|
|
797
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
798
|
+
profiles: PROFILES,
|
|
799
|
+
signal: controller.signal,
|
|
800
|
+
subscribers: [a.subscriber, b.subscriber],
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await waitFor(() => a.handle.onChange !== undefined && b.handle.onChange !== undefined);
|
|
804
|
+
expect(a.handle.signal!.aborted).toBe(false);
|
|
805
|
+
|
|
806
|
+
controller.abort();
|
|
807
|
+
await loop;
|
|
808
|
+
|
|
809
|
+
expect(a.handle.closes).toBe(1);
|
|
810
|
+
expect(b.handle.closes).toBe(1);
|
|
811
|
+
expect(a.handle.signal!.aborted).toBe(true);
|
|
812
|
+
expect(b.handle.signal!.aborted).toBe(true);
|
|
813
|
+
});
|
|
814
|
+
}, 15_000);
|
|
815
|
+
|
|
816
|
+
test("a close() that throws still unwinds the loop cleanly", async () => {
|
|
817
|
+
await withTestDir(async (dir) => {
|
|
818
|
+
await initRepo(dir);
|
|
819
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
820
|
+
let subscribed = false;
|
|
821
|
+
const hostile: ChangeSubscriber = {
|
|
822
|
+
lexicon: "k8s",
|
|
823
|
+
async subscribe() {
|
|
824
|
+
subscribed = true;
|
|
825
|
+
return { close: async () => { throw new Error("close blew up"); } };
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
const controller = new AbortController();
|
|
830
|
+
const loop = runOperatorForever({
|
|
831
|
+
cwd: dir,
|
|
832
|
+
holder: "op-a",
|
|
833
|
+
intervalMs: 60_000,
|
|
834
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
835
|
+
profiles: PROFILES,
|
|
836
|
+
signal: controller.signal,
|
|
837
|
+
subscribers: [hostile],
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
await waitFor(() => subscribed);
|
|
841
|
+
controller.abort();
|
|
842
|
+
await expect(loop).resolves.toBeUndefined();
|
|
843
|
+
});
|
|
844
|
+
}, 15_000);
|
|
845
|
+
|
|
846
|
+
test("no subscribers: the loop is exactly the timer it always was", async () => {
|
|
847
|
+
await withTestDir(async (dir) => {
|
|
848
|
+
await initRepo(dir);
|
|
849
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
850
|
+
const controller = new AbortController();
|
|
851
|
+
const rounds: number[] = [];
|
|
852
|
+
const signalEvents: OperatorSignalEvent[] = [];
|
|
853
|
+
const loop = runOperatorForever({
|
|
854
|
+
cwd: dir,
|
|
855
|
+
holder: "op-a",
|
|
856
|
+
intervalMs: 40,
|
|
857
|
+
activities: fakeTickActivities(dir, "staging", "staging-converge"),
|
|
858
|
+
profiles: PROFILES,
|
|
859
|
+
signal: controller.signal,
|
|
860
|
+
subscribers: [], // a project whose lexicons implement no signal seam
|
|
861
|
+
onRound: () => rounds.push(Date.now()),
|
|
862
|
+
onSignalEvent: (e) => signalEvents.push(e),
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
await waitFor(() => rounds.length >= 3);
|
|
866
|
+
controller.abort();
|
|
867
|
+
await loop;
|
|
868
|
+
expect(rounds.length).toBeGreaterThanOrEqual(3);
|
|
869
|
+
expect(signalEvents).toEqual([]);
|
|
870
|
+
});
|
|
871
|
+
}, 15_000);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
describe("formatSignalLine", () => {
|
|
875
|
+
test("renders each signal event kind as one line, none of them an observation", () => {
|
|
876
|
+
expect(formatSignalLine({ kind: "subscribed", lexicon: "k8s" })).toContain("the timer still runs");
|
|
877
|
+
expect(formatSignalLine({ kind: "subscribe-failed", lexicon: "k8s", error: "no binding" }))
|
|
878
|
+
.toContain("polling on the timer");
|
|
879
|
+
expect(formatSignalLine({ kind: "subscription-lost", lexicon: "k8s", error: "410 Gone" }))
|
|
880
|
+
.toContain("re-subscribes");
|
|
881
|
+
expect(formatSignalLine({ kind: "woken", afterMs: 120 })).toContain("running an ordinary round");
|
|
882
|
+
});
|
|
883
|
+
});
|
package/src/op/operator.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
|
|
|
41
41
|
import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
|
|
42
42
|
import { StaleLockError } from "../lifecycle/git";
|
|
43
43
|
import { cronMatches, cronDueBetween } from "./cron";
|
|
44
|
+
import { createChangeSignalGate, DEFAULT_SIGNAL_FLOOR_MS, type WakeReason } from "./change-signal";
|
|
45
|
+
import type { ChangeSubscription } from "../lexicon";
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Poll interval between rounds — the operator's own cadence, and the fallback
|
|
@@ -211,11 +213,67 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
211
213
|
});
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
/**
|
|
217
|
+
* One lexicon's change-signal seam, already bound to an environment (#1981).
|
|
218
|
+
*
|
|
219
|
+
* The loop never sees a `LexiconPlugin`: core's operator does not import
|
|
220
|
+
* plugins, and binding happens where plugins are already loaded
|
|
221
|
+
* (`collectChangeSubscribers` in `../cli/plugins.ts`), the same extract-then-
|
|
222
|
+
* thread shape `collectBuildRootContributors` uses for build roots.
|
|
223
|
+
*/
|
|
224
|
+
export interface ChangeSubscriber {
|
|
225
|
+
/** Which lexicon supplies the signal. Log lines only; never a fact. */
|
|
226
|
+
lexicon: string;
|
|
227
|
+
subscribe(ctx: {
|
|
228
|
+
onChange: () => void;
|
|
229
|
+
onError: (message: string) => void;
|
|
230
|
+
signal: AbortSignal;
|
|
231
|
+
}): Promise<ChangeSubscription>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What the loop's change-signal machinery reports, alongside the per-op tick
|
|
236
|
+
* events. Separate from {@link OperatorTickEvent} on purpose: none of this is
|
|
237
|
+
* about an Op, and none of it is an observation.
|
|
238
|
+
*/
|
|
239
|
+
export type OperatorSignalEvent =
|
|
240
|
+
/** A lexicon's subscription is live and the loop may now wake early. */
|
|
241
|
+
| { kind: "subscribed"; lexicon: string }
|
|
242
|
+
/** Establishing it failed. The loop keeps its timer and retries next round. */
|
|
243
|
+
| { kind: "subscribe-failed"; lexicon: string; error: string }
|
|
244
|
+
/** A live subscription died. Reported once; the next round re-subscribes. */
|
|
245
|
+
| { kind: "subscription-lost"; lexicon: string; error: string }
|
|
246
|
+
/** A signal shortened the sleep. The round that follows is an ordinary round. */
|
|
247
|
+
| { kind: "woken"; afterMs: number };
|
|
248
|
+
|
|
249
|
+
/** Render one signal event as `chant operator`'s log line shape. */
|
|
250
|
+
export function formatSignalLine(event: OperatorSignalEvent): string {
|
|
251
|
+
switch (event.kind) {
|
|
252
|
+
case "subscribed":
|
|
253
|
+
return `operator: ${event.lexicon} change signal subscribed (the timer still runs)`;
|
|
254
|
+
case "subscribe-failed":
|
|
255
|
+
return `operator: ${event.lexicon} change signal unavailable, polling on the timer (${event.error})`;
|
|
256
|
+
case "subscription-lost":
|
|
257
|
+
return `operator: ${event.lexicon} change signal lost, polling on the timer until the next round re-subscribes (${event.error})`;
|
|
258
|
+
case "woken":
|
|
259
|
+
return `operator: woken by a change signal after ${event.afterMs}ms, running an ordinary round`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
214
263
|
export interface OperatorLoopOptions extends OperatorRoundOptions {
|
|
215
264
|
/** @default DEFAULT_OPERATOR_INTERVAL_MS */
|
|
216
265
|
intervalMs?: number;
|
|
217
266
|
/** Called after every round completes — the CLI's one-line-per-tick log lives here, not inside the loop itself, so a test can drive rounds without capturing stdout. */
|
|
218
267
|
onRound?: (events: OperatorTickEvent[]) => void;
|
|
268
|
+
/**
|
|
269
|
+
* Change-signal seams to keep subscribed for the life of the loop (#1981).
|
|
270
|
+
* Omitted, or empty, and the loop is exactly what it was: a timer.
|
|
271
|
+
*/
|
|
272
|
+
subscribers?: readonly ChangeSubscriber[];
|
|
273
|
+
/** @default DEFAULT_SIGNAL_FLOOR_MS. The shortest gap a signal may force between two rounds. */
|
|
274
|
+
signalFloorMs?: number;
|
|
275
|
+
/** Called for every subscription and wake event. The CLI logs one line each. */
|
|
276
|
+
onSignalEvent?: (event: OperatorSignalEvent) => void;
|
|
219
277
|
}
|
|
220
278
|
|
|
221
279
|
/**
|
|
@@ -224,6 +282,12 @@ export interface OperatorLoopOptions extends OperatorRoundOptions {
|
|
|
224
282
|
* (issue: "if it dies, nothing breaks and nothing is lost") — this function
|
|
225
283
|
* is exactly `while (!aborted) { round(); sleep(); }`, nothing durable lives
|
|
226
284
|
* in its own memory that a restart would need to recover.
|
|
285
|
+
*
|
|
286
|
+
* With `subscribers` (#1981) the sleep gains a second wake source, and only a
|
|
287
|
+
* wake source. A signal aborts the current sleep; the round that follows is
|
|
288
|
+
* the same round the timer would have run, deriving everything from a fresh
|
|
289
|
+
* observation. Nothing a subscription reports reaches a tick, because the
|
|
290
|
+
* seam has nowhere to put it: `onChange` takes no arguments.
|
|
227
291
|
*/
|
|
228
292
|
export async function runOperatorForever(opts: OperatorLoopOptions): Promise<void> {
|
|
229
293
|
const holder = opts.holder ?? currentHolderId();
|
|
@@ -232,11 +296,83 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
232
296
|
// lands on a firing minute — a fresh operator owes no catch-up for the ticks
|
|
233
297
|
// it was not running for.
|
|
234
298
|
const scheduleState = opts.scheduleState ?? new Map<string, Date>();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
299
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
|
|
300
|
+
const subscribers = opts.subscribers ?? [];
|
|
301
|
+
|
|
302
|
+
// Nothing to subscribe to: the loop below is byte for byte the loop that
|
|
303
|
+
// shipped before #1981, and takes none of the machinery's cost.
|
|
304
|
+
if (subscribers.length === 0) {
|
|
305
|
+
while (!opts.signal?.aborted) {
|
|
306
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState });
|
|
307
|
+
opts.onRound?.(events);
|
|
308
|
+
if (opts.signal?.aborted) break;
|
|
309
|
+
await sleepAbortable(intervalMs, opts.signal);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const gate = createChangeSignalGate({ floorMs: opts.signalFloorMs ?? DEFAULT_SIGNAL_FLOOR_MS });
|
|
315
|
+
// The subscriptions the loop currently holds, by lexicon. A lexicon absent
|
|
316
|
+
// from this map is one to (re-)subscribe on the next round, which is how a
|
|
317
|
+
// killed watch comes back without a special retry path.
|
|
318
|
+
const live = new Map<string, ChangeSubscription>();
|
|
319
|
+
// Stops every subscription at once when the loop ends, whatever ends it.
|
|
320
|
+
const subscriptionsAbort = new AbortController();
|
|
321
|
+
|
|
322
|
+
const closeAll = async (): Promise<void> => {
|
|
323
|
+
subscriptionsAbort.abort();
|
|
324
|
+
const open = [...live.values()];
|
|
325
|
+
live.clear();
|
|
326
|
+
// A close that throws must not mask why the loop is unwinding.
|
|
327
|
+
await Promise.allSettled(open.map((s) => s.close()));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const ensureSubscribed = async (): Promise<void> => {
|
|
331
|
+
for (const subscriber of subscribers) {
|
|
332
|
+
if (live.has(subscriber.lexicon) || subscriptionsAbort.signal.aborted) continue;
|
|
333
|
+
try {
|
|
334
|
+
const subscription = await subscriber.subscribe({
|
|
335
|
+
onChange: () => gate.signal(),
|
|
336
|
+
onError: (error) => {
|
|
337
|
+
// Reported once, then forgotten: dropping it from `live` is what
|
|
338
|
+
// makes the next round re-subscribe. The loop itself is untouched.
|
|
339
|
+
if (!live.delete(subscriber.lexicon)) return;
|
|
340
|
+
opts.onSignalEvent?.({ kind: "subscription-lost", lexicon: subscriber.lexicon, error });
|
|
341
|
+
},
|
|
342
|
+
signal: subscriptionsAbort.signal,
|
|
343
|
+
});
|
|
344
|
+
live.set(subscriber.lexicon, subscription);
|
|
345
|
+
opts.onSignalEvent?.({ kind: "subscribed", lexicon: subscriber.lexicon });
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// A subscription that cannot be established is a slower loop, never a
|
|
348
|
+
// stopped one: report it and fall through to the timer.
|
|
349
|
+
opts.onSignalEvent?.({
|
|
350
|
+
kind: "subscribe-failed",
|
|
351
|
+
lexicon: subscriber.lexicon,
|
|
352
|
+
error: err instanceof Error ? err.message : String(err),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
while (!opts.signal?.aborted) {
|
|
360
|
+
gate.roundStarted();
|
|
361
|
+
const startedAt = Date.now();
|
|
362
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState });
|
|
363
|
+
opts.onRound?.(events);
|
|
364
|
+
if (opts.signal?.aborted) break;
|
|
365
|
+
|
|
366
|
+
await ensureSubscribed();
|
|
367
|
+
if (opts.signal?.aborted) break;
|
|
368
|
+
|
|
369
|
+
const reason: WakeReason = await gate.wait(intervalMs, opts.signal);
|
|
370
|
+
if (reason === "signal") {
|
|
371
|
+
opts.onSignalEvent?.({ kind: "woken", afterMs: Date.now() - startedAt });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
await closeAll();
|
|
240
376
|
}
|
|
241
377
|
}
|
|
242
378
|
|