@wringer/core 0.0.0 → 0.1.1

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 (258) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +71 -2
  3. package/dist/answer-supervisor-connection.d.ts +8 -0
  4. package/dist/app-machine.d.ts +76 -0
  5. package/dist/append-run-event.d.ts +16 -0
  6. package/dist/bind-drivers.d.ts +9 -0
  7. package/dist/build-commands.d.ts +5 -0
  8. package/dist/build-envelope-json-schema.d.ts +1 -0
  9. package/dist/build-envelope.d.ts +16 -0
  10. package/dist/build-launch-guard.d.ts +1 -0
  11. package/dist/build-manifest-write.d.ts +6 -0
  12. package/dist/build-promised-object-reads.d.ts +7 -0
  13. package/dist/build-report.d.ts +10 -0
  14. package/dist/build-run-handle.d.ts +2 -0
  15. package/dist/build-run-view.d.ts +29 -0
  16. package/dist/build-service-key.d.ts +1 -0
  17. package/dist/build-shut-down-steps.d.ts +14 -0
  18. package/dist/build-skip-worktree-marks.d.ts +7 -0
  19. package/dist/build-spawn-error.d.ts +2 -0
  20. package/dist/build-state-paths.d.ts +21 -0
  21. package/dist/build-submodule-adds.d.ts +7 -0
  22. package/dist/builtins/build-doctor-command.d.ts +2 -0
  23. package/dist/builtins/build-exec-command.d.ts +2 -0
  24. package/dist/builtins/build-lifecycle-commands.d.ts +3 -0
  25. package/dist/builtins/build-state-commands.d.ts +2 -0
  26. package/dist/builtins/build-use-pointers.d.ts +9 -0
  27. package/dist/builtins/build-verdict-commands.d.ts +2 -0
  28. package/dist/builtins/call-run-supervisor.d.ts +5 -0
  29. package/dist/builtins/pick-doctor-outcome.d.ts +13 -0
  30. package/dist/builtins/pick-start-action.d.ts +10 -0
  31. package/dist/builtins/pick-stop-plan.d.ts +14 -0
  32. package/dist/builtins/start-run.d.ts +22 -0
  33. package/dist/builtins/stop-data-schema.d.ts +6 -0
  34. package/dist/builtins/stop-run.d.ts +3 -0
  35. package/dist/call-supervisor.d.ts +10 -0
  36. package/dist/cap-data.d.ts +17 -0
  37. package/dist/collect-driver-configs.d.ts +7 -0
  38. package/dist/create-directory.d.ts +1 -0
  39. package/dist/create-evidence-writer.d.ts +13 -0
  40. package/dist/create-file-state.d.ts +36 -0
  41. package/dist/create-log-writer.d.ts +5 -0
  42. package/dist/create-node-repo-host.d.ts +2 -0
  43. package/dist/create-node-state-file-system.d.ts +10 -0
  44. package/dist/create-repo-state.d.ts +2 -0
  45. package/dist/create-socket-folder.d.ts +1 -0
  46. package/dist/create-wringer.d.ts +17 -0
  47. package/dist/decide-guard.d.ts +12 -0
  48. package/dist/define-driver.d.ts +13 -0
  49. package/dist/dispatch.d.ts +10 -0
  50. package/dist/envelope.d.ts +63 -0
  51. package/dist/error-codes.d.ts +25 -0
  52. package/dist/find-cancel-failure.d.ts +2 -0
  53. package/dist/find-colliding-service-key.d.ts +5 -0
  54. package/dist/find-own-value.d.ts +1 -0
  55. package/dist/find-remote-stop-failure.d.ts +2 -0
  56. package/dist/find-verdict-refusal.d.ts +3 -0
  57. package/dist/follow-process.d.ts +10 -0
  58. package/dist/get-lock-value.d.ts +2 -0
  59. package/dist/get-target.d.ts +2 -0
  60. package/dist/host.d.ts +19 -0
  61. package/dist/index.d.ts +58 -0
  62. package/dist/is-error-code.d.ts +1 -0
  63. package/dist/keep-cancelled-call.d.ts +1 -0
  64. package/dist/kill-process-group.d.ts +1 -0
  65. package/dist/launch-line-schema.d.ts +3 -0
  66. package/dist/listen-supervisor-socket.d.ts +8 -0
  67. package/dist/local.d.ts +2 -0
  68. package/dist/make-fence.d.ts +8 -0
  69. package/dist/make-fenced-exec.d.ts +10 -0
  70. package/dist/make-process-supervisor-port.d.ts +7 -0
  71. package/dist/open-drive.d.ts +23 -0
  72. package/dist/open-runless-drive.d.ts +17 -0
  73. package/dist/parse-launch-line.d.ts +2 -0
  74. package/dist/parse-patch-id.d.ts +2 -0
  75. package/dist/parse-report-block.d.ts +9 -0
  76. package/dist/parse-run-log.d.ts +2 -0
  77. package/dist/pick-access-refusal.d.ts +12 -0
  78. package/dist/pick-command-needs.d.ts +15 -0
  79. package/dist/pick-command-plan.d.ts +23 -0
  80. package/dist/pick-context-target.d.ts +7 -0
  81. package/dist/pick-drive-kind.d.ts +8 -0
  82. package/dist/pick-envelope-context.d.ts +1 -0
  83. package/dist/pick-envelope-payload.d.ts +12 -0
  84. package/dist/pick-envelope-run.d.ts +6 -0
  85. package/dist/pick-envelope-target.d.ts +4 -0
  86. package/dist/pick-late-failure-write.d.ts +13 -0
  87. package/dist/pick-log-refusal.d.ts +4 -0
  88. package/dist/pick-merge-base-failure.d.ts +2 -0
  89. package/dist/pick-merge-base-steps.d.ts +13 -0
  90. package/dist/pick-pending-cleanups.d.ts +10 -0
  91. package/dist/pick-submodule-refusal.d.ts +2 -0
  92. package/dist/pick-supervisor-operation.d.ts +16 -0
  93. package/dist/prepare-args.d.ts +3 -0
  94. package/dist/probe-process.d.ts +1 -0
  95. package/dist/quote-shell-word.d.ts +1 -0
  96. package/dist/race-deadline.d.ts +1 -0
  97. package/dist/read-changed-paths.d.ts +7 -0
  98. package/dist/read-default-base.d.ts +2 -0
  99. package/dist/read-fingerprint.d.ts +13 -0
  100. package/dist/read-head-sha.d.ts +2 -0
  101. package/dist/read-launch-host.d.ts +26 -0
  102. package/dist/read-pid-namespace.d.ts +1 -0
  103. package/dist/read-stream-line-batches.d.ts +2 -0
  104. package/dist/read-stream-lines.d.ts +2 -0
  105. package/dist/read-text.d.ts +1 -0
  106. package/dist/read-verdicts.d.ts +5 -0
  107. package/dist/remove-directory.d.ts +1 -0
  108. package/dist/remove-file.d.ts +1 -0
  109. package/dist/render-report-markdown.d.ts +4 -0
  110. package/dist/report-schema.d.ts +51 -0
  111. package/dist/run-events.d.ts +129 -0
  112. package/dist/run-git.d.ts +9 -0
  113. package/dist/run-process.d.ts +19 -0
  114. package/dist/run-supervisor-process.d.ts +2 -0
  115. package/dist/run-supervisor.d.ts +16 -0
  116. package/dist/settle-dispatch-answer.d.ts +9 -0
  117. package/dist/settle-envelope.d.ts +16 -0
  118. package/dist/ssh.d.ts +12 -0
  119. package/dist/start-launch.d.ts +20 -0
  120. package/dist/start-services.d.ts +16 -0
  121. package/dist/supervisor-machine.d.ts +78 -0
  122. package/dist/supervisor-protocol.d.ts +43 -0
  123. package/dist/to-wringer-error.d.ts +2 -0
  124. package/dist/types.d.ts +353 -0
  125. package/dist/wait-for-cancelled-calls.d.ts +2 -0
  126. package/dist/wringer-error.d.ts +12 -0
  127. package/dist/write-envelope-log.d.ts +11 -0
  128. package/dist/write-late-failure.d.ts +11 -0
  129. package/dist/write-new-file.d.ts +1 -0
  130. package/envelope.schema.json +175 -0
  131. package/package.json +21 -5
  132. package/src/answer-supervisor-connection.ts +117 -0
  133. package/src/app-machine.ts +174 -0
  134. package/src/append-run-event.ts +131 -0
  135. package/src/bind-drivers.ts +33 -0
  136. package/src/build-commands.ts +104 -0
  137. package/src/build-envelope-json-schema.ts +34 -0
  138. package/src/build-envelope.ts +40 -0
  139. package/src/build-launch-guard.ts +5 -0
  140. package/src/build-manifest-write.ts +26 -0
  141. package/src/build-promised-object-reads.ts +48 -0
  142. package/src/build-report.ts +55 -0
  143. package/src/build-run-handle.ts +13 -0
  144. package/src/build-run-view.ts +120 -0
  145. package/src/build-service-key.ts +4 -0
  146. package/src/build-shut-down-steps.ts +54 -0
  147. package/src/build-skip-worktree-marks.ts +36 -0
  148. package/src/build-spawn-error.ts +12 -0
  149. package/src/build-state-paths.ts +53 -0
  150. package/src/build-submodule-adds.ts +64 -0
  151. package/src/builtins/build-doctor-command.ts +163 -0
  152. package/src/builtins/build-exec-command.ts +41 -0
  153. package/src/builtins/build-lifecycle-commands.ts +115 -0
  154. package/src/builtins/build-state-commands.ts +152 -0
  155. package/src/builtins/build-use-pointers.ts +26 -0
  156. package/src/builtins/build-verdict-commands.ts +265 -0
  157. package/src/builtins/call-run-supervisor.ts +40 -0
  158. package/src/builtins/pick-doctor-outcome.ts +32 -0
  159. package/src/builtins/pick-start-action.ts +35 -0
  160. package/src/builtins/pick-stop-plan.ts +32 -0
  161. package/src/builtins/start-run.ts +281 -0
  162. package/src/builtins/stop-data-schema.ts +8 -0
  163. package/src/builtins/stop-run.ts +49 -0
  164. package/src/call-supervisor.ts +109 -0
  165. package/src/cap-data.ts +43 -0
  166. package/src/collect-driver-configs.ts +36 -0
  167. package/src/create-directory.ts +6 -0
  168. package/src/create-evidence-writer.ts +82 -0
  169. package/src/create-file-state.ts +193 -0
  170. package/src/create-log-writer.ts +38 -0
  171. package/src/create-node-repo-host.ts +20 -0
  172. package/src/create-node-state-file-system.ts +52 -0
  173. package/src/create-repo-state.ts +15 -0
  174. package/src/create-socket-folder.ts +24 -0
  175. package/src/create-wringer.ts +169 -0
  176. package/src/decide-guard.ts +47 -0
  177. package/src/define-driver.ts +165 -0
  178. package/src/dispatch.ts +589 -0
  179. package/src/envelope.ts +57 -0
  180. package/src/error-codes.ts +39 -0
  181. package/src/find-cancel-failure.ts +7 -0
  182. package/src/find-colliding-service-key.ts +16 -0
  183. package/src/find-own-value.ts +8 -0
  184. package/src/find-remote-stop-failure.ts +19 -0
  185. package/src/find-verdict-refusal.ts +26 -0
  186. package/src/follow-process.ts +147 -0
  187. package/src/get-lock-value.ts +17 -0
  188. package/src/get-target.ts +15 -0
  189. package/src/host.ts +26 -0
  190. package/src/index.ts +73 -0
  191. package/src/is-error-code.ts +3 -0
  192. package/src/keep-cancelled-call.ts +21 -0
  193. package/src/kill-process-group.ts +13 -0
  194. package/src/launch-line-schema.ts +6 -0
  195. package/src/listen-supervisor-socket.ts +75 -0
  196. package/src/local.ts +20 -0
  197. package/src/make-fence.ts +27 -0
  198. package/src/make-fenced-exec.ts +52 -0
  199. package/src/make-process-supervisor-port.ts +65 -0
  200. package/src/open-drive.ts +151 -0
  201. package/src/open-runless-drive.ts +71 -0
  202. package/src/parse-launch-line.ts +19 -0
  203. package/src/parse-patch-id.ts +8 -0
  204. package/src/parse-report-block.ts +39 -0
  205. package/src/parse-run-log.ts +34 -0
  206. package/src/pick-access-refusal.ts +32 -0
  207. package/src/pick-command-needs.ts +29 -0
  208. package/src/pick-command-plan.ts +51 -0
  209. package/src/pick-context-target.ts +22 -0
  210. package/src/pick-drive-kind.ts +23 -0
  211. package/src/pick-envelope-context.ts +10 -0
  212. package/src/pick-envelope-payload.ts +28 -0
  213. package/src/pick-envelope-run.ts +25 -0
  214. package/src/pick-envelope-target.ts +10 -0
  215. package/src/pick-late-failure-write.ts +44 -0
  216. package/src/pick-log-refusal.ts +18 -0
  217. package/src/pick-merge-base-failure.ts +15 -0
  218. package/src/pick-merge-base-steps.ts +37 -0
  219. package/src/pick-pending-cleanups.ts +23 -0
  220. package/src/pick-submodule-refusal.ts +46 -0
  221. package/src/pick-supervisor-operation.ts +52 -0
  222. package/src/prepare-args.ts +22 -0
  223. package/src/probe-process.ts +16 -0
  224. package/src/quote-shell-word.ts +9 -0
  225. package/src/race-deadline.ts +23 -0
  226. package/src/read-changed-paths.ts +39 -0
  227. package/src/read-default-base.ts +21 -0
  228. package/src/read-fingerprint.ts +271 -0
  229. package/src/read-head-sha.ts +23 -0
  230. package/src/read-launch-host.ts +64 -0
  231. package/src/read-pid-namespace.ts +18 -0
  232. package/src/read-stream-line-batches.ts +28 -0
  233. package/src/read-stream-lines.ts +11 -0
  234. package/src/read-text.ts +5 -0
  235. package/src/read-verdicts.ts +57 -0
  236. package/src/remove-directory.ts +6 -0
  237. package/src/remove-file.ts +6 -0
  238. package/src/render-report-markdown.ts +53 -0
  239. package/src/report-schema.ts +37 -0
  240. package/src/run-events.ts +71 -0
  241. package/src/run-git.ts +49 -0
  242. package/src/run-process.ts +168 -0
  243. package/src/run-supervisor-process.ts +51 -0
  244. package/src/run-supervisor.ts +327 -0
  245. package/src/settle-dispatch-answer.ts +26 -0
  246. package/src/settle-envelope.ts +34 -0
  247. package/src/ssh.ts +211 -0
  248. package/src/start-launch.ts +327 -0
  249. package/src/start-services.ts +108 -0
  250. package/src/supervisor-machine.ts +169 -0
  251. package/src/supervisor-protocol.ts +42 -0
  252. package/src/to-wringer-error.ts +20 -0
  253. package/src/types.ts +476 -0
  254. package/src/wait-for-cancelled-calls.ts +37 -0
  255. package/src/wringer-error.ts +25 -0
  256. package/src/write-envelope-log.ts +72 -0
  257. package/src/write-late-failure.ts +40 -0
  258. package/src/write-new-file.ts +17 -0
