@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,193 @@
1
+ import { basename, join } from 'node:path';
2
+ import { withFileLock } from '@zgeoff/std/with-file-lock';
3
+ import { z } from 'zod';
4
+ import { appendRunEvent } from './append-run-event.ts';
5
+ import { buildManifestWrite } from './build-manifest-write.ts';
6
+ import { buildRunView } from './build-run-view.ts';
7
+ import type { RunView } from './build-run-view.ts';
8
+ import { buildRunPaths, buildStatePaths } from './build-state-paths.ts';
9
+ import type { StatePaths } from './build-state-paths.ts';
10
+ import type { StateFileSystem } from './create-node-state-file-system.ts';
11
+ import { getLockValue } from './get-lock-value.ts';
12
+ import { parseRunLog } from './parse-run-log.ts';
13
+ import { lateFailureEventSchema, runEventSchema, runIDSchema } from './run-events.ts';
14
+ import type { LateFailureEvent, RunEvent, RunID } from './run-events.ts';
15
+ import { WringerError } from './wringer-error.ts';
16
+
17
+ export const pointersSchema = z.strictObject({
18
+ currentRun: z.record(z.string(), runIDSchema),
19
+ target: z.string().optional(),
20
+ feature: z.string().optional(),
21
+ cursors: z.record(z.string(), z.number().int().nonnegative()),
22
+ cursorsRun: runIDSchema.optional(),
23
+ });
24
+
25
+ export type Pointers = z.infer<typeof pointersSchema>;
26
+
27
+ export type RunViewRefusal = (run: RunView | undefined) => WringerError | null;
28
+
29
+ export interface StatePort {
30
+ readonly paths: StatePaths;
31
+ readonly readPointers: () => Promise<Pointers>;
32
+ readonly withTargetLock: <T>(target: string, work: () => Promise<T>) => Promise<T>;
33
+ readonly writePointers: (change: (current: Pointers) => Pointers) => Promise<Pointers>;
34
+ readonly appendEvent: (runID: RunID, event: RunEvent) => Promise<void>;
35
+
36
+ // Rejects with the refusal, and writes nothing, when `refuse` returns one for the run as its log
37
+ // stands under the run's lock.
38
+ readonly appendEventUnless: (
39
+ runID: RunID,
40
+ event: RunEvent,
41
+ refuse: RunViewRefusal,
42
+ ) => Promise<void>;
43
+ readonly appendLateFailure: (event: LateFailureEvent) => Promise<void>;
44
+ readonly readRunEvents: (runID: RunID) => Promise<readonly RunEvent[]>;
45
+ readonly readRun: (runID: RunID) => Promise<RunView | undefined>;
46
+ readonly listRuns: () => Promise<readonly RunID[]>;
47
+ readonly writeManifest: (runID: RunID) => Promise<void>;
48
+ readonly readCursors: (runID: RunID) => Promise<Record<string, number>>;
49
+ }
50
+
51
+ export interface FileStateOptions {
52
+ readonly stateDir: string;
53
+ readonly lockTimeoutMs: number;
54
+ readonly fs: StateFileSystem;
55
+ }
56
+
57
+ export const eventSizeCapBytes = 64 * 1024;
58
+
59
+ export function createFileState(options: FileStateOptions): StatePort {
60
+ const paths = buildStatePaths(options.stateDir);
61
+ const fs = options.fs;
62
+
63
+ const withLock = async <T>(name: string, work: () => Promise<T>): Promise<T> => {
64
+ const path = join(paths.locks, name);
65
+
66
+ await fs.createDirectory(paths.locks);
67
+
68
+ const result = await withFileLock(path, work, { waitMs: options.lockTimeoutMs });
69
+
70
+ return getLockValue(path, result);
71
+ };
72
+
73
+ const readPointers = async (): Promise<Pointers> => {
74
+ const text = await fs.readText(paths.state);
75
+
76
+ if (text === null) {
77
+ return { currentRun: {}, cursors: {} };
78
+ }
79
+
80
+ const parsed: unknown = JSON.parse(text);
81
+
82
+ return pointersSchema.parse(parsed);
83
+ };
84
+
85
+ const readRunEvents = async (runID: RunID): Promise<readonly RunEvent[]> => {
86
+ const text = await fs.readText(buildRunPaths(options.stateDir, runID).log);
87
+
88
+ return parseRunLog(text ?? '');
89
+ };
90
+
91
+ const readRun = async (runID: RunID): Promise<RunView | undefined> => {
92
+ const events = await readRunEvents(runID);
93
+
94
+ return buildRunView({ runID, events });
95
+ };
96
+
97
+ const appendCheckedEvent = async (
98
+ runID: RunID,
99
+ event: RunEvent,
100
+ refuse: RunViewRefusal | null,
101
+ ): Promise<void> => {
102
+ const parsed = runEventSchema.parse(event);
103
+ const line = `${JSON.stringify(parsed)}\n`;
104
+ const lineBytes = Buffer.byteLength(line);
105
+
106
+ if (lineBytes > eventSizeCapBytes) {
107
+ throw new WringerError('internal', {
108
+ message: `A ${event.type} event of ${lineBytes} bytes is over the log's cap of ${eventSizeCapBytes} bytes.`,
109
+ });
110
+ }
111
+
112
+ const runPaths = buildRunPaths(options.stateDir, runID);
113
+
114
+ await fs.createDirectory(runPaths.directory);
115
+
116
+ await appendRunEvent({
117
+ fs,
118
+ logPath: runPaths.log,
119
+ lockPath: runPaths.lock,
120
+ lockTimeoutMs: options.lockTimeoutMs,
121
+ line,
122
+ event: parsed,
123
+ refuse: refuse === null ? null : (events) => refuse(buildRunView({ runID, events })),
124
+ });
125
+ };
126
+
127
+ return {
128
+ paths,
129
+ readPointers,
130
+ withTargetLock: (target, work) => withLock(`target-${target}.lock`, work),
131
+ writePointers: (change) =>
132
+ withLock('state.lock', async () => {
133
+ const current = await readPointers();
134
+
135
+ const next = pointersSchema.parse(change(current));
136
+
137
+ await fs.writeTextAtomic(paths.state, `${JSON.stringify(next, null, 2)}\n`);
138
+
139
+ return next;
140
+ }),
141
+ appendEvent: (runID, event) => appendCheckedEvent(runID, event, null),
142
+ appendEventUnless: (runID, event, refuse) => appendCheckedEvent(runID, event, refuse),
143
+ appendLateFailure: (event) =>
144
+ withLock('late-failures.lock', async () => {
145
+ const parsed = lateFailureEventSchema.parse(event);
146
+
147
+ await fs.appendText(paths.lateFailures, `${JSON.stringify(parsed)}\n`);
148
+ }),
149
+ readRunEvents,
150
+ readRun,
151
+ listRuns: async () => {
152
+ const names = await fs.listNames(paths.runs);
153
+
154
+ return names.flatMap((name) => {
155
+ const parsed = runIDSchema.safeParse(name);
156
+
157
+ return parsed.success ? [parsed.data] : [];
158
+ });
159
+ },
160
+
161
+ // Under the run's lock, so a manifest built from an older read never replaces a newer one.
162
+ writeManifest: (runID) =>
163
+ withLock(basename(buildRunPaths(options.stateDir, runID).lock), async () => {
164
+ const view = await readRun(runID);
165
+
166
+ const plan = buildManifestWrite(buildRunPaths(options.stateDir, runID).manifest, view);
167
+
168
+ if (plan !== null) {
169
+ await fs.writeTextAtomic(plan.path, plan.text);
170
+ }
171
+ }),
172
+
173
+ // A cursor is a byte offset into a stream's NDJSON file, so reading the cursors costs one size
174
+ // read per stream, however long the streams grow.
175
+ readCursors: async (runID) => {
176
+ const streams = buildRunPaths(options.stateDir, runID).streams;
177
+
178
+ const names = await fs.listNames(streams);
179
+
180
+ const files = names.filter((name) => name.endsWith('.ndjson'));
181
+
182
+ const sizes = await Promise.all(
183
+ files.map(async (name) => {
184
+ const size = await fs.readFileSize(join(streams, name));
185
+
186
+ return [basename(name, '.ndjson'), size] as const;
187
+ }),
188
+ );
189
+
190
+ return Object.fromEntries(sizes);
191
+ },
192
+ };
193
+ }
@@ -0,0 +1,38 @@
1
+ export interface LogWriter {
2
+ // Settles with the append's own result. An append after `close` writes nothing.
3
+ readonly append: (text: string) => Promise<void>;
4
+
5
+ // Settles once each append that came before it has, so nothing reaches the log after it.
6
+ readonly close: () => Promise<void>;
7
+ }
8
+
9
+ // Each append waits for the one before it, so the app's stdout and stderr reach the log one chunk
10
+ // at a time, and a close knows the last write.
11
+ export function createLogWriter(appendLog: (text: string) => Promise<void>): LogWriter {
12
+ let closed = false;
13
+ let last: Promise<unknown> = Promise.resolve();
14
+
15
+ const writeAfter = async (previous: Promise<unknown>, text: string): Promise<void> => {
16
+ await previous;
17
+ await appendLog(text);
18
+ };
19
+
20
+ return {
21
+ append: (text) => {
22
+ if (closed) {
23
+ return Promise.resolve();
24
+ }
25
+
26
+ const writing = writeAfter(last, text);
27
+
28
+ last = Promise.allSettled([writing]);
29
+
30
+ return writing;
31
+ },
32
+ close: async () => {
33
+ closed = true;
34
+
35
+ await last;
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createDirectory } from './create-directory.ts';
3
+ import { readText } from './read-text.ts';
4
+ import { removeDirectory } from './remove-directory.ts';
5
+ import { runGit, runGitBytes } from './run-git.ts';
6
+ import type { RepoHost } from './types.ts';
7
+ import { writeNewFile } from './write-new-file.ts';
8
+
9
+ // The commands' view of this machine's git and disk, for an entry point to pass in.
10
+ export function createNodeRepoHost(): RepoHost {
11
+ return {
12
+ runGit,
13
+ runGitBytes,
14
+ removeDirectory,
15
+ readText,
16
+ createDirectory,
17
+ writeNewFile,
18
+ readBytes: readFile,
19
+ };
20
+ }
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { appendFile, readdir, rename, stat, truncate, writeFile } from 'node:fs/promises';
3
+ import { readOptionalText } from '@zgeoff/std';
4
+ import { createDirectory } from './create-directory.ts';
5
+ import { isErrorCode } from './is-error-code.ts';
6
+
7
+ // The file operations the run state needs. A missing file or folder reads as null or as no names;
8
+ // every other failure, such as EACCES or EIO, rejects.
9
+ export interface StateFileSystem {
10
+ readonly readText: (path: string) => Promise<string | null>;
11
+ readonly listNames: (directory: string) => Promise<readonly string[]>;
12
+ readonly readFileSize: (path: string) => Promise<number>;
13
+ readonly createDirectory: (path: string) => Promise<void>;
14
+ readonly appendText: (path: string, text: string) => Promise<void>;
15
+ readonly truncateFile: (path: string, length: number) => Promise<void>;
16
+ readonly writeTextAtomic: (path: string, text: string) => Promise<void>;
17
+ }
18
+
19
+ export function createNodeStateFileSystem(): StateFileSystem {
20
+ return {
21
+ readText: async (path) => {
22
+ const text = await readOptionalText(path);
23
+
24
+ return text ?? null;
25
+ },
26
+ listNames: async (directory) => {
27
+ try {
28
+ return await readdir(directory);
29
+ } catch (error) {
30
+ if (isErrorCode(error, 'ENOENT')) {
31
+ return [];
32
+ }
33
+
34
+ throw error;
35
+ }
36
+ },
37
+ createDirectory,
38
+ appendText: appendFile,
39
+ readFileSize: async (path) => {
40
+ const info = await stat(path);
41
+
42
+ return info.size;
43
+ },
44
+ truncateFile: truncate,
45
+ writeTextAtomic: async (path, text) => {
46
+ const temporary = `${path}.${randomUUID()}.tmp`;
47
+
48
+ await writeFile(temporary, text, { flag: 'wx' });
49
+ await rename(temporary, path);
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path';
2
+ import { createFileState } from './create-file-state.ts';
3
+ import type { StatePort } from './create-file-state.ts';
4
+ import { createNodeStateFileSystem } from './create-node-state-file-system.ts';
5
+
6
+ const lockTimeoutMs = 30_000;
7
+
8
+ // The run state on disk under the repo's state folder, for an entry point to pass in.
9
+ export function createRepoState(root: string, stateDir: string): StatePort {
10
+ return createFileState({
11
+ stateDir: join(root, stateDir),
12
+ lockTimeoutMs,
13
+ fs: createNodeStateFileSystem(),
14
+ });
15
+ }
@@ -0,0 +1,24 @@
1
+ import { chmod, mkdir, mkdtemp } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ // Unix socket paths are capped near 108 bytes.
6
+ const maxSocketPathBytes = 100;
7
+
8
+ // Only the owner may reach the socket: its folder is owner-only before the socket exists, whatever
9
+ // the umask. A path too long for a socket moves to a fresh owner-only folder in the temp folder,
10
+ // which `mkdtemp` names at random so no other user can claim it first. Returns the socket's path.
11
+ export async function createSocketFolder(preferredSocket: string): Promise<string> {
12
+ if (Buffer.byteLength(preferredSocket) <= maxSocketPathBytes) {
13
+ const folder = dirname(preferredSocket);
14
+
15
+ await mkdir(folder, { recursive: true, mode: 0o700 });
16
+ await chmod(folder, 0o700);
17
+
18
+ return preferredSocket;
19
+ }
20
+
21
+ const folder = await mkdtemp(join(tmpdir(), 'wringer-'));
22
+
23
+ return join(folder, 'sock');
24
+ }
@@ -0,0 +1,169 @@
1
+ import { buildActionCommand, buildRepoCheck, buildRepoCommand } from './build-commands.ts';
2
+ import { buildDoctorCommand } from './builtins/build-doctor-command.ts';
3
+ import { buildExecCommand } from './builtins/build-exec-command.ts';
4
+ import { buildLifecycleCommands } from './builtins/build-lifecycle-commands.ts';
5
+ import { buildStateCommands } from './builtins/build-state-commands.ts';
6
+ import { buildVerdictCommands } from './builtins/build-verdict-commands.ts';
7
+ import type {
8
+ ActionDefinition,
9
+ AnyDriver,
10
+ AnyDrivers,
11
+ CheckDefinition,
12
+ CommandSpec,
13
+ FeatureDefinition,
14
+ HookHandlers,
15
+ RegisteredCommand,
16
+ RunnableRepoCheck,
17
+ TargetDefinition,
18
+ Wringer,
19
+ } from './types.ts';
20
+ import { WringerError } from './wringer-error.ts';
21
+
22
+ export interface WringerOptions {
23
+ readonly name: string;
24
+ readonly stateDir?: string | undefined;
25
+ }
26
+
27
+ export interface WringerBuilder<Ds extends AnyDrivers> {
28
+ readonly use: <const Added extends readonly AnyDriver[]>(
29
+ ...drivers: Added
30
+ ) => WringerBuilder<readonly [...Ds, ...Added]>;
31
+ readonly target: (name: string, target: TargetDefinition<Ds>) => WringerBuilder<Ds>;
32
+ readonly command: <Args, Data>(spec: CommandSpec<Args, Data, Ds>) => WringerBuilder<Ds>;
33
+ readonly action: <Input, Data>(action: ActionDefinition<Input, Data, Ds>) => WringerBuilder<Ds>;
34
+ readonly check: <Input, Data>(check: CheckDefinition<Input, Data, Ds>) => WringerBuilder<Ds>;
35
+ readonly feature: (...features: readonly FeatureDefinition[]) => WringerBuilder<Ds>;
36
+ readonly hook: <Name extends keyof HookHandlers>(
37
+ name: Name,
38
+ handler: HookHandlers[Name],
39
+ ) => WringerBuilder<Ds>;
40
+ readonly build: () => Wringer;
41
+ }
42
+
43
+ // Each call returns a new builder. `build` adds the built-in commands, refuses a duplicate command
44
+ // path or target name, then freezes the registry.
45
+ export function createWringer(options: WringerOptions): WringerBuilder<readonly []> {
46
+ return makeBuilder({
47
+ options,
48
+ drivers: [],
49
+ targets: [],
50
+ commands: [],
51
+ checks: [],
52
+ features: [],
53
+ hooks: {},
54
+ });
55
+ }
56
+
57
+ interface Parts {
58
+ readonly options: WringerOptions;
59
+ readonly drivers: readonly AnyDriver[];
60
+ readonly targets: readonly (readonly [string, TargetDefinition])[];
61
+ readonly commands: readonly ((driverNames: readonly string[]) => RegisteredCommand)[];
62
+ readonly checks: readonly ((driverNames: readonly string[]) => RunnableRepoCheck)[];
63
+ readonly features: readonly FeatureDefinition[];
64
+ readonly hooks: Partial<HookHandlers>;
65
+ }
66
+
67
+ function makeBuilder<Ds extends AnyDrivers>(parts: Parts): WringerBuilder<Ds> {
68
+ return {
69
+ // oxlint-disable-next-line zgeoff/function-verb -- the public builder API names this step `use`
70
+ use: (...drivers) => makeBuilder({ ...parts, drivers: [...parts.drivers, ...drivers] }),
71
+ target: (name, target) =>
72
+ makeBuilder({ ...parts, targets: [...parts.targets, [name, target]] }),
73
+ command: (spec) =>
74
+ makeBuilder({
75
+ ...parts,
76
+ commands: [...parts.commands, (names) => buildRepoCommand(spec, undefined, names)],
77
+ }),
78
+ action: (action) =>
79
+ makeBuilder({
80
+ ...parts,
81
+ commands: [...parts.commands, (names) => buildActionCommand(action, names)],
82
+ }),
83
+ check: (check) =>
84
+ makeBuilder({ ...parts, checks: [...parts.checks, (names) => buildRepoCheck(check, names)] }),
85
+ feature: (...features) => makeBuilder({ ...parts, features: [...parts.features, ...features] }),
86
+ hook: (name, handler) => makeBuilder({ ...parts, hooks: { ...parts.hooks, [name]: handler } }),
87
+ build: () => buildWringer(parts),
88
+ };
89
+ }
90
+
91
+ export const defaultStateDir = '.wringer';
92
+
93
+ function buildWringer(parts: Parts): Wringer {
94
+ if (parts.targets.length === 0) {
95
+ throw new WringerError('usage', { message: 'A wringer CLI needs at least one target.' });
96
+ }
97
+
98
+ const driverNames = parts.drivers.map((driver) => driver.name);
99
+
100
+ // A run keys each driver's services by its name, so a second driver of one name would replace
101
+ // the first one's services and leave them running past the run.
102
+ const repeated = driverNames.find((name, index) => driverNames.indexOf(name) !== index);
103
+
104
+ if (repeated !== undefined) {
105
+ throw new WringerError('usage', {
106
+ message: `Two drivers are named \`${repeated}\`.`,
107
+ hint: 'Give each driver its own name.',
108
+ });
109
+ }
110
+
111
+ const driverCommands = parts.drivers.flatMap((driver) =>
112
+ driver.commands.map((info): RegisteredCommand => ({
113
+ kind: 'driver',
114
+ info,
115
+ driver: driver.name,
116
+ })),
117
+ );
118
+
119
+ // A driver command reaches its client through the run's services, so it cannot run without one.
120
+ const runless = driverCommands.find((command) => command.info.needsRun === false);
121
+
122
+ if (runless !== undefined) {
123
+ throw new WringerError('usage', {
124
+ message: `The driver command \`${runless.info.path.join(' ')}\` needs a run, because its client calls the run's services.`,
125
+ hint: 'Leave `needsRun` unset on a driver command.',
126
+ });
127
+ }
128
+
129
+ const registered = [
130
+ ...buildLifecycleCommands(),
131
+ ...buildStateCommands(),
132
+ ...buildVerdictCommands(),
133
+ buildDoctorCommand(),
134
+ buildExecCommand(),
135
+ ...driverCommands,
136
+ ...parts.commands.map((buildEntry) => buildEntry(driverNames)),
137
+ ];
138
+
139
+ return Object.freeze({
140
+ name: parts.options.name,
141
+ stateDir: parts.options.stateDir ?? defaultStateDir,
142
+ drivers: Object.freeze([...parts.drivers]),
143
+ targets: buildUniqueMap(parts.targets, 'target'),
144
+ commands: buildUniqueMap(
145
+ registered.map((command) => [command.info.path.join(' '), command] as const),
146
+ 'command',
147
+ ),
148
+ checks: Object.freeze(parts.checks.map((buildEntry) => buildEntry(driverNames))),
149
+ features: Object.freeze([...parts.features]),
150
+ hooks: Object.freeze({ ...parts.hooks }),
151
+ });
152
+ }
153
+
154
+ function buildUniqueMap<Value>(
155
+ entries: readonly (readonly [string, Value])[],
156
+ kind: string,
157
+ ): ReadonlyMap<string, Value> {
158
+ const map = new Map<string, Value>();
159
+
160
+ for (const [key, value] of entries) {
161
+ if (map.has(key)) {
162
+ throw new WringerError('usage', { message: `Two ${kind}s share the name \`${key}\`.` });
163
+ }
164
+
165
+ map.set(key, value);
166
+ }
167
+
168
+ return map;
169
+ }
@@ -0,0 +1,47 @@
1
+ import type { RunView } from './build-run-view.ts';
2
+ import type { Mode } from './run-events.ts';
3
+ import { WringerError } from './wringer-error.ts';
4
+
5
+ export interface GuardInput {
6
+ readonly needsRun: boolean;
7
+ readonly needsDoctor: boolean;
8
+ readonly run: RunView | undefined;
9
+ readonly targetName: string;
10
+ readonly mode: Mode;
11
+ readonly fingerprintChanged: boolean;
12
+ }
13
+
14
+ // The doctor fails closed: a drive command runs only on a running run whose own log holds a doctor
15
+ // pass. A launched app runs this worktree's code, so its pass also needs the tree unchanged since
16
+ // launch; an attached app runs code from elsewhere.
17
+ export function decideGuard(input: GuardInput): WringerError | null {
18
+ const start = input.mode === 'launch' ? 'up' : 'attach';
19
+
20
+ if (input.needsRun && input.run?.status !== 'running') {
21
+ return new WringerError('not-running', {
22
+ message: `Target ${input.targetName} has no running run.`,
23
+ hint: `Run \`${start} --target ${input.targetName}\` first.`,
24
+ });
25
+ }
26
+
27
+ if (!input.needsDoctor) {
28
+ return null;
29
+ }
30
+
31
+ if (input.run?.doctor !== 'passed') {
32
+ return new WringerError('doctor-required', {
33
+ message: `The doctor has not passed on run ${input.run?.runID ?? 'none'}.`,
34
+ hint: `Run \`doctor --target ${input.targetName}\`, and fix what it reports.`,
35
+ });
36
+ }
37
+
38
+ if (input.fingerprintChanged) {
39
+ return new WringerError('doctor-required', {
40
+ message:
41
+ 'The working tree changed since this run started, so its doctor pass no longer holds.',
42
+ hint: 'Run `up --restart`, then `doctor`.',
43
+ });
44
+ }
45
+
46
+ return null;
47
+ }