@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.
Files changed (160) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +144 -2
  3. package/dist/driver/ExpectationActor.d.ts +56 -0
  4. package/dist/driver/ExpectationActor.d.ts.map +1 -0
  5. package/dist/driver/ExpectationActor.js +210 -0
  6. package/dist/driver/ExpectationActor.js.map +1 -0
  7. package/dist/driver/PEWDriver.d.ts +144 -0
  8. package/dist/driver/PEWDriver.d.ts.map +1 -0
  9. package/dist/driver/PEWDriver.js +303 -0
  10. package/dist/driver/PEWDriver.js.map +1 -0
  11. package/dist/driver/PEWDriverConnection.d.ts +139 -0
  12. package/dist/driver/PEWDriverConnection.d.ts.map +1 -0
  13. package/dist/driver/PEWDriverConnection.js +298 -0
  14. package/dist/driver/PEWDriverConnection.js.map +1 -0
  15. package/dist/driver/actor-machine.d.ts +28 -0
  16. package/dist/driver/actor-machine.d.ts.map +1 -0
  17. package/dist/driver/actor-machine.js +50 -0
  18. package/dist/driver/actor-machine.js.map +1 -0
  19. package/dist/driver/connection-miss.d.ts +9 -0
  20. package/dist/driver/connection-miss.d.ts.map +1 -0
  21. package/dist/driver/connection-miss.js +13 -0
  22. package/dist/driver/connection-miss.js.map +1 -0
  23. package/dist/driver/index.d.ts +6 -0
  24. package/dist/driver/index.d.ts.map +1 -0
  25. package/dist/driver/index.js +5 -0
  26. package/dist/driver/index.js.map +1 -0
  27. package/dist/error-with-invariant.d.ts +9 -0
  28. package/dist/error-with-invariant.d.ts.map +1 -0
  29. package/dist/error-with-invariant.js +12 -0
  30. package/dist/error-with-invariant.js.map +1 -0
  31. package/dist/executor/index.d.ts +4 -0
  32. package/dist/executor/index.d.ts.map +1 -0
  33. package/dist/executor/index.js +4 -0
  34. package/dist/executor/index.js.map +1 -0
  35. package/dist/executor/kernel-intern.d.ts +16 -0
  36. package/dist/executor/kernel-intern.d.ts.map +1 -0
  37. package/dist/executor/kernel-intern.js +98 -0
  38. package/dist/executor/kernel-intern.js.map +1 -0
  39. package/dist/executor/orchestrator.d.ts +97 -0
  40. package/dist/executor/orchestrator.d.ts.map +1 -0
  41. package/dist/executor/orchestrator.js +465 -0
  42. package/dist/executor/orchestrator.js.map +1 -0
  43. package/dist/executor/types.d.ts +8 -0
  44. package/dist/executor/types.d.ts.map +1 -0
  45. package/dist/executor/types.js +2 -0
  46. package/dist/executor/types.js.map +1 -0
  47. package/dist/index.d.ts +2 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +2 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/schemas/connection.d.ts +4 -0
  52. package/dist/schemas/connection.d.ts.map +1 -0
  53. package/dist/schemas/connection.js +4 -0
  54. package/dist/schemas/connection.js.map +1 -0
  55. package/dist/schemas/index.d.ts +2 -0
  56. package/dist/schemas/index.d.ts.map +1 -0
  57. package/dist/schemas/index.js +2 -0
  58. package/dist/schemas/index.js.map +1 -0
  59. package/dist/shared/attention.d.ts +27 -0
  60. package/dist/shared/attention.d.ts.map +1 -0
  61. package/dist/shared/attention.js +116 -0
  62. package/dist/shared/attention.js.map +1 -0
  63. package/dist/shared/control.d.ts +4 -0
  64. package/dist/shared/control.d.ts.map +1 -0
  65. package/dist/shared/control.js +2 -0
  66. package/dist/shared/control.js.map +1 -0
  67. package/dist/shared/index.d.ts +7 -0
  68. package/dist/shared/index.d.ts.map +1 -0
  69. package/dist/shared/index.js +7 -0
  70. package/dist/shared/index.js.map +1 -0
  71. package/dist/shared/map-field-awareness.d.ts +19 -0
  72. package/dist/shared/map-field-awareness.d.ts.map +1 -0
  73. package/dist/shared/map-field-awareness.js +58 -0
  74. package/dist/shared/map-field-awareness.js.map +1 -0
  75. package/dist/shared/models/ApprovableExpectation.d.ts +15 -0
  76. package/dist/shared/models/ApprovableExpectation.d.ts.map +1 -0
  77. package/dist/shared/models/ApprovableExpectation.js +138 -0
  78. package/dist/shared/models/ApprovableExpectation.js.map +1 -0
  79. package/dist/shared/models/ConnectionSpec.d.ts +44 -0
  80. package/dist/shared/models/ConnectionSpec.d.ts.map +1 -0
  81. package/dist/shared/models/ConnectionSpec.js +180 -0
  82. package/dist/shared/models/ConnectionSpec.js.map +1 -0
  83. package/dist/shared/models/DriverSpec.d.ts +45 -0
  84. package/dist/shared/models/DriverSpec.d.ts.map +1 -0
  85. package/dist/shared/models/DriverSpec.js +221 -0
  86. package/dist/shared/models/DriverSpec.js.map +1 -0
  87. package/dist/shared/models/IntrospectingPlexusModel.d.ts +8 -0
  88. package/dist/shared/models/IntrospectingPlexusModel.d.ts.map +1 -0
  89. package/dist/shared/models/IntrospectingPlexusModel.js +48 -0
  90. package/dist/shared/models/IntrospectingPlexusModel.js.map +1 -0
  91. package/dist/shared/models/Job.d.ts +85 -0
  92. package/dist/shared/models/Job.d.ts.map +1 -0
  93. package/dist/shared/models/Job.js +261 -0
  94. package/dist/shared/models/Job.js.map +1 -0
  95. package/dist/shared/models/ProgrammaticJob.d.ts +6 -0
  96. package/dist/shared/models/ProgrammaticJob.d.ts.map +1 -0
  97. package/dist/shared/models/ProgrammaticJob.js +80 -0
  98. package/dist/shared/models/ProgrammaticJob.js.map +1 -0
  99. package/dist/shared/models/WorkContainer.d.ts +36 -0
  100. package/dist/shared/models/WorkContainer.d.ts.map +1 -0
  101. package/dist/shared/models/WorkContainer.js +140 -0
  102. package/dist/shared/models/WorkContainer.js.map +1 -0
  103. package/dist/shared/models/WorkExpectation.d.ts +63 -0
  104. package/dist/shared/models/WorkExpectation.d.ts.map +1 -0
  105. package/dist/shared/models/WorkExpectation.js +290 -0
  106. package/dist/shared/models/WorkExpectation.js.map +1 -0
  107. package/dist/shared/models/WorkExpectationWithIntrospection.d.ts +4 -0
  108. package/dist/shared/models/WorkExpectationWithIntrospection.d.ts.map +1 -0
  109. package/dist/shared/models/WorkExpectationWithIntrospection.js +28 -0
  110. package/dist/shared/models/WorkExpectationWithIntrospection.js.map +1 -0
  111. package/dist/shared/models/index.d.ts +7 -0
  112. package/dist/shared/models/index.d.ts.map +1 -0
  113. package/dist/shared/models/index.js +6 -0
  114. package/dist/shared/models/index.js.map +1 -0
  115. package/dist/shared/models/jsonRecord.d.ts +14 -0
  116. package/dist/shared/models/jsonRecord.d.ts.map +1 -0
  117. package/dist/shared/models/jsonRecord.js +46 -0
  118. package/dist/shared/models/jsonRecord.js.map +1 -0
  119. package/dist/shared/resolution.d.ts +39 -0
  120. package/dist/shared/resolution.d.ts.map +1 -0
  121. package/dist/shared/resolution.js +124 -0
  122. package/dist/shared/resolution.js.map +1 -0
  123. package/dist/shared/revalidation-demand.d.ts +27 -0
  124. package/dist/shared/revalidation-demand.d.ts.map +1 -0
  125. package/dist/shared/revalidation-demand.js +116 -0
  126. package/dist/shared/revalidation-demand.js.map +1 -0
  127. package/dist/shared/role.d.ts +12 -0
  128. package/dist/shared/role.d.ts.map +1 -0
  129. package/dist/shared/role.js +21 -0
  130. package/dist/shared/role.js.map +1 -0
  131. package/dist/shared/types.d.ts +43 -0
  132. package/dist/shared/types.d.ts.map +1 -0
  133. package/dist/shared/types.js +12 -0
  134. package/dist/shared/types.js.map +1 -0
  135. package/package.json +79 -3
  136. package/src/driver/ExpectationActor.ts +244 -0
  137. package/src/driver/PEWDriver.ts +302 -0
  138. package/src/driver/actor-machine.ts +54 -0
  139. package/src/driver/connection-miss.ts +13 -0
  140. package/src/driver/index.ts +5 -0
  141. package/src/executor/index.ts +3 -0
  142. package/src/executor/kernel-intern.ts +121 -0
  143. package/src/executor/orchestrator.ts +463 -0
  144. package/src/executor/types.ts +3 -0
  145. package/src/index.ts +1 -0
  146. package/src/schemas/connection.ts +4 -0
  147. package/src/schemas/index.ts +1 -0
  148. package/src/shared/control.ts +4 -0
  149. package/src/shared/index.ts +6 -0
  150. package/src/shared/map-field-awareness.ts +65 -0
  151. package/src/shared/models/ConnectionSpec.ts +84 -0
  152. package/src/shared/models/DriverSpec.ts +102 -0
  153. package/src/shared/models/Job.ts +192 -0
  154. package/src/shared/models/WorkContainer.ts +90 -0
  155. package/src/shared/models/WorkExpectation.ts +229 -0
  156. package/src/shared/models/index.ts +6 -0
  157. package/src/shared/models/jsonRecord.ts +49 -0
  158. package/src/shared/revalidation-demand.ts +121 -0
  159. package/src/shared/role.ts +32 -0
  160. package/src/shared/types.ts +57 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * YAML connection inventory. Settings ride a private synced bag; the public