@@ -0,0 +1,589 @@
1
+ import { join } from 'node:path';
2
+ import { match } from 'ts-pattern';
3
+ import { buildEnvelope } from './build-envelope.ts';
4
+ import type { RunView } from './build-run-view.ts';
5
+ import { buildRunPaths } from './build-state-paths.ts';
6
+ import { capData } from './cap-data.ts';
7
+ import type { StatePort } from './create-file-state.ts';
8
+ import { envelopeVersion } from './envelope-version.ts';
9
+ import type { Envelope, Evidence } from './envelope.ts';
10
+ import type { ExitCode } from './error-codes.ts';
11
+ import { findOwnValue } from './find-own-value.ts';
12
+ import { getTarget } from './get-target.ts';
13
+ import type { Access } from './host.ts';
14
+ import { keepCancelledCall } from './keep-cancelled-call.ts';
15
+ import { defaultRefusalHint } from './make-fence.ts';
16
+ import { openDrive, unassignedFeature } from './open-drive.ts';
17
+ import { openRunlessDrive } from './open-runless-drive.ts';
18
+ import { pickAccessRefusal } from './pick-access-refusal.ts';
19
+ import { pickCommandNeeds } from './pick-command-needs.ts';
20
+ import { pickCommandPlan } from './pick-command-plan.ts';
21
+ import { pickContextTarget } from './pick-context-target.ts';
22
+ import type { DriveKind } from './pick-drive-kind.ts';
23
+ import { pickEnvelopeContext } from './pick-envelope-context.ts';
24
+ import { pickEnvelopePayload } from './pick-envelope-payload.ts';
25
+ import { pickEnvelopeRun } from './pick-envelope-run.ts';
26
+ import { pickEnvelopeTarget } from './pick-envelope-target.ts';
27
+ import { pickLogRefusal } from './pick-log-refusal.ts';
28
+ import { raceDeadline } from './race-deadline.ts';
29
+ import { readDefaultBase } from './read-default-base.ts';
30
+ import { readFingerprint } from './read-fingerprint.ts';
31
+ import { readHeadSHA } from './read-head-sha.ts';
32
+ import type { RunID } from './run-events.ts';
33
+ import { settleDispatchAnswer } from './settle-dispatch-answer.ts';
34
+ import { settleEnvelope } from './settle-envelope.ts';
35
+ import { toWringerError } from './to-wringer-error.ts';
36
+ import type {
37
+ ArgsResult,
38
+ BuiltinContext,
39
+ Deadline,
40
+ Drive,
41
+ Effect,
42
+ Emit,
43
+ RegisteredCommand,
44
+ Session,
45
+ TargetDefinition,
46
+ Wringer,
47
+ } from './types.ts';
48
+ import { waitForCancelledCalls } from './wait-for-cancelled-calls.ts';
49
+ import { WringerError } from './wringer-error.ts';
50
+ import { writeEnvelopeLog } from './write-envelope-log.ts';
51
+ import { writeLateFailure } from './write-late-failure.ts';
52
+
53
+ export interface DispatchResult {
54
+ readonly envelope: Envelope;
55
+ readonly exitCode: ExitCode;
56
+
57
+ // Settles once a command that timed out has finished its own work, with any failure of that work
58
+ // in the run's log.
59
+ readonly late: Promise<void>;
60
+ }
61
+
62
+ export const defaultTimeoutMs = 120_000;
63
+
64
+ interface CommandOutcome {
65
+ readonly data: unknown;
66
+ }
67
+
68
+ // What a command has done so far, filled in as it runs, so a command that fails or misses its
69
+ // deadline still reports the evidence it kept and the run and feature it drove.
70
+ interface DriveTrace {
71
+ readonly evidence: (readonly Evidence[])[];
72
+ drive: boolean;
73
+ ranOnRun: boolean;
74
+ boundRun: RunID | null;
75
+ readonly late: Promise<void>[];
76
+
77
+ // Each service call to the run's supervisor that was still running at the deadline.
78
+ readonly calls: Promise<unknown>[];
79
+
80
+ // Set once the command's outcome is settled, so the work's later events never follow the answer.
81
+ answered: boolean;
82
+ }
83
+
84
+ // Every command, built-in, driver, repo or action, ends here and yields exactly one envelope.
85
+ // Dispatch is the one place that catches: a thrown error of any kind becomes the envelope's error.
86
+ export async function dispatch(
87
+ wringer: Wringer,
88
+ path: readonly string[],
89
+ rawArgs: unknown,
90
+ session: Session,
91
+ ): Promise<DispatchResult> {
92
+ const startedMs = performance.now();
93
+ const now = session.now ?? (() => new Date());
94
+ const stateDir = join(session.root, wringer.stateDir);
95
+ const state = session.state;
96
+
97
+ const read = await readContext(wringer, path, state, session).catch((error: unknown) =>
98
+ toWringerError(error),
99
+ );
100
+
101
+ const before = read instanceof WringerError ? null : read;
102
+ let outcome: CommandOutcome | null = null;
103
+ let failure: WringerError | null = null;
104
+ let pending: Promise<CommandOutcome> | null = null;
105
+
106
+ const trace: DriveTrace = {
107
+ evidence: [],
108
+ drive: false,
109
+ ranOnRun: false,
110
+ boundRun: null,
111
+ late: [],
112
+ calls: [],
113
+ answered: false,
114
+ };
115
+
116
+ try {
117
+ if (read instanceof WringerError) {
118
+ throw read;
119
+ }
120
+
121
+ const context = read;
122
+ const command = wringer.commands.get(path.join(' '));
123
+
124
+ if (command === undefined) {
125
+ throw new WringerError('usage', { message: `There is no command \`${path.join(' ')}\`.` });
126
+ }
127
+
128
+ const timeoutMs = session.timeoutMs ?? command.info.timeoutMs ?? defaultTimeoutMs;
129
+ const deadline = AbortSignal.timeout(timeoutMs);
130
+
131
+ const work = runCommand({
132
+ wringer,
133
+ state,
134
+ session,
135
+ stateDir,
136
+ command,
137
+ rawArgs,
138
+ context,
139
+ now,
140
+ deadline,
141
+ deadlineAt: now().getTime() + timeoutMs,
142
+ trace,
143
+ });
144
+
145
+ pending = work;
146
+
147
+ outcome = await raceDeadline(work, deadline);
148
+
149
+ pending = null;
150
+ } catch (error) {
151
+ failure = toWringerError(error);
152
+
153
+ // Only a timeout leaves the work running; any other failure is the work's own, and it is over.
154
+ if (failure.code !== 'timeout') {
155
+ pending = null;
156
+ }
157
+ }
158
+
159
+ // A timeout never reaches the caller while a cancelled call's work can still land, and a cancel
160
+ // that failed answers in the timeout's place.
161
+ if (failure?.code === 'timeout') {
162
+ const cancelFailure = await waitForCancelledCalls(trace.calls);
163
+
164
+ failure = cancelFailure ?? failure;
165
+ }
166
+
167
+ trace.answered = true;
168
+
169
+ const after = await readContext(wringer, path, state, session).catch(() => before);
170
+ const bound = await readBoundRun(state, trace.boundRun);
171
+
172
+ const settled = pickEnvelopeContext(before, after, trace.ranOnRun);
173
+ const run = pickEnvelopeRun(before, settled, bound);
174
+ const feature = settled?.feature ?? null;
175
+
176
+ const payload = pickEnvelopePayload({
177
+ data: outcome?.data,
178
+ commandFailure: failure,
179
+ cursors: run === undefined ? {} : await readEnvelopeCursors(state, run.runID),
180
+ });
181
+
182
+ const capped = await capData(payload.data, {
183
+ root: session.root,
184
+ directory:
185
+ run === undefined
186
+ ? join(stateDir, 'payloads')
187
+ : join(buildRunPaths(stateDir, run.runID).features, feature ?? unassignedFeature),
188
+ now,
189
+ files: session.repoHost,
190
+ });
191
+
192
+ const input = settleEnvelope({
193
+ command: path.join('.'),
194
+ target: pickEnvelopeTarget(settled, bound),
195
+ instance: run?.runID ?? null,
196
+ feature,
197
+ sha: await readHeadSHA(session.repoHost, session.root),
198
+ durationMs: performance.now() - startedMs,
199
+ driveEvidence: trace.evidence.flat(),
200
+ payload,
201
+ capped,
202
+ });
203
+
204
+ const envelope = buildEnvelope(input);
205
+
206
+ const commandLate =
207
+ pending === null
208
+ ? Promise.resolve()
209
+ : writeLateFailure(pending, {
210
+ state,
211
+ runID: run?.runID ?? null,
212
+ command: envelope.command,
213
+ answered: failure,
214
+ now,
215
+ });
216
+
217
+ const late = waitForLate([commandLate, ...trace.late]);
218
+
219
+ const logFailure =
220
+ run === undefined
221
+ ? null
222
+ : await writeEnvelopeLog(state, run, envelope, {
223
+ drive: trace.drive,
224
+ failure: input.error,
225
+ now,
226
+ refuse: pickLogRefusal(envelope, before?.run),
227
+ });
228
+
229
+ return { ...settleDispatchAnswer(input, logFailure), late };
230
+ }
231
+
232
+ interface CommandContext {
233
+ readonly targetName: string;
234
+ readonly target: TargetDefinition;
235
+ readonly run: RunView | undefined;
236
+ readonly feature: string | null;
237
+ readonly cursors: Readonly<Record<string, number>>;
238
+ }
239
+
240
+ async function readContext(
241
+ wringer: Wringer,
242
+ path: readonly string[],
243
+ state: StatePort,
244
+ session: Session,
245
+ ): Promise<CommandContext> {
246
+ // A feature names a folder of the run, so only a feature of the map may reach a path; `use` checks
247
+ // the feature it saves, and this checks the one a command passes.
248
+ if (
249
+ session.feature !== undefined &&
250
+ !wringer.features.some((feature) => feature.name === session.feature)
251
+ ) {
252
+ throw new WringerError('usage', {
253
+ message: `The feature map has no feature named ${session.feature}.`,
254
+ hint: 'Run `features` to list them.',
255
+ });
256
+ }
257
+
258
+ const pointers = await state.readPointers();
259
+
260
+ const targetName = pickContextTarget({
261
+ targetNames: [...wringer.targets.keys()],
262
+ sessionTarget: session.target,
263
+ savedTarget: pointers.target,
264
+ commandKey: path.join(' '),
265
+ });
266
+
267
+ const target = getTarget(wringer, targetName);
268
+ const runID = findOwnValue(pointers.currentRun, targetName);
269
+ const run = runID === undefined ? undefined : await state.readRun(runID);
270
+
271
+ return {
272
+ targetName,
273
+ target,
274
+ run,
275
+ feature: session.feature ?? pointers.feature ?? null,
276
+
277
+ // Saved cursors are offsets into one run's streams; another run's streams start over.
278
+ cursors: runID !== undefined && pointers.cursorsRun === runID ? pointers.cursors : {},
279
+ };
280
+ }
281
+
282
+ interface CommandRequest {
283
+ readonly wringer: Wringer;
284
+ readonly state: StatePort;
285
+ readonly session: Session;
286
+ readonly stateDir: string;
287
+ readonly command: RegisteredCommand;
288
+ readonly rawArgs: unknown;
289
+ readonly context: CommandContext;
290
+ readonly now: () => Date;
291
+ readonly deadline: AbortSignal;
292
+ readonly deadlineAt: number;
293
+ readonly trace: DriveTrace;
294
+ }
295
+
296
+ // Reads the facts, leaves each decision to the pure planners, and carries out the plan.
297
+ async function runCommand(request: CommandRequest): Promise<CommandOutcome> {
298
+ const info = request.command.info;
299
+ const context = request.context;
300
+
301
+ const needs = pickCommandNeeds({
302
+ isBuiltin: request.command.kind === 'builtin',
303
+ needsRun: info.needsRun,
304
+ needsDoctor: info.needsDoctor,
305
+ mode: context.target.mode,
306
+ doctor: context.run?.doctor,
307
+ });
308
+
309
+ const fingerprintChanged = needs.readsFingerprint
310
+ ? await probeFingerprintChanged(request)
311
+ : false;
312
+
313
+ const plan = pickCommandPlan({
314
+ needsRun: needs.needsRun,
315
+ needsDoctor: needs.needsDoctor,
316
+ isRepoCommand: request.command.kind === 'repo',
317
+ run: context.run,
318
+ targetName: context.targetName,
319
+ mode: context.target.mode,
320
+ fingerprintChanged,
321
+ deadlinePassed: request.deadline.aborted,
322
+ });
323
+
324
+ if (plan.kind === 'refuse') {
325
+ throw plan.failure;
326
+ }
327
+
328
+ const hint = context.target.refusalHint ?? defaultRefusalHint;
329
+ const drive = openCommandDrive(request, plan.driveKind, hint);
330
+ const prepared = prepareCommand(request, drive, hint);
331
+
332
+ const accessRefusal = pickAccessRefusal({
333
+ path: info.path,
334
+ allowedTargets: request.command.kind === 'repo' ? request.command.targets : undefined,
335
+ targetName: context.targetName,
336
+ access: context.target.access,
337
+ effect: prepared.effect,
338
+ hint,
339
+ });
340
+
341
+ if (accessRefusal !== null) {
342
+ throw accessRefusal;
343
+ }
344
+
345
+ // A drive's evidence list grows as the command runs; the trace holds the same list, one per drive.
346
+ if (drive !== undefined) {
347
+ request.trace.evidence.push(drive.evidence);
348
+ }
349
+
350
+ request.trace.drive = plan.drive;
351
+ request.trace.ranOnRun = plan.driveKind === 'run';
352
+
353
+ const data = await prepared.run();
354
+
355
+ const output = info.data.safeParse(data);
356
+
357
+ if (!output.success) {
358
+ throw new WringerError('internal', {
359
+ message: `\`${info.path.join(' ')}\` returned data that does not match its schema: ${output.error.message}`,
360
+ data: { stack: output.error.stack ?? output.error.message },
361
+ });
362
+ }
363
+
364
+ return { data: output.data };
365
+ }
366
+
367
+ async function probeFingerprintChanged(request: CommandRequest): Promise<boolean> {
368
+ const base = await readDefaultBase(request.session.repoHost, request.session.root);
369
+
370
+ const fingerprint = await readFingerprint(request.session.repoHost, {
371
+ cwd: request.session.root,
372
+ base,
373
+ exclude: [request.wringer.stateDir],
374
+ });
375
+
376
+ return fingerprint !== request.context.run?.fingerprint;
377
+ }
378
+
379
+ function openCommandDrive(
380
+ request: CommandRequest,
381
+ kind: DriveKind,
382
+ hint: string,
383
+ ): Drive | undefined {
384
+ const run = request.context.run;
385
+ const access = request.context.target.access;
386
+
387
+ if (kind === 'run' && run !== undefined) {
388
+ return openRunDrive(request, { run, access, hint });
389
+ }
390
+
391
+ if (kind === 'runless') {
392
+ return openRunlessDrive({
393
+ wringer: request.wringer,
394
+ root: request.session.root,
395
+ stateDir: request.stateDir,
396
+ targetName: request.context.targetName,
397
+ target: request.context.target,
398
+ access,
399
+ hint,
400
+ feature: request.context.feature,
401
+ signal: request.deadline,
402
+ now: request.now,
403
+ emit: makeEmit(request.session, request.trace),
404
+ files: request.session.repoHost,
405
+ });
406
+ }
407
+
408
+ return undefined;
409
+ }
410
+
411
+ interface RunDriveOptions {
412
+ readonly run: RunView;
413
+ readonly access: Access;
414
+ readonly hint: string;
415
+ readonly deadline?: Deadline | undefined;
416
+ }
417
+
418
+ function openRunDrive(request: CommandRequest, options: RunDriveOptions): Drive {
419
+ return openDrive({
420
+ wringer: request.wringer,
421
+ root: request.session.root,
422
+ stateDir: request.stateDir,
423
+ targetName: request.context.targetName,
424
+ target: request.context.target,
425
+ access: options.access,
426
+ hint: options.hint,
427
+ run: options.run,
428
+ feature: request.context.feature,
429
+ cursors: request.context.cursors,
430
+ signal: options.deadline?.signal ?? request.deadline,
431
+ deadlineAt: options.deadline?.at ?? request.deadlineAt,
432
+ now: request.now,
433
+ emit: makeEmit(request.session, request.trace),
434
+ files: request.session.repoHost,
435
+ callSupervisor: (call) => {
436
+ const calling = request.session.supervisor.call(call);
437
+
438
+ keepCancelledCall(request.trace.calls, calling, request.deadline);
439
+
440
+ return calling;
441
+ },
442
+ });
443
+ }
444
+
445
+ function makeEmit(session: Session, trace: Readonly<DriveTrace>): Emit {
446
+ return (event, data) => {
447
+ if (trace.answered) {
448
+ return;
449
+ }
450
+
451
+ session.output.writeLine(JSON.stringify({ wringer: envelopeVersion, event, data }));
452
+ };
453
+ }
454
+
455
+ interface ReadyCommand {
456
+ readonly effect: Effect;
457
+ readonly run: () => Promise<unknown>;
458
+ }
459
+
460
+ function prepareCommand(
461
+ request: CommandRequest,
462
+ drive: Drive | undefined,
463
+ hint: string,
464
+ ): ReadyCommand {
465
+ return match(request.command)
466
+ .returnType<ReadyCommand>()
467
+ .with({ kind: 'builtin' }, (builtin) => {
468
+ const prepared = getPrepared(builtin.prepare(request.rawArgs));
469
+
470
+ return {
471
+ effect: prepared.effect,
472
+ run: () => prepared.run(buildBuiltinContext(request, hint)),
473
+ };
474
+ })
475
+ .with({ kind: 'repo' }, (repo) => {
476
+ const prepared = getPrepared(repo.prepare(request.rawArgs));
477
+
478
+ return { effect: prepared.effect, run: () => prepared.run(getDrive(drive).ctx) };
479
+ })
480
+ .with({ kind: 'driver' }, (driverCommand) => {
481
+ const opened = getDrive(drive).drivers.get(driverCommand.driver);
482
+
483
+ if (opened === undefined) {
484
+ throw new WringerError('usage', {
485
+ message: `Target ${request.context.targetName} gives the ${driverCommand.driver} driver no config.`,
486
+ });
487
+ }
488
+
489
+ const prepared = getPrepared(
490
+ opened.prepare(driverCommand.info.path.join(' '), request.rawArgs),
491
+ );
492
+
493
+ return {
494
+ effect: prepared.effect,
495
+ run: () => prepared.run(request.context.feature, makeEmit(request.session, request.trace)),
496
+ };
497
+ })
498
+ .exhaustive();
499
+ }
500
+
501
+ function getPrepared<Prepared>(result: ArgsResult<Prepared>): Prepared {
502
+ if (!result.ok) {
503
+ throw new WringerError('usage', { message: result.message });
504
+ }
505
+
506
+ return result.prepared;
507
+ }
508
+
509
+ function buildBuiltinContext(request: CommandRequest, hint: string): BuiltinContext {
510
+ return {
511
+ wringer: request.wringer,
512
+ state: request.state,
513
+ session: request.session,
514
+ stateDir: request.stateDir,
515
+ targetName: request.context.targetName,
516
+ target: request.context.target,
517
+ run: request.context.run,
518
+ feature: request.context.feature,
519
+ signal: request.deadline,
520
+ deadlineAt: request.deadlineAt,
521
+ now: request.now,
522
+ wait: request.session.wait ?? ((delayMs) => Bun.sleep(delayMs)),
523
+ emit: makeEmit(request.session, request.trace),
524
+ openDrive: (access, deadline) => {
525
+ const drive = openRunDrive(request, {
526
+ run: getRunningRun(request.context.run),
527
+ access,
528
+ hint,
529
+ deadline,
530
+ });
531
+
532
+ request.trace.evidence.push(drive.evidence);
533
+
534
+ return drive;
535
+ },
536
+ bindRun: (runID) => {
537
+ request.trace.boundRun = runID;
538
+ },
539
+ keepLate: (work) => {
540
+ request.trace.late.push(work);
541
+ },
542
+ };
543
+ }
544
+
545
+ function getRunningRun(run: RunView | undefined): RunView {
546
+ if (run === undefined) {
547
+ throw new WringerError('not-running', { message: 'The target has no running run.' });
548
+ }
549
+
550
+ return run;
551
+ }
552
+
553
+ function getDrive(drive: Drive | undefined): Drive {
554
+ if (drive === undefined) {
555
+ throw new WringerError('not-running', { message: 'The command needs a running run.' });
556
+ }
557
+
558
+ return drive;
559
+ }
560
+
561
+ // A bound run whose log cannot be read leaves the envelope to the run the pointers name.
562
+ async function readBoundRun(state: StatePort, runID: RunID | null): Promise<RunView | null> {
563
+ if (runID === null) {
564
+ return null;
565
+ }
566
+
567
+ try {
568
+ const view = await state.readRun(runID);
569
+
570
+ return view ?? null;
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+
576
+ async function readEnvelopeCursors(
577
+ state: StatePort,
578
+ runID: RunID,
579
+ ): Promise<Record<string, number> | WringerError> {
580
+ try {
581
+ return await state.readCursors(runID);
582
+ } catch (error) {
583
+ return toWringerError(error);
584
+ }
585
+ }
586
+
587
+ async function waitForLate(works: readonly Promise<void>[]): Promise<void> {
588
+ await Promise.all(works);
589
+ }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod';
2
+ import { envelopeVersion } from './envelope-version.ts';
3
+ import { errorCodes } from './error-codes.ts';
4
+
5
+ export const evidenceSchema = z.strictObject({
6
+ kind: z.string().min(1),
7
+ path: z.string().min(1),
8
+ sha256: z.string().regex(/^[0-9a-f]{64}$/u),
9
+ });
10
+
11
+ export type Evidence = z.infer<typeof evidenceSchema>;
12
+
13
+ export const envelopeErrorSchema = z.strictObject({
14
+ code: z.enum(errorCodes),
15
+ message: z.string(),
16
+ hint: z.string().nullable(),
17
+ });
18
+
19
+ export const envelopeSchema = z
20
+ .strictObject({
21
+ wringer: z.literal(envelopeVersion),
22
+ ok: z.boolean(),
23
+ command: z.string().min(1),
24
+ target: z.string().nullable(),
25
+ instance: z.string().nullable(),
26
+ feature: z.string().nullable(),
27
+ sha: z.string().nullable(),
28
+ durationMs: z.number().int().nonnegative(),
29
+
30
+ // JSON drops a field that is undefined, so a logged envelope with no data would not read back.
31
+ data: z.unknown().refine((value) => value !== undefined, {
32
+ message: 'an envelope holds its data, or null for none',
33
+ }),
34
+ evidence: z.array(evidenceSchema),
35
+ cursors: z.record(z.string(), z.number().int().nonnegative()),
36
+ truncated: z.literal(true).optional(),
37
+ truncatedPath: z.string().optional(),
38
+ error: envelopeErrorSchema.nullable(),
39
+ })
40
+ .refine((envelope) => envelope.ok === (envelope.error === null), {
41
+ message: 'an envelope has an error exactly when it is not ok',
42
+ path: ['error'],
43
+ })
44
+ .refine((envelope) => (envelope.truncated === true) === (envelope.truncatedPath !== undefined), {
45
+ message: 'a truncated envelope names the path of its full data',
46
+ path: ['truncatedPath'],
47
+ });
48
+
49
+ export type Envelope = z.infer<typeof envelopeSchema>;
50
+
51
+ export const streamEventSchema = z.strictObject({
52
+ wringer: z.literal(envelopeVersion),
53
+ event: z.string().min(1),
54
+ data: z.unknown(),
55
+ });
56
+
57
+ export type StreamEvent = z.infer<typeof streamEventSchema>;
@@ -0,0 +1,39 @@
1
+ export const errorCodes = [
2
+ 'check-failed',
3
+ 'usage',
4
+ 'read-only',
5
+ 'doctor-required',
6
+ 'not-running',
7
+ 'target-not-allowed',
8
+ 'environment',
9
+ 'internal',
10
+ 'timeout',
11
+ ] as const;
12
+
13
+ export type ErrorCode = (typeof errorCodes)[number];
14
+
15
+ export type ExitCode = 0 | 1 | 2 | 3 | 4 | 5;
16
+
17
+ export const exitCodeFor = {
18
+ 'check-failed': 1,
19
+ usage: 2,
20
+ 'read-only': 3,
21
+ 'doctor-required': 3,
22
+ 'not-running': 3,
23
+ 'target-not-allowed': 3,
24
+ environment: 4,
25
+ internal: 4,
26
+ timeout: 5,
27
+ } as const satisfies Readonly<Record<ErrorCode, ExitCode>>;
28
+
29
+ export const defaultMessageFor = {
30
+ 'check-failed': 'A check failed.',
31
+ usage: 'The command or its arguments are not valid.',
32
+ 'read-only': 'The target refused a write.',
33
+ 'doctor-required': 'The doctor has not passed on this run.',
34
+ 'not-running': 'The target has no running run.',
35
+ 'target-not-allowed': 'The command does not run on this target.',
36
+ environment: 'The environment failed.',
37
+ internal: 'wringer failed with an internal error.',
38
+ timeout: 'The command ran past its deadline.',
39
+ } as const satisfies Readonly<Record<ErrorCode, string>>;
@@ -0,0 +1,7 @@
1
+ import type { WringerError } from './wringer-error.ts';
2
+
3
+ // The first failure of a cancelled service call other than its timeout, such as a browser that
4
+ // could not close the page of a cancelled action, which tells the caller the action may still run.
5
+ export function findCancelFailure(failures: readonly WringerError[]): WringerError | null {
6
+ return failures.find((failure) => failure.code !== 'timeout') ?? null;
7
+ }
@@ -0,0 +1,16 @@
1
+ import { buildServiceKey } from './build-service-key.ts';
2
+
3
+ export interface DriverServices {
4
+ readonly name: string;
5
+ readonly serviceNames: readonly string[];
6
+ }
7
+
8
+ // A key that two driver services share, such as `cache/main` with `store` and `cache` with
9
+ // `main/store`, or null when each key is unique.
10
+ export function findCollidingServiceKey(drivers: readonly DriverServices[]): string | null {
11
+ const keys = drivers.flatMap((driver) =>
12
+ driver.serviceNames.map((service) => buildServiceKey(driver.name, service)),
13
+ );
14
+
15
+ return keys.find((key, index) => keys.indexOf(key) !== index) ?? null;
16
+ }