@inhuman.tools/plexus-expectations 0.0.0-stage → 0.1.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/LICENSE.md +7 -0
- package/README.md +144 -2
- package/dist/driver/ExpectationActor.d.ts +56 -0
- package/dist/driver/ExpectationActor.d.ts.map +1 -0
- package/dist/driver/ExpectationActor.js +210 -0
- package/dist/driver/ExpectationActor.js.map +1 -0
- package/dist/driver/PEWDriver.d.ts +144 -0
- package/dist/driver/PEWDriver.d.ts.map +1 -0
- package/dist/driver/PEWDriver.js +303 -0
- package/dist/driver/PEWDriver.js.map +1 -0
- package/dist/driver/PEWDriverConnection.d.ts +139 -0
- package/dist/driver/PEWDriverConnection.d.ts.map +1 -0
- package/dist/driver/PEWDriverConnection.js +298 -0
- package/dist/driver/PEWDriverConnection.js.map +1 -0
- package/dist/driver/actor-machine.d.ts +28 -0
- package/dist/driver/actor-machine.d.ts.map +1 -0
- package/dist/driver/actor-machine.js +50 -0
- package/dist/driver/actor-machine.js.map +1 -0
- package/dist/driver/connection-miss.d.ts +9 -0
- package/dist/driver/connection-miss.d.ts.map +1 -0
- package/dist/driver/connection-miss.js +13 -0
- package/dist/driver/connection-miss.js.map +1 -0
- package/dist/driver/index.d.ts +6 -0
- package/dist/driver/index.d.ts.map +1 -0
- package/dist/driver/index.js +5 -0
- package/dist/driver/index.js.map +1 -0
- package/dist/error-with-invariant.d.ts +9 -0
- package/dist/error-with-invariant.d.ts.map +1 -0
- package/dist/error-with-invariant.js +12 -0
- package/dist/error-with-invariant.js.map +1 -0
- package/dist/executor/index.d.ts +4 -0
- package/dist/executor/index.d.ts.map +1 -0
- package/dist/executor/index.js +4 -0
- package/dist/executor/index.js.map +1 -0
- package/dist/executor/kernel-intern.d.ts +16 -0
- package/dist/executor/kernel-intern.d.ts.map +1 -0
- package/dist/executor/kernel-intern.js +98 -0
- package/dist/executor/kernel-intern.js.map +1 -0
- package/dist/executor/orchestrator.d.ts +97 -0
- package/dist/executor/orchestrator.d.ts.map +1 -0
- package/dist/executor/orchestrator.js +465 -0
- package/dist/executor/orchestrator.js.map +1 -0
- package/dist/executor/types.d.ts +8 -0
- package/dist/executor/types.d.ts.map +1 -0
- package/dist/executor/types.js +2 -0
- package/dist/executor/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/connection.d.ts +4 -0
- package/dist/schemas/connection.d.ts.map +1 -0
- package/dist/schemas/connection.js +4 -0
- package/dist/schemas/connection.js.map +1 -0
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +2 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/shared/attention.d.ts +27 -0
- package/dist/shared/attention.d.ts.map +1 -0
- package/dist/shared/attention.js +116 -0
- package/dist/shared/attention.js.map +1 -0
- package/dist/shared/control.d.ts +4 -0
- package/dist/shared/control.d.ts.map +1 -0
- package/dist/shared/control.js +2 -0
- package/dist/shared/control.js.map +1 -0
- package/dist/shared/index.d.ts +7 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +7 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/map-field-awareness.d.ts +19 -0
- package/dist/shared/map-field-awareness.d.ts.map +1 -0
- package/dist/shared/map-field-awareness.js +58 -0
- package/dist/shared/map-field-awareness.js.map +1 -0
- package/dist/shared/models/ApprovableExpectation.d.ts +15 -0
- package/dist/shared/models/ApprovableExpectation.d.ts.map +1 -0
- package/dist/shared/models/ApprovableExpectation.js +138 -0
- package/dist/shared/models/ApprovableExpectation.js.map +1 -0
- package/dist/shared/models/ConnectionSpec.d.ts +44 -0
- package/dist/shared/models/ConnectionSpec.d.ts.map +1 -0
- package/dist/shared/models/ConnectionSpec.js +180 -0
- package/dist/shared/models/ConnectionSpec.js.map +1 -0
- package/dist/shared/models/DriverSpec.d.ts +45 -0
- package/dist/shared/models/DriverSpec.d.ts.map +1 -0
- package/dist/shared/models/DriverSpec.js +221 -0
- package/dist/shared/models/DriverSpec.js.map +1 -0
- package/dist/shared/models/IntrospectingPlexusModel.d.ts +8 -0
- package/dist/shared/models/IntrospectingPlexusModel.d.ts.map +1 -0
- package/dist/shared/models/IntrospectingPlexusModel.js +48 -0
- package/dist/shared/models/IntrospectingPlexusModel.js.map +1 -0
- package/dist/shared/models/Job.d.ts +85 -0
- package/dist/shared/models/Job.d.ts.map +1 -0
- package/dist/shared/models/Job.js +261 -0
- package/dist/shared/models/Job.js.map +1 -0
- package/dist/shared/models/ProgrammaticJob.d.ts +6 -0
- package/dist/shared/models/ProgrammaticJob.d.ts.map +1 -0
- package/dist/shared/models/ProgrammaticJob.js +80 -0
- package/dist/shared/models/ProgrammaticJob.js.map +1 -0
- package/dist/shared/models/WorkContainer.d.ts +36 -0
- package/dist/shared/models/WorkContainer.d.ts.map +1 -0
- package/dist/shared/models/WorkContainer.js +140 -0
- package/dist/shared/models/WorkContainer.js.map +1 -0
- package/dist/shared/models/WorkExpectation.d.ts +63 -0
- package/dist/shared/models/WorkExpectation.d.ts.map +1 -0
- package/dist/shared/models/WorkExpectation.js +290 -0
- package/dist/shared/models/WorkExpectation.js.map +1 -0
- package/dist/shared/models/WorkExpectationWithIntrospection.d.ts +4 -0
- package/dist/shared/models/WorkExpectationWithIntrospection.d.ts.map +1 -0
- package/dist/shared/models/WorkExpectationWithIntrospection.js +28 -0
- package/dist/shared/models/WorkExpectationWithIntrospection.js.map +1 -0
- package/dist/shared/models/index.d.ts +7 -0
- package/dist/shared/models/index.d.ts.map +1 -0
- package/dist/shared/models/index.js +6 -0
- package/dist/shared/models/index.js.map +1 -0
- package/dist/shared/models/jsonRecord.d.ts +14 -0
- package/dist/shared/models/jsonRecord.d.ts.map +1 -0
- package/dist/shared/models/jsonRecord.js +46 -0
- package/dist/shared/models/jsonRecord.js.map +1 -0
- package/dist/shared/resolution.d.ts +39 -0
- package/dist/shared/resolution.d.ts.map +1 -0
- package/dist/shared/resolution.js +124 -0
- package/dist/shared/resolution.js.map +1 -0
- package/dist/shared/revalidation-demand.d.ts +27 -0
- package/dist/shared/revalidation-demand.d.ts.map +1 -0
- package/dist/shared/revalidation-demand.js +116 -0
- package/dist/shared/revalidation-demand.js.map +1 -0
- package/dist/shared/role.d.ts +12 -0
- package/dist/shared/role.d.ts.map +1 -0
- package/dist/shared/role.js +21 -0
- package/dist/shared/role.js.map +1 -0
- package/dist/shared/types.d.ts +43 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +12 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +79 -3
- package/src/driver/ExpectationActor.ts +244 -0
- package/src/driver/PEWDriver.ts +302 -0
- package/src/driver/actor-machine.ts +54 -0
- package/src/driver/connection-miss.ts +13 -0
- package/src/driver/index.ts +5 -0
- package/src/executor/index.ts +3 -0
- package/src/executor/kernel-intern.ts +121 -0
- package/src/executor/orchestrator.ts +463 -0
- package/src/executor/types.ts +3 -0
- package/src/index.ts +1 -0
- package/src/schemas/connection.ts +4 -0
- package/src/schemas/index.ts +1 -0
- package/src/shared/control.ts +4 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/map-field-awareness.ts +65 -0
- package/src/shared/models/ConnectionSpec.ts +84 -0
- package/src/shared/models/DriverSpec.ts +102 -0
- package/src/shared/models/Job.ts +192 -0
- package/src/shared/models/WorkContainer.ts +90 -0
- package/src/shared/models/WorkExpectation.ts +229 -0
- package/src/shared/models/index.ts +6 -0
- package/src/shared/models/jsonRecord.ts +49 -0
- package/src/shared/revalidation-demand.ts +121 -0
- package/src/shared/role.ts +32 -0
- package/src/shared/types.ts +57 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { type PlexusAwareness } from "@here.build/plexus";
|
|
2
|
+
import { ErrorWithInvariant } from "@inhuman.tools/error-with-invariant";
|
|
3
|
+
import { reaction, runInAction } from "mobx";
|
|
4
|
+
import invariant from "tiny-invariant";
|
|
5
|
+
import { createActor } from "xstate";
|
|
6
|
+
|
|
7
|
+
import { type ActorPhase, type ActorPhaseEvent, expectationActorMachine } from "./actor-machine.js";
|
|
8
|
+
import type { PEWDriver } from "./PEWDriver.js";
|
|
9
|
+
import type { Settlement } from "../executor/types.js";
|
|
10
|
+
import type { SessionAwareness } from "../shared/index.js";
|
|
11
|
+
import {
|
|
12
|
+
type AnyWorkExpectation,
|
|
13
|
+
type ReportOf,
|
|
14
|
+
type ResultOf,
|
|
15
|
+
ROLE_LANE_OCCUPIED,
|
|
16
|
+
} from "../shared/models/WorkExpectation.js";
|
|
17
|
+
import { reportRole, retractRole } from "../shared/role.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown from `run()` — `start()` settles the work as fail, not crash.
|
|
21
|
+
*
|
|
22
|
+
* Two kinds: {@link ConnectionNotReady} (fulfiller not live), {@link DemandIncomplete}
|
|
23
|
+
* (required field missing on the expectation). Not this family: cancel, applySettlement,
|
|
24
|
+
* report JSON crash, HTTP/tool `fail(reason)`.
|
|
25
|
+
*/
|
|
26
|
+
export abstract class ExpectationInvariant extends ErrorWithInvariant {
|
|
27
|
+
override name = this.constructor.name;
|
|
28
|
+
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Honor started; the fulfiller is not live. */
|
|
35
|
+
export class ConnectionNotReady extends ExpectationInvariant {
|
|
36
|
+
constructor(readonly connection: string) {
|
|
37
|
+
super(`${connection} connection not ready`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Required field missing on the expectation body. */
|
|
42
|
+
export class DemandIncomplete extends ExpectationInvariant {
|
|
43
|
+
constructor(readonly field: string) {
|
|
44
|
+
super(`demand missing ${field}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One execution of one expectation, bound to the process that has the live connection.
|
|
50
|
+
*
|
|
51
|
+
* ENDINGS ARE OBSERVED, NOT IMPOSED. The actor writes durable endings only at
|
|
52
|
+
* two authority points — its own settlement (`#settle` / `#crash`) and host
|
|
53
|
+
* admission (`admitTermination`). Everything that must happen BECAUSE the
|
|
54
|
+
* work ended (abort the signal, leave) runs in the done reaction — so an
|
|
55
|
+
* ending that arrives from anywhere (a peer's write synced in, host policy)
|
|
56
|
+
* stops this actor exactly like its own. Child endings are supervision's
|
|
57
|
+
* job (the tree-orphan rule), never a cascade from here.
|
|
58
|
+
*/
|
|
59
|
+
export abstract class ExpectationActor<E extends AnyWorkExpectation = AnyWorkExpectation> {
|
|
60
|
+
readonly #hub: PlexusAwareness<SessionAwareness>;
|
|
61
|
+
readonly #controller = new AbortController();
|
|
62
|
+
readonly signal: AbortSignal = this.#controller.signal;
|
|
63
|
+
#lastReportJson: string | null = null;
|
|
64
|
+
#settlement: Settlement<ResultOf<E>> | null = null;
|
|
65
|
+
#resolveSettled!: (settlement: Settlement<ResultOf<E>>) => void;
|
|
66
|
+
#rejectSettled!: (reason: unknown) => void;
|
|
67
|
+
public readonly settled: Promise<Settlement<ResultOf<E>>>;
|
|
68
|
+
readonly #phase = createActor(expectationActorMachine).start();
|
|
69
|
+
#laneDisposers: Array<() => void> = [];
|
|
70
|
+
#left = false;
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
public readonly expectation: E,
|
|
74
|
+
public readonly driverConnection: PEWDriver<E>,
|
|
75
|
+
) {
|
|
76
|
+
this.settled = new Promise<Settlement<ResultOf<E>>>((resolve, reject) => {
|
|
77
|
+
this.#resolveSettled = resolve;
|
|
78
|
+
this.#rejectSettled = reject;
|
|
79
|
+
});
|
|
80
|
+
void this.settled.catch(() => {});
|
|
81
|
+
|
|
82
|
+
this.signal.addEventListener(
|
|
83
|
+
"abort",
|
|
84
|
+
() => {
|
|
85
|
+
this.#send("ABORT");
|
|
86
|
+
},
|
|
87
|
+
{ once: true },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
invariant(expectation.plexus != null, "ExpectationActor requires the expectation to be on a session plexus");
|
|
91
|
+
this.#hub = expectation.plexus.awareness as PlexusAwareness<SessionAwareness>;
|
|
92
|
+
this.#laneDisposers.push(
|
|
93
|
+
reaction(
|
|
94
|
+
() => this.expectation.isDone,
|
|
95
|
+
(done) => {
|
|
96
|
+
if (!done) return;
|
|
97
|
+
this.abort("done");
|
|
98
|
+
this.#send("RELEASE");
|
|
99
|
+
this.leave();
|
|
100
|
+
},
|
|
101
|
+
{ fireImmediately: true },
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get started(): boolean {
|
|
107
|
+
return !this.#phase.getSnapshot().can({ type: "START" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get done(): boolean {
|
|
111
|
+
return this.#phase.getSnapshot().status === "done";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
start(): void {
|
|
115
|
+
invariant(this.#phase.getSnapshot().can({ type: "START" }), "double-start attempt");
|
|
116
|
+
this.#send("START");
|
|
117
|
+
runInAction(() => {
|
|
118
|
+
this.expectation.markStarted();
|
|
119
|
+
});
|
|
120
|
+
this.#occupyRoleLane();
|
|
121
|
+
try {
|
|
122
|
+
const result = this.run();
|
|
123
|
+
if (result != null && typeof (result as Promise<void>).then === "function") {
|
|
124
|
+
(result as Promise<void>).catch((error) => this.#onRunError(error));
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.#onRunError(error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#onRunError(error: unknown): void {
|
|
132
|
+
if (error instanceof ExpectationInvariant) {
|
|
133
|
+
this.fail(error);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.#crash(error);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#matches(phase: ActorPhase): boolean {
|
|
140
|
+
return this.#phase.getSnapshot().matches(phase);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#send(type: ActorPhaseEvent["type"]): void {
|
|
144
|
+
if (!this.#phase.getSnapshot().can({ type })) return;
|
|
145
|
+
this.#phase.send({ type });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#occupyRoleLane(): void {
|
|
149
|
+
reportRole(this.#hub, this.expectation.uuid, ROLE_LANE_OCCUPIED);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected abstract run(): void | Promise<void>;
|
|
153
|
+
|
|
154
|
+
abort(reason?: unknown): void {
|
|
155
|
+
this.#controller.abort(reason);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
protected report(frame: ReportOf<E>): void {
|
|
159
|
+
if (!this.#matches("running")) return;
|
|
160
|
+
let json: string;
|
|
161
|
+
try {
|
|
162
|
+
json = JSON.stringify(frame ?? null);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.#crash(error);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.#lastReportJson = json;
|
|
168
|
+
reportRole(this.#hub, this.expectation.uuid, JSON.parse(json));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
protected complete(result: ResultOf<E>): void {
|
|
172
|
+
this.#settle({ outcome: "complete", result });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
protected fail(reason: unknown): void {
|
|
176
|
+
this.#settle({ outcome: "fail", reason });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#settle(settlement: Settlement<ResultOf<E>>): void {
|
|
180
|
+
if (this.done || !this.#phase.getSnapshot().can({ type: "SETTLE" })) return;
|
|
181
|
+
const wasRunning = this.#matches("running");
|
|
182
|
+
this.#settlement = settlement;
|
|
183
|
+
this.#resolveSettled(settlement);
|
|
184
|
+
this.#send("SETTLE");
|
|
185
|
+
this.#commitEnding("settle", wasRunning);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#crash(reason: unknown): void {
|
|
189
|
+
if (this.done || !this.#phase.getSnapshot().can({ type: "CRASH" })) return;
|
|
190
|
+
const wasRunning = this.#matches("running");
|
|
191
|
+
this.#rejectSettled(reason);
|
|
192
|
+
this.#send("CRASH");
|
|
193
|
+
this.#commitEnding("crash", wasRunning, String(reason));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* ENDING PRECEDES ABORT. A settlement captured while running (or consulted
|
|
198
|
+
* when admitting cancel) wins. A complete/fail flushed from an abort listener
|
|
199
|
+
* is stored on `#settlement` but not written here.
|
|
200
|
+
*/
|
|
201
|
+
#commitEnding(kind: "settle" | "crash" | "cancel", wasRunning: boolean, crashDetail?: string): void {
|
|
202
|
+
if (this.expectation.isDone) return;
|
|
203
|
+
if (this.#settlement !== null && (wasRunning || kind === "cancel")) {
|
|
204
|
+
const settlement = this.#settlement;
|
|
205
|
+
if (settlement.outcome === "complete") {
|
|
206
|
+
this.expectation.succeed("settled", "", this.#frameJson(), { result: settlement.result });
|
|
207
|
+
} else {
|
|
208
|
+
this.expectation.fail(
|
|
209
|
+
"settled",
|
|
210
|
+
settlement.reason instanceof Error ? settlement.reason.message : String(settlement.reason),
|
|
211
|
+
this.#frameJson(),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (kind === "cancel") {
|
|
217
|
+
this.expectation.fail("cancel", "terminate", this.#frameJson());
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!wasRunning) return;
|
|
221
|
+
if (kind === "crash") {
|
|
222
|
+
this.expectation.fail("crash", crashDetail ?? "", this.#frameJson());
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Host cancel. A settlement captured while running outranks the cancel. */
|
|
227
|
+
admitTermination(): void {
|
|
228
|
+
this.#commitEnding("cancel", this.#matches("running"));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#frameJson(): string {
|
|
232
|
+
return this.#lastReportJson ?? "null";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
leave(): void {
|
|
236
|
+
if (!this.#left) {
|
|
237
|
+
this.#left = true;
|
|
238
|
+
for (const dispose of this.#laneDisposers) dispose();
|
|
239
|
+
this.#laneDisposers = [];
|
|
240
|
+
retractRole(this.#hub, this.expectation.uuid);
|
|
241
|
+
}
|
|
242
|
+
this.driverConnection.release(this.expectation);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { DefaultedWeakMap } from "@here.build/collections";
|
|
2
|
+
import type { PlexusAwareness } from "@here.build/plexus";
|
|
3
|
+
import { action, computed, observable, toJS } from "mobx";
|
|
4
|
+
|
|
5
|
+
import type { ExpectationActor } from "./ExpectationActor.js";
|
|
6
|
+
import { pewConfigurationSchema, pewSecretsSchema } from "../schemas/connection.js";
|
|
7
|
+
import type { AnyWorkExpectation, WorkExpectation } from "../shared/models/WorkExpectation.js";
|
|
8
|
+
import { revalidationDemandOf, type RevalidationDemand } from "../shared/revalidation-demand.js";
|
|
9
|
+
import type { ReadyState } from "../shared/types.js";
|
|
10
|
+
|
|
11
|
+
/** Duck-typed — drivers may ship a different Zod major than this package. */
|
|
12
|
+
export type ConfigurationSchema = {
|
|
13
|
+
safeParse(
|
|
14
|
+
data: unknown,
|
|
15
|
+
):
|
|
16
|
+
| { readonly success: true; readonly data: unknown }
|
|
17
|
+
| { readonly success: false; readonly error: { readonly issues: ReadonlyArray<{ readonly message?: string }> } };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Process-local. Secrets are never a Plexus field. */
|
|
21
|
+
export type ConnectionSecretsView = {
|
|
22
|
+
readonly revision?: number;
|
|
23
|
+
get(field: string): unknown | undefined;
|
|
24
|
+
/** MCP env_* enumerates deposited fields. */
|
|
25
|
+
all?(): Readonly<Record<string, unknown>> | undefined;
|
|
26
|
+
/** Hosts that persist a refreshed session (reauth) implement this. */
|
|
27
|
+
replace?(secrets: Readonly<Record<string, string>>): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const EMPTY_SECRETS: ConnectionSecretsView = { get: () => undefined };
|
|
31
|
+
|
|
32
|
+
/** Module-graph duplication breaks instanceof; the elevation gate checks this brand too. */
|
|
33
|
+
// eslint-disable-next-line no-secrets/no-secrets -- package identity tag, not a secret
|
|
34
|
+
export const PEW_DRIVER_BRAND = "@inhuman.tools/plexus-expectations:PEWDriver@1";
|
|
35
|
+
|
|
36
|
+
/** A connection construction the driver already knows will work. */
|
|
37
|
+
export type DriverOffer = {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly description: string;
|
|
40
|
+
readonly live: boolean;
|
|
41
|
+
readonly adoptable: boolean;
|
|
42
|
+
readonly settings: Readonly<Record<string, unknown>>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Driver extras the orchestrator mirrors onto `spec.live`.
|
|
47
|
+
* Core process facts live on `readyState` / `error` / `offers`.
|
|
48
|
+
*/
|
|
49
|
+
export function extraLiveFromState(state: object): Record<string, unknown> {
|
|
50
|
+
const extra: Record<string, unknown> = {};
|
|
51
|
+
for (const [key, value] of Object.entries(state)) {
|
|
52
|
+
if (value === undefined) continue;
|
|
53
|
+
extra[key] = toJS(value);
|
|
54
|
+
}
|
|
55
|
+
return extra;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The CLASS is the driver (one loaded module per driver name).
|
|
60
|
+
* The INSTANCE is a live connection (one per connection id).
|
|
61
|
+
*
|
|
62
|
+
* CLAIM DOES NOT LIVE HERE. Which WorkExpectation classes this driver handles
|
|
63
|
+
* lives on the advertised driver record (`can`): dynamically loaded code does
|
|
64
|
+
* not get to declare its own authority.
|
|
65
|
+
*
|
|
66
|
+
* Live state is report only — no machinery branches on it. Instance bootstrap
|
|
67
|
+
* truth is `bootstrapDone` / `bootstrapPending`. Drivers read {@link attention}
|
|
68
|
+
* as the reduced token, never clientIds.
|
|
69
|
+
*
|
|
70
|
+
* YAML doors ({@link configurationSchema} / {@link secretsSchema}) parse
|
|
71
|
+
* without `new`. Secrets stay a live view — the host may replace it (reauth).
|
|
72
|
+
*/
|
|
73
|
+
export abstract class PEWDriver<
|
|
74
|
+
E extends AnyWorkExpectation = AnyWorkExpectation,
|
|
75
|
+
C = Readonly<Record<string, unknown>>,
|
|
76
|
+
> implements AsyncDisposable {
|
|
77
|
+
// ── driver side (the CLASS is the driver) ─────────────────────────────
|
|
78
|
+
static readonly pewBrand: string = PEW_DRIVER_BRAND;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* YAML settings shape. Default is a passthrough record — subclasses replace
|
|
82
|
+
* this with a real object schema (transforms included). First issue message
|
|
83
|
+
* is what construct / parse throw.
|
|
84
|
+
*/
|
|
85
|
+
static readonly configurationSchema: ConfigurationSchema = pewConfigurationSchema;
|
|
86
|
+
|
|
87
|
+
static readonly configurationLabel: string = "";
|
|
88
|
+
|
|
89
|
+
static configurationError(message: string): string {
|
|
90
|
+
const label = this.configurationLabel.trim();
|
|
91
|
+
return label === "" ? `connection config invalid: ${message}` : `${label} connection config invalid: ${message}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** YAML settings door. Does not construct an instance. */
|
|
95
|
+
static parseConfiguration(settings: unknown = {}): unknown {
|
|
96
|
+
const result = this.configurationSchema.safeParse(settings ?? {});
|
|
97
|
+
if (result.success) return result.data;
|
|
98
|
+
const message = result.error.issues[0]?.message ?? "invalid";
|
|
99
|
+
throw new Error(this.configurationError(message));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static readonly secretsSchema: ConfigurationSchema = pewSecretsSchema;
|
|
103
|
+
|
|
104
|
+
static secretsError(message: string): string {
|
|
105
|
+
const label = this.configurationLabel.trim();
|
|
106
|
+
return label === "" ? `connection secrets invalid: ${message}` : `${label} connection secrets invalid: ${message}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** YAML secrets door. Does not construct an instance. */
|
|
110
|
+
static parseSecrets(secrets: unknown = {}): unknown {
|
|
111
|
+
const result = this.secretsSchema.safeParse(secrets ?? {});
|
|
112
|
+
if (result.success) return result.data;
|
|
113
|
+
const message = result.error.issues[0]?.message ?? "invalid";
|
|
114
|
+
throw new Error(this.secretsError(message));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Shared on the prototype chain — own extras per subclass. */
|
|
118
|
+
static readonly state: Record<string, unknown> = {};
|
|
119
|
+
/**
|
|
120
|
+
* A plain list, or a getter over observable state when the driver keeps its
|
|
121
|
+
* offers fresh (local detection). The orchestrator mirrors it onto
|
|
122
|
+
* `DriverSpec.offers` with a reaction, which is also what observes it.
|
|
123
|
+
*/
|
|
124
|
+
static readonly offers: readonly DriverOffer[] = [];
|
|
125
|
+
|
|
126
|
+
/** Kernel-plane occupancy hub. Orchestrator assignment is own-per-class. */
|
|
127
|
+
static awareness: PlexusAwareness | undefined;
|
|
128
|
+
static driverName = "";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A getter, not `@computed` — MobX: the decorator's initializer pins to the
|
|
132
|
+
* declaring class, so subclasses would share one computed.
|
|
133
|
+
*/
|
|
134
|
+
static get revalidationDemand(): RevalidationDemand {
|
|
135
|
+
const hub = this.awareness;
|
|
136
|
+
const name = this.driverName;
|
|
137
|
+
if (hub === undefined || name === "") return "none";
|
|
138
|
+
return revalidationDemandOf(hub, { kind: "driver", name });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── connection side (the INSTANCE is the connection) ──────────────────
|
|
142
|
+
@observable accessor readyState: ReadyState = null;
|
|
143
|
+
@observable accessor error: string = "";
|
|
144
|
+
/**
|
|
145
|
+
* MobX: shallow — extras (models, tools, account) are replaced wholesale.
|
|
146
|
+
* Override the accessor with a wider literal to add driver facts.
|
|
147
|
+
*/
|
|
148
|
+
@observable.shallow accessor state: Record<string, unknown> = {};
|
|
149
|
+
|
|
150
|
+
@observable.ref accessor awareness: PlexusAwareness | undefined = undefined;
|
|
151
|
+
|
|
152
|
+
@computed
|
|
153
|
+
get revalidationDemand(): RevalidationDemand {
|
|
154
|
+
const hub = this.awareness;
|
|
155
|
+
if (hub === undefined) return "none";
|
|
156
|
+
return revalidationDemandOf(hub, { kind: "connection", id: this.connectionId });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
public readonly connectionId: string,
|
|
161
|
+
settings: unknown = {},
|
|
162
|
+
secrets: ConnectionSecretsView | Readonly<Record<string, unknown>> = EMPTY_SECRETS,
|
|
163
|
+
) {
|
|
164
|
+
const cls = new.target as typeof PEWDriver;
|
|
165
|
+
this.settings = cls.parseConfiguration(settings) as C;
|
|
166
|
+
const view: ConnectionSecretsView =
|
|
167
|
+
typeof (secrets as ConnectionSecretsView).get === "function"
|
|
168
|
+
? (secrets as ConnectionSecretsView)
|
|
169
|
+
: {
|
|
170
|
+
get: (field) => (secrets as Readonly<Record<string, unknown>>)[field],
|
|
171
|
+
all: () => secrets as Readonly<Record<string, unknown>>,
|
|
172
|
+
};
|
|
173
|
+
cls.parseSecrets(view.all?.() ?? {});
|
|
174
|
+
this.secrets = view;
|
|
175
|
+
}
|
|
176
|
+
readonly settings: C;
|
|
177
|
+
/** Live view. The host may replace it (reauth). */
|
|
178
|
+
secrets: ConnectionSecretsView;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Host binds the disk writer for this connection's settings bag.
|
|
182
|
+
* Parsed settings stay readonly; a driver that learns a value overrides this.
|
|
183
|
+
*/
|
|
184
|
+
bindSettingsWriter(_write: (patch: Readonly<Record<string, unknown>>) => void): void {}
|
|
185
|
+
|
|
186
|
+
abstract bootstrap(): Promise<void>;
|
|
187
|
+
|
|
188
|
+
bootstrapDone = false;
|
|
189
|
+
bootstrapPending: Promise<void> | null = null;
|
|
190
|
+
/** `resolve()` threw — the connection is broken, not the work. */
|
|
191
|
+
broken = false;
|
|
192
|
+
/** Concurrent callers share one promise. Async settlement is its own MobX action. */
|
|
193
|
+
@action
|
|
194
|
+
ensureBootstrapStarted(): Promise<void> {
|
|
195
|
+
if (this.bootstrapDone) return Promise.resolve();
|
|
196
|
+
if (this.bootstrapPending) return this.bootstrapPending;
|
|
197
|
+
this.readyState = "bootstrapping";
|
|
198
|
+
this.error = "";
|
|
199
|
+
this.bootstrapPending = this.bootstrap().then(
|
|
200
|
+
action(() => {
|
|
201
|
+
this.bootstrapDone = true;
|
|
202
|
+
this.readyState = "ready";
|
|
203
|
+
this.error = "";
|
|
204
|
+
}),
|
|
205
|
+
action((error: unknown) => {
|
|
206
|
+
this.readyState = "failed";
|
|
207
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
208
|
+
throw error;
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
return this.bootstrapPending;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@action
|
|
215
|
+
failReady(error: unknown): void {
|
|
216
|
+
this.broken = true;
|
|
217
|
+
this.readyState = "failed";
|
|
218
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
protected abstract resolve(expectation: E): ExpectationActor<E>;
|
|
222
|
+
/** Nothing in PEW calls this; hosts or tests may. */
|
|
223
|
+
async [Symbol.asyncDispose](): Promise<void> {}
|
|
224
|
+
|
|
225
|
+
private readonly actors = new DefaultedWeakMap<WorkExpectation, ExpectationActor<any>>((expectation: WorkExpectation): ExpectationActor<any> => {
|
|
226
|
+
const actor = this.resolve(expectation as E);
|
|
227
|
+
this.liveActors.add(actor);
|
|
228
|
+
return actor;
|
|
229
|
+
});
|
|
230
|
+
private readonly liveActors = new Set<ExpectationActor<any>>();
|
|
231
|
+
|
|
232
|
+
has(expectation: WorkExpectation): boolean {
|
|
233
|
+
return this.actors.has(expectation);
|
|
234
|
+
}
|
|
235
|
+
resolveExpectation<T extends AnyWorkExpectation>(expectation: T): ExpectationActor<T> {
|
|
236
|
+
return this.actors.get(expectation);
|
|
237
|
+
}
|
|
238
|
+
release(expectation: WorkExpectation): void {
|
|
239
|
+
if (!this.actors.has(expectation)) return;
|
|
240
|
+
const actor = this.actors.get(expectation);
|
|
241
|
+
this.actors.delete(expectation);
|
|
242
|
+
this.liveActors.delete(actor);
|
|
243
|
+
actor.leave();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Logout/drop aborts interned work. Not park. Subclasses abort `#inflight`.
|
|
248
|
+
*/
|
|
249
|
+
abortOnDrop(reason: unknown): void {
|
|
250
|
+
for (const actor of this.liveActors) actor.abort(reason);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Honor one demand.
|
|
255
|
+
* `resolve()` throwing is a broken connection, not a failed expectation.
|
|
256
|
+
*/
|
|
257
|
+
handle(expectation: WorkExpectation): void {
|
|
258
|
+
if (this.broken) return;
|
|
259
|
+
if (expectation.isDone) return;
|
|
260
|
+
if (this.has(expectation)) {
|
|
261
|
+
const existing = this.resolveExpectation(expectation);
|
|
262
|
+
if (!existing.started) existing.start();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let actor: ExpectationActor<any>;
|
|
267
|
+
try {
|
|
268
|
+
actor = this.resolveExpectation(expectation);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.failReady(error);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Awareness / host policy may have sealed the work while resolve() ran.
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- resolve() can seal via spawn hooks
|
|
276
|
+
if (expectation.isDone) {
|
|
277
|
+
actor.leave();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!actor.started) {
|
|
282
|
+
actor.start();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A concrete `PEWDriver` subclass — constructable, not abstract.
|
|
289
|
+
* `loadDriverModule` returns `unknown`; {@link isDriverConnectionClass} is the gate.
|
|
290
|
+
*/
|
|
291
|
+
export type DriverConnectionClass = {
|
|
292
|
+
[K in keyof typeof PEWDriver]: (typeof PEWDriver)[K];
|
|
293
|
+
} & (new (...args: ConstructorParameters<typeof PEWDriver>) => PEWDriver);
|
|
294
|
+
|
|
295
|
+
export function isDriverConnectionClass(value: unknown): value is DriverConnectionClass {
|
|
296
|
+
if (typeof value !== "function") return false;
|
|
297
|
+
if ((value as { pewBrand?: unknown }).pewBrand === PEW_DRIVER_BRAND) return true;
|
|
298
|
+
return (
|
|
299
|
+
typeof (value as { prototype?: unknown }).prototype === "object" &&
|
|
300
|
+
(value as { prototype: object }).prototype instanceof PEWDriver
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { setup, type EventFromLogic, type StateValueFrom } from "xstate";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process-local actor phase. Distinct from the durable WorkExpectation lifecycle.
|
|
5
|
+
*
|
|
6
|
+
* minted ──START──► running ──SETTLE|CRASH──► resolved (final)
|
|
7
|
+
* │ │
|
|
8
|
+
* ├──ABORT──► aborted ──SETTLE|CRASH──────────┘
|
|
9
|
+
* └──RELEASE────────┴──RELEASE──────────► released (final)
|
|
10
|
+
*
|
|
11
|
+
* ENDINGS ARE OBSERVED. RELEASE fires from the actor's done reaction on the
|
|
12
|
+
* durable entity — never from a control-flow wrapper. `resolved` is
|
|
13
|
+
* settlement/crash; `released` is abort without a settlement.
|
|
14
|
+
*/
|
|
15
|
+
export const expectationActorMachine = setup({
|
|
16
|
+
types: {
|
|
17
|
+
events: {} as { type: "START" | "SETTLE" | "CRASH" | "ABORT" | "RELEASE" },
|
|
18
|
+
},
|
|
19
|
+
}).createMachine({
|
|
20
|
+
id: "pew-expectation-actor",
|
|
21
|
+
initial: "minted",
|
|
22
|
+
states: {
|
|
23
|
+
minted: {
|
|
24
|
+
tags: ["live"],
|
|
25
|
+
on: {
|
|
26
|
+
START: "running",
|
|
27
|
+
ABORT: "aborted",
|
|
28
|
+
RELEASE: "released",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
running: {
|
|
32
|
+
tags: ["live"],
|
|
33
|
+
on: {
|
|
34
|
+
SETTLE: "resolved",
|
|
35
|
+
CRASH: "resolved",
|
|
36
|
+
ABORT: "aborted",
|
|
37
|
+
RELEASE: "released",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
resolved: { type: "final" },
|
|
41
|
+
aborted: {
|
|
42
|
+
on: {
|
|
43
|
+
SETTLE: "resolved",
|
|
44
|
+
CRASH: "resolved",
|
|
45
|
+
RELEASE: "released",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
released: { type: "final" },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type ActorPhase = StateValueFrom<typeof expectationActorMachine>;
|
|
53
|
+
|
|
54
|
+
export type ActorPhaseEvent = EventFromLogic<typeof expectationActorMachine>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intern / send-ready miss. Remainder maps `noconnect` → writeFail("no model connection").
|
|
3
|
+
* Honor fallback (one release) catches only this class.
|
|
4
|
+
*/
|
|
5
|
+
export class ConnectionMiss extends Error {
|
|
6
|
+
readonly code: "noconnect" | "not-ready" | "needs-auth" | "connection-dropped";
|
|
7
|
+
|
|
8
|
+
constructor(code: ConnectionMiss["code"], message?: string) {
|
|
9
|
+
super(message ?? code);
|
|
10
|
+
this.name = "ConnectionMiss";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|