3
+ * face is {@link configuration} (deep-readonly get, whole-bag set).
4
+ * Secrets never live here. PEW process facts are `readyState` / `error`
5
+ * plus {@link live}. YAML file fill does not touch process facts.
6
+ */
7
+ import { PlexusModel, syncing } from "@here.build/plexus";
8
+ import { computed } from "mobx";
9
+
10
+ import type { ReadyState, SpecCatalog } from "../types.js";
11
+ import { DriverSpec } from "./DriverSpec.js";
12
+ import { readJsonRecord, writeJsonRecord, type LiveFacts } from "./jsonRecord.js";
13
+
14
+ export type ConfigurationAtom = string | number | boolean | null;
15
+ export type ConfigurationValue =
16
+ | ConfigurationAtom
17
+ | readonly ConfigurationValue[]
18
+ | { readonly [key: string]: ConfigurationValue };
19
+ export type ConnectionConfiguration = { readonly [key: string]: ConfigurationValue };
20
+
21
+ @syncing("@inhuman.tools/plexus-expectations:ConnectionSpec")
22
+ export class ConnectionSpec extends PlexusModel<SpecCatalog | null> {
23
+ get id(): string {
24
+ return (this.parentFieldKey as string | null) ?? "";
25
+ }
26
+
27
+ /** Only `failed` is a problem. */
28
+ get issue(): "failed" | false {
29
+ return this.readyState === "failed" ? "failed" : false;
30
+ }
31
+
32
+ /** YAML `driver:` resolves to this entity. */
33
+ @syncing accessor driver!: DriverSpec;
34
+ @syncing accessor name: string = "";
35
+ @syncing accessor annotation: string = "";
36
+ @syncing.record private accessor configurationMap: Record<string, string> = {};
37
+
38
+ /** PEW instance self-report. `null` = never ensured. YAML fill does not touch this. */
39
+ @syncing accessor readyState: ReadyState = null;
40
+ @syncing accessor error: string = "";
41
+ @syncing.record private accessor liveMap: Record<string, string> = {};
42
+
43
+ /** Frozen through; assign the whole bag to write. */
44
+ @computed
45
+ get configuration(): ConnectionConfiguration {
46
+ return readJsonRecord(this.configurationMap) as ConnectionConfiguration;
47
+ }
48
+
49
+ set configuration(settings: Readonly<Record<string, unknown>> | undefined) {
50
+ this.#writeConfiguration(settings);
51
+ }
52
+
53
+ @syncing.action
54
+ #writeConfiguration(settings: Readonly<Record<string, unknown>> | undefined): void {
55
+ writeJsonRecord(this.configurationMap, settings);
56
+ }
57
+
58
+ @computed
59
+ get live(): LiveFacts {
60
+ return readJsonRecord(this.liveMap) as LiveFacts;
61
+ }
62
+
63
+ set live(next: Readonly<Record<string, unknown>> | undefined) {
64
+ this.#writeLive(next);
65
+ }
66
+
67
+ @syncing.action
68
+ #writeLive(next: Readonly<Record<string, unknown>> | undefined): void {
69
+ writeJsonRecord(this.liveMap, next);
70
+ }
71
+
72
+ /** Process self-report. YAML fill does not call this. */
73
+ @syncing.action
74
+ stampProcess(next: {
75
+ readonly readyState: ReadyState;
76
+ readonly error: string;
77
+ readonly live: Readonly<Record<string, unknown>> | undefined;
78
+ }): void {
79
+ this.readyState = next.readyState;
80
+ this.error = next.error;
81
+ this.live = next.live;
82
+ }
83
+ }
84
+
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Advertised driver inventory. `can` is a plexus list — file tokens, no load required.
3
+ * PEW process facts are `readyState` / `error` / `offersJson` plus {@link live}.
4
+ * YAML file fill does not touch process facts.
5
+ */
6
+ import { PlexusModel, syncing } from "@here.build/plexus";
7
+ import { computed } from "mobx";
8
+ import invariant from "tiny-invariant";
9
+
10
+ import type { ReadyState, SpecCatalog } from "../types.js";
11
+ import { readJsonRecord, writeJsonRecord, type LiveFacts } from "./jsonRecord.js";
12
+
13
+ /** How a new connection of this driver is offered, before the class loads. */
14
+ export type DriverMultiplicity = "add-by-default" | "add-as-secondary";
15
+
16
+ @syncing("@inhuman.tools/plexus-expectations:DriverSpec")
17
+ export class DriverSpec extends PlexusModel<SpecCatalog | null> {
18
+ @syncing accessor name: string = "";
19
+ @syncing accessor description: string = "";
20
+ @syncing accessor importPath: string = "";
21
+ /** Expectation kind names this driver handles. Not tool names. */
22
+ @syncing.list accessor abilityClaim: string[] = [];
23
+ @syncing accessor multiplicity: DriverMultiplicity = "add-as-secondary";
24
+
25
+ /** Module-load self-report. `null` = never loaded. YAML fill does not touch this. */
26
+ @syncing accessor readyState: ReadyState = null;
27
+ @syncing accessor error: string = "";
28
+ /** Offer list JSON. Null = no offers. */
29
+ @syncing private accessor offersJson: string | null = null;
30
+ @syncing.record private accessor liveMap: Record<string, string> = {};
31
+
32
+ @computed
33
+ get live(): LiveFacts {
34
+ return readJsonRecord(this.liveMap) as LiveFacts;
35
+ }
36
+
37
+ set live(next: Readonly<Record<string, unknown>> | undefined) {
38
+ this.#writeLive(next);
39
+ }
40
+
41
+ @syncing.action
42
+ #writeLive(next: Readonly<Record<string, unknown>> | undefined): void {
43
+ writeJsonRecord(this.liveMap, next);
44
+ }
45
+
46
+ /**
47
+ * Module-load self-report. Omitted `offersJson` / `live` stay
48
+ * (failure must not wipe a previous load).
49
+ */
50
+ @syncing.action
51
+ stampLoad(next: {
52
+ readonly readyState: ReadyState;
53
+ readonly error: string;
54
+ readonly offersJson?: string | null;
55
+ readonly live?: Readonly<Record<string, unknown>>;
56
+ }): void {
57
+ this.readyState = next.readyState;
58
+ this.error = next.error;
59
+ if (next.offersJson !== undefined) this.offersJson = next.offersJson;
60
+ if (next.live !== undefined) this.live = next.live;
61
+ }
62
+
63
+ /**
64
+ * Class-bootstrap offers. Null, blank, and the string `"null"` are no offers.
65
+ * A non-string cell or malformed JSON throws: a silent [] hides a driver defect.
66
+ */
67
+ @computed
68
+ get offers(): readonly object[] {
69
+ return readDriverOffers(this.offersJson);
70
+ }
71
+
72
+ set offers(next: readonly object[] | null) {
73
+ this.#writeOffers(next);
74
+ }
75
+
76
+ @syncing.action
77
+ #writeOffers(next: readonly object[] | null): void {
78
+ this.offersJson = next == null ? null : JSON.stringify(next);
79
+ }
80
+ }
81
+
82
+ export function readDriverOffers(raw: unknown): object[] {
83
+ if (raw == null) return [];
84
+ invariant(typeof raw === "string", "harness/kernel/offers: offersJson must be a JSON string");
85
+ const text = raw.trim();
86
+ if (text === "" || text === "null") return [];
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(text);
90
+ } catch (error) {
91
+ throw new Error(
92
+ `harness/kernel/offers: offersJson is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
93
+ );
94
+ }
95
+ invariant(Array.isArray(parsed), "harness/kernel/offers: offersJson must be a JSON array of offer objects");
96
+ for (const item of parsed) {
97
+ invariant(typeof item === "object", "harness/kernel/offers: offersJson entries must be objects");
98
+ invariant(item !== null, "harness/kernel/offers: offersJson entries cannot be null");
99
+ invariant(!Array.isArray(item), "harness/kernel/offers: offersJson entries cannot be arrays");
100
+ }
101
+ return parsed as object[];
102
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * COLLECTORS ARE NOT `.work`. Collectors expand until `walkBoundary`.
3
+ * Job is a WorkContainer, not a WorkExpectation.
4
+ *
5
+ * `orchestrationResult` is the guard's boolean. `orchestrated` is computed
6
+ * (`!== null`). `cancelled` is the cancel fact. Do not assign `orchestrated`.
7
+ *
8
+ * Await settles on `result`, not on this object. `result` stays null until
9
+ * `endedAtMs` is set, then this class fulfills `undefined`. `cancelled` alone
10
+ * does not settle. A subclass names its value and rejection by overriding `result`.
11
+ */
12
+ import { syncing } from "@here.build/plexus";
13
+ import { computed, when } from "mobx";
14
+ import invariant from "tiny-invariant";
15
+
16
+ import { WorkContainer } from "./WorkContainer.js";
17
+
18
+ export type JobResolution<TValue, TReason> =
19
+ { readonly status: "fulfilled"; readonly value: TValue } | { readonly status: "rejected"; readonly reason: TReason };
20
+
21
+ export type JobKind = "command" | "agent" | "bot" | "workflow";
22
+
23
+ /** Runtime that owns a job's effect. */
24
+ export type JobEffectOwner = "session-runtime" | "tool-runtime" | "scheme-runtime" | "agent-calls" | "honor";
25
+
26
+ /** What a restart does with a job whose outcome it cannot see. */
27
+ export type UnknownOutcomeRecovery = "resume-queued" | "resume-inner" | "unknown-no-replay";
28
+
29
+ @syncing("@inhuman.tools/plexus-expectations:Job")
30
+ export abstract class Job<TValue = unknown, TReason = unknown> extends WorkContainer implements Promise<TValue> {
31
+ #pending: Promise<TValue> | null = null;
32
+
33
+ /** Not this instantiation — a parent holds jobs with other fulfill and reject types. */
34
+ @syncing.child.list accessor children: Job<unknown, unknown>[] = [];
35
+ @syncing accessor background: boolean = false;
36
+ @syncing accessor walkBoundary: boolean = false;
37
+ /** Guard return. `null` = phase 1 not done. */
38
+ @syncing accessor orchestrationResult: true | false | null = null;
39
+ /** Cancel fact. `false → true`. Not a deny and not a human approval. */
40
+ @syncing accessor cancelled: boolean = false;
41
+ /** This job's own work has finalized (ok or not). */
42
+ @syncing accessor completed: boolean = false;
43
+ /**
44
+ * Crash ending: the license guard or the executor body threw. Empty means
45
+ * no crash. The message is the fact; the host stamps a receipt from it and
46
+ * the job completes, so whoever joins the job is released with an outcome.
47
+ */
48
+ @syncing accessor crash: string = "";
49
+ /**
50
+ * Ordering as data: jobs that must be released before this one is claimed
51
+ * (license and body). Refs to earlier siblings, never a cycle. The
52
+ * orchestrator honours it on every pass, so it holds across a restart.
53
+ */
54
+ @syncing.list accessor after: Job<unknown, unknown>[] = [];
55
+
56
+ /**
57
+ * Seal + end clock, in one update: `result` keys on `endedAtMs`, so a
58
+ * crash between the two writes would leave a completed job unawaitable.
59
+ * Deny-without-BODY leaves `startedAtMs` null.
60
+ */
61
+ @syncing.action
62
+ markComplete(now = Date.now()): void {
63
+ if (!this.completed) this.completed = true;
64
+ this.markEnded(now);
65
+ }
66
+
67
+ /** Replace {@link after}. Call before the job becomes reachable. */
68
+ @syncing.action
69
+ stampAfter(jobs: readonly Job<unknown, unknown>[]): void {
70
+ this.after.splice(0, this.after.length, ...jobs);
71
+ }
72
+
73
+ /**
74
+ * Released for a successor: done, cancelled, or a background job that has
75
+ * started. A denied or failed predecessor is done too; order is not success.
76
+ */
77
+ get released(): boolean {
78
+ return this.completed || this.cancelled || (this.orchestrated && this.background);
79
+ }
80
+
81
+ /** Every {@link after} predecessor is released. */
82
+ @computed
83
+ get afterReleased(): boolean {
84
+ return this.after.every((job) => job.released);
85
+ }
86
+
87
+ /** Parent `child` when it is not already in `children`. Does not enqueue or wait. */
88
+ registerJob<T extends Job<unknown, unknown>>(child: T): T {
89
+ if (!this.children.includes(child)) this.children.push(child);
90
+ return child;
91
+ }
92
+
93
+ @computed
94
+ get orchestrated(): boolean {
95
+ return this.orchestrationResult !== null;
96
+ }
97
+
98
+ /** Not one call. Agent, bot, shell, and program override. */
99
+ get kind(): JobKind {
100
+ return "workflow";
101
+ }
102
+
103
+ /** Not the background flag. Call, program, and shell override. */
104
+ get isTrackedBackground(): boolean {
105
+ return false;
106
+ }
107
+
108
+ /** Not the wire name. Call, program, and shell override. */
109
+ get object(): string {
110
+ return "";
111
+ }
112
+
113
+ /**
114
+ * Effect contract for this class. Not synced. An abstract intermediate
115
+ * declares none, so every concrete kind has to state its own.
116
+ */
117
+ abstract readonly owner: JobEffectOwner;
118
+ abstract readonly intent: string;
119
+ abstract readonly checkpoint: string;
120
+ abstract readonly dispatch: string;
121
+ abstract readonly settlement: string;
122
+ abstract readonly cancel: string;
123
+ abstract readonly unknownRecovery: UnknownOutcomeRecovery;
124
+ abstract readonly unknownNote: string;
125
+ abstract readonly intentRequired: boolean;
126
+
127
+ @computed
128
+ get jobs(): Job<unknown, unknown>[] {
129
+ const out: Job<unknown, unknown>[] = [];
130
+ for (const child of this.children) {
131
+ if (!(child instanceof Job)) continue;
132
+ out.push(child);
133
+ if (!child.walkBoundary) out.push(...child.jobs);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ @computed
139
+ get foregroundJobs(): Job<unknown, unknown>[] {
140
+ return this.jobs.filter((node) => !node.background);
141
+ }
142
+
143
+ @computed
144
+ get backgroundJobs(): Job<unknown, unknown>[] {
145
+ return this.jobs.filter((node) => node.background);
146
+ }
147
+
148
+ @computed
149
+ get result(): JobResolution<TValue, TReason> | null {
150
+ if (this.endedAtMs == null) return null;
151
+ return { status: "fulfilled", value: undefined as TValue };
152
+ }
153
+
154
+ @computed
155
+ get work(): readonly WorkContainer[] {
156
+ return this.children;
157
+ }
158
+
159
+ then<TFulfilled = TValue, TRejected = never>(
160
+ onfulfilled?: ((value: TValue) => TFulfilled | PromiseLike<TFulfilled>) | null,
161
+ onrejected?: ((reason: TReason) => TRejected | PromiseLike<TRejected>) | null,
162
+ ): Promise<TFulfilled | TRejected> {
163
+ return this.#settled().then(onfulfilled, onrejected);
164
+ }
165
+
166
+ catch<TRejected = never>(
167
+ onrejected?: ((reason: TReason) => TRejected | PromiseLike<TRejected>) | null,
168
+ ): Promise<TValue | TRejected> {
169
+ return this.#settled().catch(onrejected);
170
+ }
171
+
172
+ finally(onfinally?: (() => void) | null): Promise<TValue> {
173
+ return this.#settled().finally(onfinally);
174
+ }
175
+
176
+ get [Symbol.toStringTag](): string {
177
+ return "Job";
178
+ }
179
+
180
+ #settled(): Promise<TValue> {
181
+ if (this.#pending != null) return this.#pending;
182
+ const deliver = (): TValue => {
183
+ const resolution = this.result;
184
+ invariant(resolution != null, "Job settled without result");
185
+ if (resolution.status === "rejected") throw resolution.reason;
186
+ return resolution.value;
187
+ };
188
+ this.#pending =
189
+ this.result != null ? Promise.resolve().then(deliver) : when(() => this.result != null).then(deliver);
190
+ return this.#pending;
191
+ }
192
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Work graph node. `work` may be a synced list or a computed getter.
3
+ * Walking `.work` from the document root is the whole out-reachable set.
4
+ *
5
+ * Brand is on this class — do not duck-type `.work`.
6
+ *
7
+ * Thenable. The fulfillment is not `this` — a thenable that resolves to itself
8
+ * never settles. This class settles when `endedAtMs` is set, fulfills
9
+ * `undefined`, and does not reject. {@link WorkExpectation} fulfills its
10
+ * outcome. {@link Job} fulfills or rejects from `result`. The promise value
11
+ * here is `unknown` so both stay containers.
12
+ */
13
+ import { PlexusModel, syncing } from "@here.build/plexus";
14
+ import { when } from "mobx";
15
+
16
+ export const workContainer: unique symbol = Symbol.for("work:container") as never;
17
+
18
+ /** Base promise value. An expectation narrows it to the outcome; a job narrows it per class. */
19
+ export type WorkFulfillment = unknown;
20
+
21
+ @syncing("@inhuman.tools/plexus-expectations:WorkContainer")
22
+ export abstract class WorkContainer extends PlexusModel implements Promise<WorkFulfillment> {
23
+ readonly [workContainer] = true as const;
24
+ #settledPromise: Promise<WorkFulfillment> | null = null;
25
+
26
+ /** Epoch ms. Write-once. BODY / actor start — not mint, not the guard. */
27
+ @syncing accessor startedAtMs: number | null = null;
28
+ /** Epoch ms. Write-once. Job complete / PEW succeed|fail. This class's thenable settles on this. */
29
+ @syncing accessor endedAtMs: number | null = null;
30
+
31
+ abstract readonly work: readonly WorkContainer[];
32
+
33
+ markStarted(now = Date.now()): void {
34
+ if (this.startedAtMs != null) return;
35
+ this.startedAtMs = now;
36
+ }
37
+
38
+ markEnded(now = Date.now()): void {
39
+ if (this.endedAtMs != null) return;
40
+ this.endedAtMs = now;
41
+ }
42
+
43
+ /** Live: `now - startedAtMs`. Sealed: `endedAtMs - startedAtMs`. Null if start was never stamped. */
44
+ durationMs(now = Date.now()): number | null {
45
+ if (this.startedAtMs == null || !Number.isFinite(this.startedAtMs)) return null;
46
+ const end =
47
+ this.endedAtMs != null && Number.isFinite(this.endedAtMs) ? this.endedAtMs : now;
48
+ return Math.max(0, end - this.startedAtMs);
49
+ }
50
+
51
+ then<TFulfilled = WorkFulfillment, TRejected = never>(
52
+ onfulfilled?: ((value: WorkFulfillment) => TFulfilled | PromiseLike<TFulfilled>) | null,
53
+ onrejected?: ((reason: unknown) => TRejected | PromiseLike<TRejected>) | null,
54
+ ): Promise<TFulfilled | TRejected> {
55
+ return this.#settled().then(onfulfilled, onrejected);
56
+ }
57
+
58
+ catch<TRejected = never>(
59
+ onrejected?: ((reason: unknown) => TRejected | PromiseLike<TRejected>) | null,
60
+ ): Promise<WorkFulfillment | TRejected> {
61
+ return this.#settled().catch(onrejected);
62
+ }
63
+
64
+ finally(onfinally?: (() => void) | null): Promise<WorkFulfillment> {
65
+ return this.#settled().finally(onfinally);
66
+ }
67
+
68
+ get [Symbol.toStringTag](): string {
69
+ return "WorkContainer";
70
+ }
71
+
72
+ #settled(): Promise<WorkFulfillment> {
73
+ if (this.#settledPromise != null) return this.#settledPromise;
74
+ const fulfill = (): WorkFulfillment => undefined;
75
+ this.#settledPromise =
76
+ this.endedAtMs != null ? Promise.resolve(fulfill()) : when(() => this.endedAtMs != null).then(fulfill);
77
+ return this.#settledPromise;
78
+ }
79
+ }
80
+
81
+ /** Preorder walk of `.work`, cycle-safe. `item === container` stops a leaf `[this]` from recursing. */
82
+ export function* walkWork(container: WorkContainer, seen: Set<WorkContainer> = new Set()): Generator<WorkContainer> {
83
+ for (const item of container.work) {
84
+ if (seen.has(item)) continue;
85
+ seen.add(item);
86
+ yield item;
87
+ if (item === container) continue;
88
+ yield* walkWork(item, seen);
89
+ }
90
+ }
@@ -0,0 +1,229 @@
1
+ import { type PlexusAwareness, syncing } from "@here.build/plexus";
2
+ import { docPlexus } from "@here.build/plexus/internals";
3
+ import { comparer, computed, observable, reaction, when } from "mobx";
4
+ import invariant from "tiny-invariant";
5
+
6
+ import { type EndCause, type ExpectationOutcome } from "../control.js";
7
+ import { roleOf } from "../role.js";
8
+ import type { SessionAwareness, SessionPlexus } from "../types.js";
9
+ import { Job } from "./Job.js";
10
+ import { WorkContainer } from "./WorkContainer.js";
11
+
12
+ /**
13
+ * Awareness: empty string occupies the role map without a report frame.
14
+ * Retract drops the uuid. Not a durable outcome.
15
+ */
16
+ export const ROLE_LANE_OCCUPIED = "";
17
+
18
+ @syncing("@inhuman.tools/plexus-expectations:WorkExpectation")
19
+ export abstract class WorkExpectation<TResult = unknown, TReport = unknown>
20
+ extends WorkContainer
21
+ implements Promise<ExpectationOutcome>
22
+ {
23
+ /**
24
+ * Kernel catalog key this work rides. Required at construction.
25
+ * Plexus materialization passes a symbol through unchanged.
26
+ */
27
+ @syncing
28
+ accessor connectionId!: string;
29
+
30
+ constructor(init: { readonly connectionId: string } | symbol) {
31
+ if (typeof init !== "symbol") {
32
+ invariant(
33
+ typeof init.connectionId === "string" && init.connectionId !== "",
34
+ "WorkExpectation.connectionId is required",
35
+ );
36
+ }
37
+ super(init);
38
+ }
39
+
40
+ @syncing
41
+ accessor outcome: ExpectationOutcome | null = null;
42
+
43
+ @syncing
44
+ accessor endCause: EndCause | null = null;
45
+
46
+ @syncing
47
+ accessor endDetail: string | null = null;
48
+
49
+ /** Durable last frame as JSON. `"null"` when the work ended without a report. */
50
+ @syncing
51
+ accessor outcomeReport: string | null = null;
52
+
53
+ /** Last live role-map frame this process saw. Retract does not clear it. */
54
+ @observable.ref private accessor lastProgress: TReport | null = null;
55
+ #progressCacheStarted = false;
56
+ #settledPromise: Promise<ExpectationOutcome> | null = null;
57
+
58
+ get plexus(): SessionPlexus | null {
59
+ const doc = this.__doc__;
60
+ if (!doc) return null;
61
+ const plexus = docPlexus.get(doc);
62
+ if (!plexus) return null;
63
+ return plexus as SessionPlexus;
64
+ }
65
+
66
+ get awareness(): PlexusAwareness<SessionAwareness> | null {
67
+ const hub = this.plexus?.awareness ?? null;
68
+ if (hub != null) this.#ensureProgressCache();
69
+ return hub;
70
+ }
71
+
72
+ // MobX: arm on the first `awareness` read so a later untracked read still
73
+ // sees the last frame. Dispose on done — a sealed lane is frozen; do not
74
+ // keep a reaction per expectation for the life of the document.
75
+ #ensureProgressCache(): void {
76
+ if (this.#progressCacheStarted) return;
77
+ this.#progressCacheStarted = true;
78
+ reaction(
79
+ () => ({ done: this.isDone, frame: this.#liveProgressFrame() }),
80
+ ({ done, frame }, _previous, self) => {
81
+ if (frame !== null) this.lastProgress = frame;
82
+ if (done) self.dispose();
83
+ },
84
+ { fireImmediately: true, equals: comparer.structural },
85
+ );
86
+ }
87
+
88
+ #liveProgressFrame(): TReport | null {
89
+ const hub = this.awareness;
90
+ if (hub == null) return null;
91
+ const frame = roleOf(hub, this.uuid);
92
+ if (frame == null || frame === ROLE_LANE_OCCUPIED) return null;
93
+ return frame as TReport;
94
+ }
95
+
96
+ /** Live progress as the fulfiller tells it. Awareness retract does not drop the last frame. */
97
+ @computed
98
+ get progressAwareness(): TReport | null {
99
+ return this.#liveProgressFrame() ?? this.lastProgress;
100
+ }
101
+
102
+ /** Occupancy on the role map. Not a durable outcome. */
103
+ @computed
104
+ get workerPresent(): boolean {
105
+ const hub = this.awareness;
106
+ if (hub == null) return false;
107
+ const value = roleOf(hub, this.uuid);
108
+ return value !== undefined && value !== null;
109
+ }
110
+
111
+ get isDone(): boolean {
112
+ return this.outcome != null;
113
+ }
114
+
115
+ /** Leaf. PEW does not parent PEW. */
116
+ get work(): readonly WorkExpectation[] {
117
+ return [this];
118
+ }
119
+
120
+ /**
121
+ * Settles when {@link isDone}. Fulfills with {@link outcome} for both
122
+ * `successful` and `failed` — failure is an outcome, not a rejection.
123
+ * The fulfillment cannot be `this`: a thenable that resolves to itself
124
+ * never settles (Promise unwraps `then` forever).
125
+ */
126
+ then<TFulfilled = ExpectationOutcome, TRejected = never>(
127
+ onfulfilled?: ((value: ExpectationOutcome) => TFulfilled | PromiseLike<TFulfilled>) | null,
128
+ onrejected?: ((reason: unknown) => TRejected | PromiseLike<TRejected>) | null,
129
+ ): Promise<TFulfilled | TRejected> {
130
+ return this.#settled().then(onfulfilled, onrejected);
131
+ }
132
+
133
+ catch<TRejected = never>(
134
+ onrejected?: ((reason: unknown) => TRejected | PromiseLike<TRejected>) | null,
135
+ ): Promise<ExpectationOutcome | TRejected> {
136
+ return this.#settled().catch(onrejected);
137
+ }
138
+
139
+ finally(onfinally?: (() => void) | null): Promise<ExpectationOutcome> {
140
+ return this.#settled().finally(onfinally);
141
+ }
142
+
143
+ get [Symbol.toStringTag](): string {
144
+ return "WorkExpectation";
145
+ }
146
+
147
+ #settled(): Promise<ExpectationOutcome> {
148
+ if (this.#settledPromise != null) return this.#settledPromise;
149
+ this.#settledPromise = this.isDone
150
+ ? Promise.resolve(this.#sealedOutcome())
151
+ : when(() => this.isDone).then(() => this.#sealedOutcome());
152
+ return this.#settledPromise;
153
+ }
154
+
155
+ #sealedOutcome(): ExpectationOutcome {
156
+ invariant(this.outcome != null, "WorkExpectation settled without outcome");
157
+ return this.outcome;
158
+ }
159
+
160
+ applySettlement(_result: TResult): void {}
161
+
162
+ #parseOutcomeReport(): TReport | null {
163
+ if (this.outcomeReport == null) return null;
164
+ try {
165
+ return JSON.parse(this.outcomeReport) as TReport | null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Durable outcome frame if written, else last progress seen.
173
+ * {@link ROLE_LANE_OCCUPIED} is not a frame. Retract keeps the last frame.
174
+ */
175
+ @computed
176
+ get report(): TReport | null {
177
+ if (this.outcomeReport != null) return this.#parseOutcomeReport();
178
+ return this.progressAwareness;
179
+ }
180
+
181
+ @syncing.action
182
+ succeed(cause: EndCause, detail: string, outcomeReport: string, settlement?: { readonly result: TResult }): boolean {
183
+ if (this.isDone) return false;
184
+ if (settlement !== undefined) {
185
+ try {
186
+ this.applySettlement(settlement.result);
187
+ } catch (error) {
188
+ this.outcome = "failed";
189
+ this.endCause = cause;
190
+ this.endDetail = `apply_error: ${String(error)}`;
191
+ this.outcomeReport = outcomeReport;
192
+ this.markEnded();
193
+ return true;
194
+ }
195
+ }
196
+ this.outcome = "successful";
197
+ this.endCause = cause;
198
+ this.endDetail = detail;
199
+ this.outcomeReport = outcomeReport;
200
+ this.markEnded();
201
+ return true;
202
+ }
203
+
204
+ @syncing.action
205
+ fail(cause: EndCause, detail: string, outcomeReport: string): boolean {
206
+ if (this.isDone) return false;
207
+ this.outcome = "failed";
208
+ this.endCause = cause;
209
+ this.endDetail = detail;
210
+ this.outcomeReport = outcomeReport;
211
+ this.markEnded();
212
+ return true;
213
+ }
214
+ }
215
+
216
+ /** Open work whose parent sealed — honor does not start these. */
217
+ export function isTreeOrphan(node: WorkExpectation): boolean {
218
+ if (node.isDone) return false;
219
+ const parent = node.parent;
220
+ if (parent === null) return false;
221
+ if (parent instanceof WorkExpectation) return parent.isDone;
222
+ if (parent instanceof Job) return parent.completed;
223
+ return false;
224
+ }
225
+
226
+ /** Admits every WorkExpectation instantiation. */
227
+ export type AnyWorkExpectation = WorkExpectation<any, any>;
228
+ export type ResultOf<E extends AnyWorkExpectation> = Parameters<E["applySettlement"]>[0];
229
+ export type ReportOf<E extends AnyWorkExpectation> = Exclude<E["report"], null>;