@intentius/chant 0.94.0 → 0.95.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/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +14 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/operator.d.ts +90 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/steward-beside.d.ts +84 -0
- package/dist/op/steward-beside.d.ts.map +1 -0
- package/dist/op/steward.d.ts +87 -2
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +11 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +5 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +37 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +57 -1
- package/dist/workspace/graph-cache.d.ts +168 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -5
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/kind-readers.d.ts +39 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +29 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +15 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-run.d.ts +2 -0
- package/dist/workspace/member-run.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +10 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +4 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +23 -7
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +12 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +26 -0
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +7 -3
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
- package/src/cli/handlers/operator.ts +53 -11
- package/src/cli/handlers/run.test.ts +71 -0
- package/src/cli/handlers/run.ts +65 -7
- package/src/cli/main.ts +17 -6
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/static-config-read.test.ts +8 -2
- package/src/meta/source-is-text.test.ts +21 -3
- package/src/okf.test.ts +6 -1
- package/src/op/builders.ts +14 -3
- package/src/op/index.ts +8 -2
- package/src/op/operator.ts +264 -16
- package/src/op/steward-beside.test.ts +267 -0
- package/src/op/steward-beside.ts +219 -0
- package/src/op/steward-points.test.ts +61 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/checks/boxes.test.ts +1 -0
- package/src/workspace/compose-graph.test.ts +1 -0
- package/src/workspace/compose-graph.ts +11 -0
- package/src/workspace/composites.test.ts +1 -1
- package/src/workspace/composites.ts +12 -5
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
- package/src/workspace/declaration.schema.json +57 -1
- package/src/workspace/declaration.ts +104 -0
- package/src/workspace/graph-cache.test.ts +343 -0
- package/src/workspace/graph-cache.ts +409 -0
- package/src/workspace/graph-cli.ts +93 -26
- package/src/workspace/graph-contract.test.ts +129 -6
- package/src/workspace/graph.schema.json +21 -1
- package/src/workspace/kind-readers.e2e.test.ts +68 -0
- package/src/workspace/kind-readers.test.ts +134 -0
- package/src/workspace/kind-readers.ts +111 -0
- package/src/workspace/kinds.test.ts +49 -0
- package/src/workspace/kinds.ts +59 -2
- package/src/workspace/member-commands.test.ts +15 -0
- package/src/workspace/member-commands.ts +47 -7
- package/src/workspace/member-run.ts +10 -2
- package/src/workspace/reason-codes.ts +3 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +20 -12
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-new.schema.json +1 -0
- package/src/workspace/records-quorum.test.ts +10 -3
- package/src/workspace/records-sessions-write.test.ts +2 -1
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +31 -1
- package/src/workspace/status-stewards.ts +39 -6
- package/src/workspace/status.schema.json +49 -4
- package/src/workspace/status.ts +22 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- package/src/workspace/workspace-kinds.schema.json +26 -0
package/src/op/operator.ts
CHANGED
|
@@ -38,12 +38,13 @@
|
|
|
38
38
|
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
39
39
|
import { discoverOps, type DiscoveredOp } from "./discover";
|
|
40
40
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "./local-executor";
|
|
41
|
-
import { acquireLease, releaseLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
|
|
41
|
+
import { acquireLease, releaseLease, stillHoldsLease, currentHolderId, readLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
|
|
42
42
|
import { randomUUID } from "node:crypto";
|
|
43
43
|
import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
|
|
44
44
|
import { readGateLedger, latestResolutionSince } from "../lifecycle/gate-ledger";
|
|
45
45
|
import { enterStewardTurn } from "./steward-turn";
|
|
46
|
-
import { stewardLeaseName, stewardTurnLeaseName, type StewardDeclaration } from "./steward";
|
|
46
|
+
import { stewardBesideOf, stewardLeaseName, stewardTurnLeaseName, stewardTurnOps, type StewardDeclaration } from "./steward";
|
|
47
|
+
import { askReady, spawnBesideRun, type BesideHandle, type BesideLauncher, type BesideWhy } from "./steward-beside";
|
|
47
48
|
import { stewardWorkHolder } from "./work-lease-run";
|
|
48
49
|
import { StaleLockError } from "../lifecycle/git";
|
|
49
50
|
import { cronMatches, cronDueBetween } from "./cron";
|
|
@@ -146,7 +147,78 @@ export type OperatorTickEvent =
|
|
|
146
147
|
* lease for the whole steward, not one per op). The next round tries
|
|
147
148
|
* again.
|
|
148
149
|
*/
|
|
149
|
-
| { kind: "turn-busy"; op: string; env: string; steward: string; heldBy?: string }
|
|
150
|
+
| { kind: "turn-busy"; op: string; env: string; steward: string; heldBy?: string }
|
|
151
|
+
/**
|
|
152
|
+
* A steward's Op beside its turns (#2861) was started in a process of its
|
|
153
|
+
* own, and the round went on without waiting for it. `why` is its cron, its
|
|
154
|
+
* ready step (`keys` are the work keys it named), or a waiting run to
|
|
155
|
+
* resume (`resumed`, the question now answered) or a gated one (`approved`).
|
|
156
|
+
* `holder` is the Op's lease holder and its work lease holder.
|
|
157
|
+
*/
|
|
158
|
+
| {
|
|
159
|
+
kind: "beside-started";
|
|
160
|
+
op: string;
|
|
161
|
+
env: string;
|
|
162
|
+
why: BesideWhy;
|
|
163
|
+
holder: string;
|
|
164
|
+
keys?: string[];
|
|
165
|
+
resumed?: string;
|
|
166
|
+
approved?: { op: string; gate: string };
|
|
167
|
+
}
|
|
168
|
+
/** A run of an Op beside the turns is in flight (#2861): this operator's, or one whose lease another holder has, such as a hand `chant run`. Nothing was started. */
|
|
169
|
+
| { kind: "beside-running"; op: string; env: string; heldBy?: string }
|
|
170
|
+
/** A run this operator started beside the turns ended (#2861), reported on the round after. `code` is `chant run`'s exit code: 0 ok, 3 gated or waiting, 1 failed. */
|
|
171
|
+
| { kind: "beside-ended"; op: string; env: string; code: number | null; error?: string }
|
|
172
|
+
/**
|
|
173
|
+
* An Op beside the turns whose ready step names no work (#2861), or only
|
|
174
|
+
* work this operator already started a run for (`already`).
|
|
175
|
+
*/
|
|
176
|
+
| { kind: "skipped-not-ready"; op: string; env: string; already?: string[] }
|
|
177
|
+
/** An Op beside the turns whose ready step failed, or answered in a shape it can't read (#2861). Nothing was started. */
|
|
178
|
+
| { kind: "ready-failed"; op: string; env: string; error: string };
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What a local steward's operator keeps about the runs it starts beside its
|
|
182
|
+
* turns (#2861), across rounds: the ones in flight, the work keys a ready
|
|
183
|
+
* step named that it already started a run for, and the runs that ended since
|
|
184
|
+
* the last round, which the next round reports. `runOperatorForever` owns one
|
|
185
|
+
* for its life; a restarted operator starts empty and finds a run still in
|
|
186
|
+
* flight by its lease.
|
|
187
|
+
*/
|
|
188
|
+
export interface BesideState {
|
|
189
|
+
running: Map<string, { handle: BesideHandle; holder: string; why: BesideWhy }>;
|
|
190
|
+
started: Map<string, Set<string>>;
|
|
191
|
+
ended: OperatorTickEvent[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A fresh {@link BesideState}. */
|
|
195
|
+
export function createBesideState(): BesideState {
|
|
196
|
+
return { running: new Map(), started: new Map(), ended: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Wait for every run in flight beside the turns to end. `chant operator --steward --once` does before it exits. */
|
|
200
|
+
export async function waitForBesideRuns(state: BesideState): Promise<void> {
|
|
201
|
+
await Promise.all([...state.running.values()].map((r) => r.handle.done));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Ask every run in flight beside the turns to stop, and wait up to `graceMs`
|
|
206
|
+
* for them to end. The operator does when it stops, so no run it started
|
|
207
|
+
* outlives it holding the Op's lease past its renewals.
|
|
208
|
+
*/
|
|
209
|
+
export async function stopBesideRuns(state: BesideState, graceMs = 10_000): Promise<void> {
|
|
210
|
+
const runs = [...state.running.values()];
|
|
211
|
+
for (const r of runs) r.handle.stop();
|
|
212
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
await Promise.race([
|
|
214
|
+
Promise.all(runs.map((r) => r.handle.done)),
|
|
215
|
+
new Promise<void>((resolve) => {
|
|
216
|
+
timer = setTimeout(resolve, graceMs);
|
|
217
|
+
timer.unref?.();
|
|
218
|
+
}),
|
|
219
|
+
]);
|
|
220
|
+
if (timer) clearTimeout(timer);
|
|
221
|
+
}
|
|
150
222
|
|
|
151
223
|
export interface OperatorRoundOptions {
|
|
152
224
|
cwd?: string;
|
|
@@ -185,6 +257,16 @@ export interface OperatorRoundOptions {
|
|
|
185
257
|
* read. Defaults to the workspace's `points` read; a test injects one.
|
|
186
258
|
*/
|
|
187
259
|
readQuestions?: (cwd: string) => Promise<Map<string, string> | null>;
|
|
260
|
+
/**
|
|
261
|
+
* The runs a steward's operator started beside its turns (#2861), kept
|
|
262
|
+
* across rounds. A round given none keeps its own, which it forgets when
|
|
263
|
+
* it returns.
|
|
264
|
+
*/
|
|
265
|
+
besideState?: BesideState;
|
|
266
|
+
/** How a run beside the turns is started. Default: `chant run <op>` in a process of its own ({@link spawnBesideRun}). */
|
|
267
|
+
launchBeside?: BesideLauncher;
|
|
268
|
+
/** The environment the steward operator was started for (`--env`), passed on to a run it starts beside its turns. */
|
|
269
|
+
stewardEnv?: string;
|
|
188
270
|
}
|
|
189
271
|
|
|
190
272
|
/** A steward's Op whose last run waits on a decision point (#2749). */
|
|
@@ -336,12 +418,12 @@ async function releaseStewardTurn(steward: string, holder: string, token: string
|
|
|
336
418
|
}
|
|
337
419
|
|
|
338
420
|
/**
|
|
339
|
-
* The Ops one round
|
|
340
|
-
* steward's Op with no schedule is considered
|
|
341
|
-
* steward's that waits on a decision point.
|
|
421
|
+
* The Ops one round runs as turns: a steward's Ops but those beside its turns
|
|
422
|
+
* (#2861), or every ConvergeOp. A steward's Op with no schedule is considered
|
|
423
|
+
* only to resume a run of the steward's that waits on a decision point.
|
|
342
424
|
*/
|
|
343
425
|
async function roundOps(opts: OperatorRoundOptions): Promise<DiscoveredOp["config"][]> {
|
|
344
|
-
if (opts.steward) return
|
|
426
|
+
if (opts.steward) return stewardTurnOps(opts.steward);
|
|
345
427
|
const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
|
|
346
428
|
return ops.map((d) => d.config);
|
|
347
429
|
}
|
|
@@ -388,6 +470,10 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
388
470
|
|
|
389
471
|
if (!(await holdSteward())) return events;
|
|
390
472
|
|
|
473
|
+
// The runs started beside the turns that ended since the last round (#2861).
|
|
474
|
+
const besideState = opts.besideState ?? createBesideState();
|
|
475
|
+
events.push(...besideState.ended.splice(0));
|
|
476
|
+
|
|
391
477
|
// A steward's Ops whose last run waits on a decision point (#2749), read
|
|
392
478
|
// once per round, and the state of those questions now.
|
|
393
479
|
const waiting = steward ? await stewardWaits(steward, opts) : new Map<string, WaitState>();
|
|
@@ -395,6 +481,7 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
395
481
|
// been approved since.
|
|
396
482
|
const gated = steward ? await stewardGates(steward, opts) : new Map<string, GateWaitState>();
|
|
397
483
|
|
|
484
|
+
let stewardLost = false;
|
|
398
485
|
for (const config of await roundOps(opts)) {
|
|
399
486
|
const env = steward ? runEnvOf(config) : (envOf(config) ?? "unknown");
|
|
400
487
|
const wait = waiting.get(config.name);
|
|
@@ -548,12 +635,152 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
|
|
|
548
635
|
restoreTurn?.();
|
|
549
636
|
if (steward && turn?.acquired) await releaseStewardTurn(steward.name, holder, turn.lease!.token, { cwd: opts.cwd });
|
|
550
637
|
}
|
|
551
|
-
if (!(await holdSteward()))
|
|
638
|
+
if (!(await holdSteward())) {
|
|
639
|
+
stewardLost = true;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// The Ops beside the turns (#2861) are considered whatever became of the
|
|
645
|
+
// turns: a turn in progress holds none of them. Only a steward that lost its
|
|
646
|
+
// own lease starts nothing.
|
|
647
|
+
if (steward && !stewardLost) {
|
|
648
|
+
await besideRound(steward, opts, holder, besideState, waiting, gated, events);
|
|
552
649
|
}
|
|
553
650
|
|
|
554
651
|
return events;
|
|
555
652
|
}
|
|
556
653
|
|
|
654
|
+
/**
|
|
655
|
+
* One round for a steward's Ops beside its turns (#2861). For each one not
|
|
656
|
+
* already running: start it when a run of the steward's waits on a question
|
|
657
|
+
* now answered or a gate now approved, when its cron fires, or when its ready
|
|
658
|
+
* step names work it has not started a run for. The run is started and not
|
|
659
|
+
* waited for.
|
|
660
|
+
*/
|
|
661
|
+
async function besideRound(
|
|
662
|
+
steward: StewardDeclaration,
|
|
663
|
+
opts: OperatorRoundOptions,
|
|
664
|
+
holder: string,
|
|
665
|
+
state: BesideState,
|
|
666
|
+
waiting: Map<string, WaitState>,
|
|
667
|
+
gated: Map<string, GateWaitState>,
|
|
668
|
+
events: OperatorTickEvent[],
|
|
669
|
+
): Promise<void> {
|
|
670
|
+
for (const beside of stewardBesideOf(steward)) {
|
|
671
|
+
const config = steward.ops.find((op) => op.name === beside.op);
|
|
672
|
+
if (!config) continue;
|
|
673
|
+
const env = runEnvOf(config);
|
|
674
|
+
|
|
675
|
+
// The cron is read whether or not a run is in flight, so a fire during a
|
|
676
|
+
// run is dropped (overlap "skip") rather than owed afterwards.
|
|
677
|
+
const cron = config.schedule?.cron;
|
|
678
|
+
let due = false;
|
|
679
|
+
if (cron) {
|
|
680
|
+
const now = (opts.now ?? (() => new Date()))();
|
|
681
|
+
const lastSeen = opts.scheduleState?.get(config.name);
|
|
682
|
+
due = lastSeen === undefined ? cronMatches(cron, now) : cronDueBetween(cron, lastSeen, now);
|
|
683
|
+
opts.scheduleState?.set(config.name, now);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const mine = state.running.get(config.name);
|
|
687
|
+
if (mine) {
|
|
688
|
+
events.push({ kind: "beside-running", op: config.name, env, heldBy: mine.holder });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
// A run another process holds the lease for: a hand `chant run`, or one
|
|
692
|
+
// this steward started before its operator restarted.
|
|
693
|
+
let current: LeaseRecord | undefined;
|
|
694
|
+
try {
|
|
695
|
+
current = (await readLease(config.name, { cwd: opts.cwd })).record;
|
|
696
|
+
} catch (err) {
|
|
697
|
+
events.push({ kind: "lease-error", op: config.name, env, error: err instanceof Error ? err.message : String(err) });
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const now = (opts.now ?? (() => new Date()))();
|
|
701
|
+
if (current && new Date(current.expiresAt).getTime() > now.getTime()) {
|
|
702
|
+
events.push({ kind: "beside-running", op: config.name, env, heldBy: current.holder });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const wait = waiting.get(config.name);
|
|
707
|
+
const stopped = gated.get(config.name);
|
|
708
|
+
const standing: OperatorTickEvent | undefined = wait
|
|
709
|
+
? { kind: "waiting-on-point", op: config.name, env, point: wait.point, question: wait.question, state: wait.state }
|
|
710
|
+
: stopped
|
|
711
|
+
? { kind: "waiting-on-gate", op: config.name, env, gateOp: stopped.gateOp, gate: stopped.gate }
|
|
712
|
+
: undefined;
|
|
713
|
+
|
|
714
|
+
let why: BesideWhy | undefined;
|
|
715
|
+
if (wait?.answered) why = "resumed";
|
|
716
|
+
else if (stopped?.approved) why = "approved";
|
|
717
|
+
else if (due) why = "cron";
|
|
718
|
+
|
|
719
|
+
let keys: string[] | undefined;
|
|
720
|
+
if (!why && beside.ready) {
|
|
721
|
+
const answer = await askReady(beside.ready, opts.activities, opts.signal);
|
|
722
|
+
if ("error" in answer) {
|
|
723
|
+
events.push({ kind: "ready-failed", op: config.name, env, error: answer.error });
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (answer.ready) {
|
|
727
|
+
const seen = state.started.get(config.name) ?? new Set<string>();
|
|
728
|
+
if (answer.keys.length === 0 || answer.keys.some((k) => !seen.has(k))) {
|
|
729
|
+
why = "ready";
|
|
730
|
+
keys = answer.keys;
|
|
731
|
+
} else {
|
|
732
|
+
events.push(standing ?? { kind: "skipped-not-ready", op: config.name, env, already: answer.keys });
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
events.push(standing ?? { kind: "skipped-not-ready", op: config.name, env });
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (!why) {
|
|
741
|
+
const idle: OperatorTickEvent | undefined = standing ?? (cron ? { kind: "skipped-not-due", op: config.name, env, cron } : undefined);
|
|
742
|
+
if (idle) events.push(idle);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const runHolder = stewardWorkHolder(steward.name, config.name, holder);
|
|
747
|
+
let handle: BesideHandle;
|
|
748
|
+
try {
|
|
749
|
+
handle = (opts.launchBeside ?? spawnBesideRun)({
|
|
750
|
+
op: config,
|
|
751
|
+
steward: steward.name,
|
|
752
|
+
...(opts.stewardEnv !== undefined ? { env: opts.stewardEnv } : {}),
|
|
753
|
+
holder: runHolder,
|
|
754
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
755
|
+
...(opts.leaseTtlMs !== undefined ? { leaseTtlMs: opts.leaseTtlMs } : {}),
|
|
756
|
+
});
|
|
757
|
+
} catch (err) {
|
|
758
|
+
events.push({ kind: "tick-failed", op: config.name, env, error: err instanceof Error ? err.message : String(err) });
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (keys) {
|
|
762
|
+
const seen = state.started.get(config.name) ?? new Set<string>();
|
|
763
|
+
for (const k of keys) seen.add(k);
|
|
764
|
+
state.started.set(config.name, seen);
|
|
765
|
+
}
|
|
766
|
+
state.running.set(config.name, { handle, holder: runHolder, why });
|
|
767
|
+
void handle.done.then((exit) => {
|
|
768
|
+
state.running.delete(config.name);
|
|
769
|
+
state.ended.push({ kind: "beside-ended", op: config.name, env, code: exit.code, ...(exit.error ? { error: exit.error } : {}) });
|
|
770
|
+
});
|
|
771
|
+
events.push({
|
|
772
|
+
kind: "beside-started",
|
|
773
|
+
op: config.name,
|
|
774
|
+
env,
|
|
775
|
+
why,
|
|
776
|
+
holder: runHolder,
|
|
777
|
+
...(keys && keys.length > 0 ? { keys } : {}),
|
|
778
|
+
...(why === "resumed" && wait ? { resumed: wait.question } : {}),
|
|
779
|
+
...(why === "approved" && stopped ? { approved: { op: stopped.gateOp, gate: stopped.gate } } : {}),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
557
784
|
/**
|
|
558
785
|
* What a failed tick says about itself (#2301).
|
|
559
786
|
*
|
|
@@ -673,17 +900,24 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
673
900
|
// lands on a firing minute — a fresh operator owes no catch-up for the ticks
|
|
674
901
|
// it was not running for.
|
|
675
902
|
const scheduleState = opts.scheduleState ?? new Map<string, Date>();
|
|
903
|
+
// The runs a steward starts beside its turns (#2861), for the life of the
|
|
904
|
+
// loop. When the loop stops, so do they.
|
|
905
|
+
const besideState = opts.besideState ?? createBesideState();
|
|
676
906
|
const intervalMs = opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS;
|
|
677
907
|
const subscribers = opts.subscribers ?? [];
|
|
678
908
|
|
|
679
|
-
// Nothing to subscribe to: the loop below is
|
|
680
|
-
//
|
|
909
|
+
// Nothing to subscribe to: the loop below is the loop that shipped before
|
|
910
|
+
// #1981, and takes none of the machinery's cost.
|
|
681
911
|
if (subscribers.length === 0) {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
912
|
+
try {
|
|
913
|
+
while (!opts.signal?.aborted) {
|
|
914
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
|
|
915
|
+
opts.onRound?.(events);
|
|
916
|
+
if (opts.signal?.aborted) break;
|
|
917
|
+
await sleepAbortable(intervalMs, opts.signal);
|
|
918
|
+
}
|
|
919
|
+
} finally {
|
|
920
|
+
await stopBesideRuns(besideState);
|
|
687
921
|
}
|
|
688
922
|
return;
|
|
689
923
|
}
|
|
@@ -736,7 +970,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
736
970
|
while (!opts.signal?.aborted) {
|
|
737
971
|
gate.roundStarted();
|
|
738
972
|
const startedAt = Date.now();
|
|
739
|
-
const events = await runOperatorRound({ ...opts, holder, scheduleState });
|
|
973
|
+
const events = await runOperatorRound({ ...opts, holder, scheduleState, besideState });
|
|
740
974
|
opts.onRound?.(events);
|
|
741
975
|
if (opts.signal?.aborted) break;
|
|
742
976
|
|
|
@@ -750,6 +984,7 @@ export async function runOperatorForever(opts: OperatorLoopOptions): Promise<voi
|
|
|
750
984
|
}
|
|
751
985
|
} finally {
|
|
752
986
|
await closeAll();
|
|
987
|
+
await stopBesideRuns(besideState);
|
|
753
988
|
}
|
|
754
989
|
}
|
|
755
990
|
|
|
@@ -780,5 +1015,18 @@ export function formatRoundLine(event: OperatorTickEvent): string {
|
|
|
780
1015
|
return `operator: steward ${event.steward} skipped=1(steward-lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
|
|
781
1016
|
case "turn-busy":
|
|
782
1017
|
return `operator: ${event.op}@${event.env} skipped=1(turn-held${event.heldBy ? `:${event.heldBy}` : ""}, steward "${event.steward}" is already mid-turn)`;
|
|
1018
|
+
case "beside-started":
|
|
1019
|
+
return `operator: ${event.op}@${event.env} started=1(beside:${event.why}) holder="${event.holder}"` +
|
|
1020
|
+
(event.keys ? ` keys="${event.keys.join(",")}"` : "") +
|
|
1021
|
+
(event.resumed ? ` resumed="${event.resumed}"` : "") +
|
|
1022
|
+
(event.approved ? ` approved="${event.approved.op}/${event.approved.gate}"` : "");
|
|
1023
|
+
case "beside-running":
|
|
1024
|
+
return `operator: ${event.op}@${event.env} skipped=1(beside-running${event.heldBy ? `:${event.heldBy}` : ""})`;
|
|
1025
|
+
case "beside-ended":
|
|
1026
|
+
return `operator: ${event.op}@${event.env} ended=1(beside) exit=${event.code ?? "none"}` + (event.error ? ` error="${event.error}"` : "");
|
|
1027
|
+
case "skipped-not-ready":
|
|
1028
|
+
return `operator: ${event.op}@${event.env} skipped=1(not-ready${event.already ? `:already-started:${event.already.join(",")}` : ""})`;
|
|
1029
|
+
case "ready-failed":
|
|
1030
|
+
return `operator: ${event.op}@${event.env} failed=1(ready) error="${event.error}"`;
|
|
783
1031
|
}
|
|
784
1032
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Op a steward runs beside its turns (#2861): the declaration, a long run
|
|
3
|
+
* that holds none of the steward's turns up, a hand run that takes the Op's
|
|
4
|
+
* lease and not the turn, and the operator stopping the runs it started.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, vi } from "vitest";
|
|
7
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
12
|
+
import type { ActivityStep, OpConfig } from "./types";
|
|
13
|
+
import { declareSteward, readinessKeys, stewardBesideFor, stewardTurnOps, stewardTurnLeaseName } from "./steward";
|
|
14
|
+
import { createBesideState, formatRoundLine, runOperatorForever, runOperatorRound, waitForBesideRuns, type OperatorTickEvent } from "./operator";
|
|
15
|
+
import { askReady, holdBesideLease, inProcessBesideLauncher, type BesideLauncher } from "./steward-beside";
|
|
16
|
+
import { stepOutput } from "./step-output-ref";
|
|
17
|
+
import { readLease } from "../lifecycle/lease";
|
|
18
|
+
import { readRunLedger } from "../lifecycle/run-ledger";
|
|
19
|
+
|
|
20
|
+
const PROFILES: Record<string, ActivityProfile> = {};
|
|
21
|
+
|
|
22
|
+
function op(name: string, cron?: string, fn = "tick"): OpConfig {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
overview: `${name} fixture`,
|
|
26
|
+
phases: [{ name: "Run", steps: [{ kind: "activity", fn, args: {} }] }],
|
|
27
|
+
...(cron ? { schedule: { cron, overlap: "skip" as const } } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ready = (fn = "ready"): ActivityStep => ({ kind: "activity", fn, args: {} });
|
|
32
|
+
|
|
33
|
+
function git(args: string[], cwd: string): void {
|
|
34
|
+
spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function initRepo(dir: string): void {
|
|
38
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
39
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
40
|
+
git(["config", "user.name", "Test"], dir);
|
|
41
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
42
|
+
writeFileSync(join(dir, "app", "index.ts"), "export const hello = 1;\n");
|
|
43
|
+
git(["add", "app/index.ts"], dir);
|
|
44
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const kinds = (events: OperatorTickEvent[]) => events.map((e) => [e.kind, e.op]);
|
|
48
|
+
|
|
49
|
+
describe("declareSteward's beside (#2861)", () => {
|
|
50
|
+
test("lists the Ops beside the turns after its turns' Ops, and names them", () => {
|
|
51
|
+
const steward = declareSteward({
|
|
52
|
+
name: "box-steward",
|
|
53
|
+
ops: [op("converge", "* * * * *")],
|
|
54
|
+
beside: [{ op: op("dispatch", undefined, "build"), ready: ready() }, op("rebuild", "0 3 * * *")],
|
|
55
|
+
});
|
|
56
|
+
expect(steward.ops.map((o) => o.name)).toEqual(["converge", "dispatch", "rebuild"]);
|
|
57
|
+
expect(steward.beside).toEqual([{ op: "dispatch", ready: ready() }, { op: "rebuild", ready: null }]);
|
|
58
|
+
expect(stewardTurnOps(steward).map((o) => o.name)).toEqual(["converge"]);
|
|
59
|
+
expect(stewardBesideFor(steward, "dispatch")?.ready).toEqual(ready());
|
|
60
|
+
expect(stewardBesideFor(steward, "converge")).toBeUndefined();
|
|
61
|
+
// A declaration from before #2861 has no beside, and every Op is a turn.
|
|
62
|
+
const { beside: _dropped, ...older } = steward;
|
|
63
|
+
expect(stewardTurnOps(older as typeof steward).map((o) => o.name)).toEqual(["converge", "dispatch", "rebuild"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("refuses an Op both in ops and beside, a ready that is not one activity step, and one that reads a step's output", () => {
|
|
67
|
+
const converge = op("converge", "* * * * *");
|
|
68
|
+
expect(() => declareSteward({ name: "s", ops: [converge], beside: [converge] })).toThrow(/listed twice/);
|
|
69
|
+
expect(() =>
|
|
70
|
+
declareSteward({ name: "s", ops: [], beside: [{ op: op("dispatch"), ready: { kind: "gate", gate: "g" } as unknown as ActivityStep }] }),
|
|
71
|
+
).toThrow(/ready is one activity step/);
|
|
72
|
+
expect(() =>
|
|
73
|
+
declareSteward({ name: "s", ops: [], beside: [{ op: op("dispatch"), ready: { kind: "activity", fn: "shellCmd", args: { cmd: stepOutput("pick", "json") } } }] }),
|
|
74
|
+
).toThrow(/reads another step's output/);
|
|
75
|
+
// Started on its own, it can't be handed the work item a run names.
|
|
76
|
+
const leased: OpConfig = { ...op("dispatch"), changesCheckout: true, workLease: {} };
|
|
77
|
+
expect(() => declareSteward({ name: "s", ops: [], beside: [{ op: leased, ready: ready() }] })).toThrow(/workLease names no item/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a ready step's answer: a list of keys, a string, true, or no work", () => {
|
|
81
|
+
expect(readinessKeys({ stdout: "", json: ["W-1:0", "intent:ws-1"] })).toEqual({ ready: true, keys: ["W-1:0", "intent:ws-1"] });
|
|
82
|
+
expect(readinessKeys([{ item: "W-2" }])).toEqual({ ready: true, keys: ['{"item":"W-2"}'] });
|
|
83
|
+
expect(readinessKeys("W-3")).toEqual({ ready: true, keys: ["W-3"] });
|
|
84
|
+
expect(readinessKeys(true)).toEqual({ ready: true, keys: [] });
|
|
85
|
+
for (const none of [false, null, "", [], { json: null }]) expect(readinessKeys(none)).toEqual({ ready: false, keys: [] });
|
|
86
|
+
expect(readinessKeys({ ready: [] })).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("askReady reports a step that fails, answers in another shape, or isn't loaded", async () => {
|
|
90
|
+
const acts = new Map<string, ActivityFn>([
|
|
91
|
+
["boom", async () => { throw new Error("no factory here"); }],
|
|
92
|
+
["odd", async () => ({ ready: [{ item: "W-1" }], held: null })],
|
|
93
|
+
]);
|
|
94
|
+
expect(await askReady(ready("boom"), acts)).toEqual({ error: "no factory here" });
|
|
95
|
+
expect(await askReady(ready("odd"), acts)).toMatchObject({ error: expect.stringContaining("neither true") });
|
|
96
|
+
expect(await askReady(ready("missing"), acts)).toMatchObject({ error: expect.stringContaining('"missing" is not loaded') });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("an Op beside a steward's turns (#2861)", () => {
|
|
101
|
+
test("a long run beside a per-minute converge: converge ticks every minute while it runs, and no work is started twice", async () => {
|
|
102
|
+
await withTestDir(async (dir) => {
|
|
103
|
+
initRepo(dir);
|
|
104
|
+
let finish: () => void = () => {};
|
|
105
|
+
const building = new Promise<void>((resolve) => { finish = resolve; });
|
|
106
|
+
const log: string[] = [];
|
|
107
|
+
const activities = new Map<string, ActivityFn>([
|
|
108
|
+
["tick", async () => { log.push("converge"); return { ok: true }; }],
|
|
109
|
+
["build", async () => { log.push("build:start"); await building; log.push("build:end"); return { built: true }; }],
|
|
110
|
+
["ready", async () => ({ stdout: "", json: ["W-1:0"] })],
|
|
111
|
+
]);
|
|
112
|
+
const steward = declareSteward({
|
|
113
|
+
name: "box-steward",
|
|
114
|
+
ops: [op("converge", "* * * * *")],
|
|
115
|
+
beside: [{ op: op("dispatch", undefined, "build"), ready: ready() }],
|
|
116
|
+
});
|
|
117
|
+
const besideState = createBesideState();
|
|
118
|
+
const scheduleState = new Map<string, Date>();
|
|
119
|
+
const launchBeside = inProcessBesideLauncher(activities, PROFILES);
|
|
120
|
+
// Cron minutes are local time, so the rounds are too. The leases use the
|
|
121
|
+
// wall clock, so a fixed `now` is only handed to the cron.
|
|
122
|
+
let minute = 1;
|
|
123
|
+
const round = () => {
|
|
124
|
+
const at = new Date(2026, 8, 25, 10, minute++, 0);
|
|
125
|
+
return runOperatorRound({ cwd: dir, holder: "op1", steward, activities, profiles: PROFILES, besideState, scheduleState, launchBeside, now: () => at });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// 10:01: converge ticks as a turn; the ready step names work, and dispatch starts beside it.
|
|
129
|
+
const first = await round();
|
|
130
|
+
expect(kinds(first)).toEqual([["ticked", "converge"], ["beside-started", "dispatch"]]);
|
|
131
|
+
expect(first[1]).toMatchObject({ why: "ready", keys: ["W-1:0"], holder: "box-steward/dispatch@op1" });
|
|
132
|
+
expect(formatRoundLine(first[1])).toBe('operator: dispatch@local started=1(beside:ready) holder="box-steward/dispatch@op1" keys="W-1:0"');
|
|
133
|
+
await vi.waitFor(() => expect(log).toContain("build:start"));
|
|
134
|
+
|
|
135
|
+
// Mid-build the run holds the Op's own lease, and not the steward's turn.
|
|
136
|
+
expect((await readLease("dispatch", { cwd: dir })).record?.holder).toBe("box-steward/dispatch@op1");
|
|
137
|
+
expect((await readLease(stewardTurnLeaseName("box-steward"), { cwd: dir })).record).toBeUndefined();
|
|
138
|
+
|
|
139
|
+
// 10:02 and 10:03: converge ticks each minute while the build runs, and no second run starts.
|
|
140
|
+
for (let i = 0; i < 2; i++) {
|
|
141
|
+
const mid = await round();
|
|
142
|
+
expect(kinds(mid)).toEqual([["ticked", "converge"], ["beside-running", "dispatch"]]);
|
|
143
|
+
}
|
|
144
|
+
expect(log.filter((l) => l === "converge")).toHaveLength(3);
|
|
145
|
+
expect(log).not.toContain("build:end");
|
|
146
|
+
|
|
147
|
+
finish();
|
|
148
|
+
await waitForBesideRuns(besideState);
|
|
149
|
+
expect((await readLease("dispatch", { cwd: dir })).record).toBeUndefined();
|
|
150
|
+
const run = (await readRunLedger("local", "dispatch", { cwd: dir })).records.at(-1)!;
|
|
151
|
+
expect(run).toMatchObject({ status: "ok", steward: "box-steward" });
|
|
152
|
+
|
|
153
|
+
// 10:04: the end is reported, and the same work still named is not started again.
|
|
154
|
+
const after = await round();
|
|
155
|
+
expect(after).toEqual([
|
|
156
|
+
{ kind: "beside-ended", op: "dispatch", env: "local", code: 0 },
|
|
157
|
+
expect.objectContaining({ kind: "ticked", op: "converge" }),
|
|
158
|
+
{ kind: "skipped-not-ready", op: "dispatch", env: "local", already: ["W-1:0"] },
|
|
159
|
+
]);
|
|
160
|
+
expect(log.filter((l) => l === "build:start")).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a hand run holds the Op's lease and not the turn: converge still ticks, and the operator starts no second run", async () => {
|
|
165
|
+
await withTestDir(async (dir) => {
|
|
166
|
+
initRepo(dir);
|
|
167
|
+
const log: string[] = [];
|
|
168
|
+
const activities = new Map<string, ActivityFn>([
|
|
169
|
+
["tick", async () => { log.push("converge"); return { ok: true }; }],
|
|
170
|
+
["ready", async () => true],
|
|
171
|
+
]);
|
|
172
|
+
const steward = declareSteward({
|
|
173
|
+
name: "box-steward",
|
|
174
|
+
ops: [op("converge", "* * * * *")],
|
|
175
|
+
beside: [{ op: op("dispatch", undefined, "build"), ready: ready() }],
|
|
176
|
+
});
|
|
177
|
+
const launched: string[] = [];
|
|
178
|
+
const launchBeside: BesideLauncher = (start) => {
|
|
179
|
+
launched.push(start.op.name);
|
|
180
|
+
return { done: Promise.resolve({ code: 0 }), stop: () => {} };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// What `chant run dispatch` takes for an Op beside the turns.
|
|
184
|
+
const hand = await holdBesideLease("dispatch", "hand-run", { cwd: dir });
|
|
185
|
+
expect(hand.acquired).toBe(true);
|
|
186
|
+
expect((await readLease(stewardTurnLeaseName("box-steward"), { cwd: dir })).record).toBeUndefined();
|
|
187
|
+
|
|
188
|
+
const at = new Date(2026, 8, 25, 10, 1, 0);
|
|
189
|
+
const events = await runOperatorRound({ cwd: dir, holder: "op1", steward, activities, profiles: PROFILES, launchBeside, now: () => at });
|
|
190
|
+
expect(events.map((e) => e.kind)).toEqual(["ticked", "beside-running"]);
|
|
191
|
+
expect(events[1]).toEqual({ kind: "beside-running", op: "dispatch", env: "local", heldBy: "hand-run" });
|
|
192
|
+
expect(log).toEqual(["converge"]);
|
|
193
|
+
expect(launched).toEqual([]);
|
|
194
|
+
|
|
195
|
+
// A second hand run is refused while the first holds the lease.
|
|
196
|
+
expect(await holdBesideLease("dispatch", "hand-run-2", { cwd: dir })).toEqual({ acquired: false, heldBy: "hand-run" });
|
|
197
|
+
if (hand.acquired) await hand.release();
|
|
198
|
+
expect((await readLease("dispatch", { cwd: dir })).record).toBeUndefined();
|
|
199
|
+
const next = await runOperatorRound({ cwd: dir, holder: "op1", steward, activities, profiles: PROFILES, launchBeside, now: () => at });
|
|
200
|
+
expect(next.map((e) => e.kind)).toContain("beside-started");
|
|
201
|
+
expect(launched).toEqual(["dispatch"]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("the lease a long run holds is renewed while it runs", async () => {
|
|
206
|
+
await withTestDir(async (dir) => {
|
|
207
|
+
initRepo(dir);
|
|
208
|
+
const held = await holdBesideLease("dispatch", "h", { cwd: dir, ttlMs: 900 });
|
|
209
|
+
expect(held.acquired).toBe(true);
|
|
210
|
+
if (!held.acquired) return;
|
|
211
|
+
const first = held.lease.expiresAt;
|
|
212
|
+
await vi.waitFor(async () => {
|
|
213
|
+
const now = (await readLease("dispatch", { cwd: dir })).record;
|
|
214
|
+
expect(now?.token).toBe(held.lease.token);
|
|
215
|
+
expect(now!.expiresAt > first).toBe(true);
|
|
216
|
+
}, { timeout: 3000 });
|
|
217
|
+
await held.release();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a cron fire starts it, and one that fires while a run is in flight is dropped", async () => {
|
|
222
|
+
await withTestDir(async (dir) => {
|
|
223
|
+
initRepo(dir);
|
|
224
|
+
const steward = declareSteward({ name: "box-steward", ops: [], beside: [op("nightly", "0 3 * * *")] });
|
|
225
|
+
let end: () => void = () => {};
|
|
226
|
+
const launchBeside: BesideLauncher = () => ({ done: new Promise((resolve) => { end = () => resolve({ code: 0 }); }), stop: () => {} });
|
|
227
|
+
const besideState = createBesideState();
|
|
228
|
+
const scheduleState = new Map<string, Date>();
|
|
229
|
+
const round = (h: number, m: number) =>
|
|
230
|
+
runOperatorRound({ cwd: dir, holder: "op1", steward, activities: new Map(), profiles: PROFILES, besideState, scheduleState, launchBeside, now: () => new Date(2026, 8, 25, h, m, 0) });
|
|
231
|
+
expect(kinds(await round(2, 59))).toEqual([["skipped-not-due", "nightly"]]);
|
|
232
|
+
expect(await round(3, 0)).toEqual([expect.objectContaining({ kind: "beside-started", op: "nightly", why: "cron" })]);
|
|
233
|
+
expect(kinds(await round(3, 1))).toEqual([["beside-running", "nightly"]]);
|
|
234
|
+
end();
|
|
235
|
+
await waitForBesideRuns(besideState);
|
|
236
|
+
expect(kinds(await round(3, 2))).toEqual([["beside-ended", "nightly"], ["skipped-not-due", "nightly"]]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("the operator stops the runs it started when it stops", async () => {
|
|
241
|
+
await withTestDir(async (dir) => {
|
|
242
|
+
initRepo(dir);
|
|
243
|
+
const steward = declareSteward({ name: "box-steward", ops: [], beside: [{ op: op("dispatch"), ready: ready() }] });
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const stopped: string[] = [];
|
|
246
|
+
const launchBeside: BesideLauncher = (start) => {
|
|
247
|
+
let end: (code: number) => void = () => {};
|
|
248
|
+
const done = new Promise<{ code: number }>((resolve) => { end = (code) => resolve({ code }); });
|
|
249
|
+
return { done, stop: () => { stopped.push(start.op.name); end(1); } };
|
|
250
|
+
};
|
|
251
|
+
await runOperatorForever({
|
|
252
|
+
cwd: dir,
|
|
253
|
+
holder: "op1",
|
|
254
|
+
steward,
|
|
255
|
+
activities: new Map<string, ActivityFn>([["ready", async () => true]]),
|
|
256
|
+
profiles: PROFILES,
|
|
257
|
+
launchBeside,
|
|
258
|
+
intervalMs: 10,
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
onRound: (events) => {
|
|
261
|
+
if (events.some((e) => e.kind === "beside-started")) controller.abort();
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
expect(stopped).toEqual(["dispatch"]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|