@wringer/core 0.1.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.
- package/LICENSE +1 -1
- package/README.md +71 -2
- package/dist/answer-supervisor-connection.d.ts +8 -0
- package/dist/app-machine.d.ts +76 -0
- package/dist/append-run-event.d.ts +16 -0
- package/dist/bind-drivers.d.ts +9 -0
- package/dist/build-commands.d.ts +5 -0
- package/dist/build-envelope-json-schema.d.ts +1 -0
- package/dist/build-envelope.d.ts +16 -0
- package/dist/build-launch-guard.d.ts +1 -0
- package/dist/build-manifest-write.d.ts +6 -0
- package/dist/build-promised-object-reads.d.ts +7 -0
- package/dist/build-report.d.ts +10 -0
- package/dist/build-run-handle.d.ts +2 -0
- package/dist/build-run-view.d.ts +29 -0
- package/dist/build-service-key.d.ts +1 -0
- package/dist/build-shut-down-steps.d.ts +14 -0
- package/dist/build-skip-worktree-marks.d.ts +7 -0
- package/dist/build-spawn-error.d.ts +2 -0
- package/dist/build-state-paths.d.ts +21 -0
- package/dist/build-submodule-adds.d.ts +7 -0
- package/dist/builtins/build-doctor-command.d.ts +2 -0
- package/dist/builtins/build-exec-command.d.ts +2 -0
- package/dist/builtins/build-lifecycle-commands.d.ts +3 -0
- package/dist/builtins/build-state-commands.d.ts +2 -0
- package/dist/builtins/build-use-pointers.d.ts +9 -0
- package/dist/builtins/build-verdict-commands.d.ts +2 -0
- package/dist/builtins/call-run-supervisor.d.ts +5 -0
- package/dist/builtins/pick-doctor-outcome.d.ts +13 -0
- package/dist/builtins/pick-start-action.d.ts +10 -0
- package/dist/builtins/pick-stop-plan.d.ts +14 -0
- package/dist/builtins/start-run.d.ts +22 -0
- package/dist/builtins/stop-data-schema.d.ts +6 -0
- package/dist/builtins/stop-run.d.ts +3 -0
- package/dist/call-supervisor.d.ts +10 -0
- package/dist/cap-data.d.ts +17 -0
- package/dist/collect-driver-configs.d.ts +7 -0
- package/dist/create-directory.d.ts +1 -0
- package/dist/create-evidence-writer.d.ts +13 -0
- package/dist/create-file-state.d.ts +36 -0
- package/dist/create-log-writer.d.ts +5 -0
- package/dist/create-node-repo-host.d.ts +2 -0
- package/dist/create-node-state-file-system.d.ts +10 -0
- package/dist/create-repo-state.d.ts +2 -0
- package/dist/create-socket-folder.d.ts +1 -0
- package/dist/create-wringer.d.ts +17 -0
- package/dist/decide-guard.d.ts +12 -0
- package/dist/define-driver.d.ts +13 -0
- package/dist/dispatch.d.ts +10 -0
- package/dist/envelope.d.ts +63 -0
- package/dist/error-codes.d.ts +25 -0
- package/dist/find-cancel-failure.d.ts +2 -0
- package/dist/find-colliding-service-key.d.ts +5 -0
- package/dist/find-own-value.d.ts +1 -0
- package/dist/find-remote-stop-failure.d.ts +2 -0
- package/dist/find-verdict-refusal.d.ts +3 -0
- package/dist/follow-process.d.ts +10 -0
- package/dist/get-lock-value.d.ts +2 -0
- package/dist/get-target.d.ts +2 -0
- package/dist/host.d.ts +19 -0
- package/dist/index.d.ts +58 -0
- package/dist/is-error-code.d.ts +1 -0
- package/dist/keep-cancelled-call.d.ts +1 -0
- package/dist/kill-process-group.d.ts +1 -0
- package/dist/launch-line-schema.d.ts +3 -0
- package/dist/listen-supervisor-socket.d.ts +8 -0
- package/dist/local.d.ts +2 -0
- package/dist/make-fence.d.ts +8 -0
- package/dist/make-fenced-exec.d.ts +10 -0
- package/dist/make-process-supervisor-port.d.ts +7 -0
- package/dist/open-drive.d.ts +23 -0
- package/dist/open-runless-drive.d.ts +17 -0
- package/dist/parse-launch-line.d.ts +2 -0
- package/dist/parse-patch-id.d.ts +2 -0
- package/dist/parse-report-block.d.ts +9 -0
- package/dist/parse-run-log.d.ts +2 -0
- package/dist/pick-access-refusal.d.ts +12 -0
- package/dist/pick-command-needs.d.ts +15 -0
- package/dist/pick-command-plan.d.ts +23 -0
- package/dist/pick-context-target.d.ts +7 -0
- package/dist/pick-drive-kind.d.ts +8 -0
- package/dist/pick-envelope-context.d.ts +1 -0
- package/dist/pick-envelope-payload.d.ts +12 -0
- package/dist/pick-envelope-run.d.ts +6 -0
- package/dist/pick-envelope-target.d.ts +4 -0
- package/dist/pick-late-failure-write.d.ts +13 -0
- package/dist/pick-log-refusal.d.ts +4 -0
- package/dist/pick-merge-base-failure.d.ts +2 -0
- package/dist/pick-merge-base-steps.d.ts +13 -0
- package/dist/pick-pending-cleanups.d.ts +10 -0
- package/dist/pick-submodule-refusal.d.ts +2 -0
- package/dist/pick-supervisor-operation.d.ts +16 -0
- package/dist/prepare-args.d.ts +3 -0
- package/dist/probe-process.d.ts +1 -0
- package/dist/quote-shell-word.d.ts +1 -0
- package/dist/race-deadline.d.ts +1 -0
- package/dist/read-changed-paths.d.ts +7 -0
- package/dist/read-default-base.d.ts +2 -0
- package/dist/read-fingerprint.d.ts +13 -0
- package/dist/read-head-sha.d.ts +2 -0
- package/dist/read-launch-host.d.ts +26 -0
- package/dist/read-pid-namespace.d.ts +1 -0
- package/dist/read-stream-line-batches.d.ts +2 -0
- package/dist/read-stream-lines.d.ts +2 -0
- package/dist/read-text.d.ts +1 -0
- package/dist/read-verdicts.d.ts +5 -0
- package/dist/remove-directory.d.ts +1 -0
- package/dist/remove-file.d.ts +1 -0
- package/dist/render-report-markdown.d.ts +4 -0
- package/dist/report-schema.d.ts +51 -0
- package/dist/run-events.d.ts +129 -0
- package/dist/run-git.d.ts +9 -0
- package/dist/run-process.d.ts +19 -0
- package/dist/run-supervisor-process.d.ts +2 -0
- package/dist/run-supervisor.d.ts +16 -0
- package/dist/settle-dispatch-answer.d.ts +9 -0
- package/dist/settle-envelope.d.ts +16 -0
- package/dist/ssh.d.ts +12 -0
- package/dist/start-launch.d.ts +20 -0
- package/dist/start-services.d.ts +16 -0
- package/dist/supervisor-machine.d.ts +78 -0
- package/dist/supervisor-protocol.d.ts +43 -0
- package/dist/to-wringer-error.d.ts +2 -0
- package/dist/types.d.ts +353 -0
- package/dist/wait-for-cancelled-calls.d.ts +2 -0
- package/dist/wringer-error.d.ts +12 -0
- package/dist/write-envelope-log.d.ts +11 -0
- package/dist/write-late-failure.d.ts +11 -0
- package/dist/write-new-file.d.ts +1 -0
- package/envelope.schema.json +175 -0
- package/package.json +21 -5
- package/src/answer-supervisor-connection.ts +117 -0
- package/src/app-machine.ts +174 -0
- package/src/append-run-event.ts +131 -0
- package/src/bind-drivers.ts +33 -0
- package/src/build-commands.ts +104 -0
- package/src/build-envelope-json-schema.ts +34 -0
- package/src/build-envelope.ts +40 -0
- package/src/build-launch-guard.ts +5 -0
- package/src/build-manifest-write.ts +26 -0
- package/src/build-promised-object-reads.ts +48 -0
- package/src/build-report.ts +55 -0
- package/src/build-run-handle.ts +13 -0
- package/src/build-run-view.ts +120 -0
- package/src/build-service-key.ts +4 -0
- package/src/build-shut-down-steps.ts +54 -0
- package/src/build-skip-worktree-marks.ts +36 -0
- package/src/build-spawn-error.ts +12 -0
- package/src/build-state-paths.ts +53 -0
- package/src/build-submodule-adds.ts +64 -0
- package/src/builtins/build-doctor-command.ts +163 -0
- package/src/builtins/build-exec-command.ts +41 -0
- package/src/builtins/build-lifecycle-commands.ts +115 -0
- package/src/builtins/build-state-commands.ts +152 -0
- package/src/builtins/build-use-pointers.ts +26 -0
- package/src/builtins/build-verdict-commands.ts +265 -0
- package/src/builtins/call-run-supervisor.ts +40 -0
- package/src/builtins/pick-doctor-outcome.ts +32 -0
- package/src/builtins/pick-start-action.ts +35 -0
- package/src/builtins/pick-stop-plan.ts +32 -0
- package/src/builtins/start-run.ts +281 -0
- package/src/builtins/stop-data-schema.ts +8 -0
- package/src/builtins/stop-run.ts +49 -0
- package/src/call-supervisor.ts +109 -0
- package/src/cap-data.ts +43 -0
- package/src/collect-driver-configs.ts +36 -0
- package/src/create-directory.ts +6 -0
- package/src/create-evidence-writer.ts +82 -0
- package/src/create-file-state.ts +193 -0
- package/src/create-log-writer.ts +38 -0
- package/src/create-node-repo-host.ts +20 -0
- package/src/create-node-state-file-system.ts +52 -0
- package/src/create-repo-state.ts +15 -0
- package/src/create-socket-folder.ts +24 -0
- package/src/create-wringer.ts +169 -0
- package/src/decide-guard.ts +47 -0
- package/src/define-driver.ts +165 -0
- package/src/dispatch.ts +589 -0
- package/src/envelope.ts +57 -0
- package/src/error-codes.ts +39 -0
- package/src/find-cancel-failure.ts +7 -0
- package/src/find-colliding-service-key.ts +16 -0
- package/src/find-own-value.ts +8 -0
- package/src/find-remote-stop-failure.ts +19 -0
- package/src/find-verdict-refusal.ts +26 -0
- package/src/follow-process.ts +147 -0
- package/src/get-lock-value.ts +17 -0
- package/src/get-target.ts +15 -0
- package/src/host.ts +26 -0
- package/src/index.ts +73 -0
- package/src/is-error-code.ts +3 -0
- package/src/keep-cancelled-call.ts +21 -0
- package/src/kill-process-group.ts +13 -0
- package/src/launch-line-schema.ts +6 -0
- package/src/listen-supervisor-socket.ts +75 -0
- package/src/local.ts +20 -0
- package/src/make-fence.ts +27 -0
- package/src/make-fenced-exec.ts +52 -0
- package/src/make-process-supervisor-port.ts +65 -0
- package/src/open-drive.ts +151 -0
- package/src/open-runless-drive.ts +71 -0
- package/src/parse-launch-line.ts +19 -0
- package/src/parse-patch-id.ts +8 -0
- package/src/parse-report-block.ts +39 -0
- package/src/parse-run-log.ts +34 -0
- package/src/pick-access-refusal.ts +32 -0
- package/src/pick-command-needs.ts +29 -0
- package/src/pick-command-plan.ts +51 -0
- package/src/pick-context-target.ts +22 -0
- package/src/pick-drive-kind.ts +23 -0
- package/src/pick-envelope-context.ts +10 -0
- package/src/pick-envelope-payload.ts +28 -0
- package/src/pick-envelope-run.ts +25 -0
- package/src/pick-envelope-target.ts +10 -0
- package/src/pick-late-failure-write.ts +44 -0
- package/src/pick-log-refusal.ts +18 -0
- package/src/pick-merge-base-failure.ts +15 -0
- package/src/pick-merge-base-steps.ts +37 -0
- package/src/pick-pending-cleanups.ts +23 -0
- package/src/pick-submodule-refusal.ts +46 -0
- package/src/pick-supervisor-operation.ts +52 -0
- package/src/prepare-args.ts +22 -0
- package/src/probe-process.ts +16 -0
- package/src/quote-shell-word.ts +9 -0
- package/src/race-deadline.ts +23 -0
- package/src/read-changed-paths.ts +39 -0
- package/src/read-default-base.ts +21 -0
- package/src/read-fingerprint.ts +271 -0
- package/src/read-head-sha.ts +23 -0
- package/src/read-launch-host.ts +64 -0
- package/src/read-pid-namespace.ts +18 -0
- package/src/read-stream-line-batches.ts +28 -0
- package/src/read-stream-lines.ts +11 -0
- package/src/read-text.ts +5 -0
- package/src/read-verdicts.ts +57 -0
- package/src/remove-directory.ts +6 -0
- package/src/remove-file.ts +6 -0
- package/src/render-report-markdown.ts +53 -0
- package/src/report-schema.ts +37 -0
- package/src/run-events.ts +71 -0
- package/src/run-git.ts +49 -0
- package/src/run-process.ts +168 -0
- package/src/run-supervisor-process.ts +51 -0
- package/src/run-supervisor.ts +327 -0
- package/src/settle-dispatch-answer.ts +26 -0
- package/src/settle-envelope.ts +34 -0
- package/src/ssh.ts +211 -0
- package/src/start-launch.ts +327 -0
- package/src/start-services.ts +108 -0
- package/src/supervisor-machine.ts +169 -0
- package/src/supervisor-protocol.ts +42 -0
- package/src/to-wringer-error.ts +20 -0
- package/src/types.ts +476 -0
- package/src/wait-for-cancelled-calls.ts +37 -0
- package/src/wringer-error.ts +25 -0
- package/src/write-envelope-log.ts +72 -0
- package/src/write-late-failure.ts +40 -0
- package/src/write-new-file.ts +17 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import type { Actor } from 'xstate';
|
|
4
|
+
import { createActor, waitFor } from 'xstate';
|
|
5
|
+
import type { AppStart } from './app-machine.ts';
|
|
6
|
+
import { appMachine } from './app-machine.ts';
|
|
7
|
+
import { buildSpawnError } from './build-spawn-error.ts';
|
|
8
|
+
import { createLogWriter } from './create-log-writer.ts';
|
|
9
|
+
import type { LogWriter } from './create-log-writer.ts';
|
|
10
|
+
import { parseLaunchLine } from './parse-launch-line.ts';
|
|
11
|
+
import { raceDeadline } from './race-deadline.ts';
|
|
12
|
+
import type { AppProcess, AppSpawnOptions, LaunchHost } from './read-launch-host.ts';
|
|
13
|
+
import { readStreamLineBatches } from './read-stream-line-batches.ts';
|
|
14
|
+
import type { Endpoints } from './run-events.ts';
|
|
15
|
+
import type { ProcessRequest } from './run-process.ts';
|
|
16
|
+
import { toWringerError } from './to-wringer-error.ts';
|
|
17
|
+
import type { LaunchPlan } from './types.ts';
|
|
18
|
+
import { WringerError } from './wringer-error.ts';
|
|
19
|
+
|
|
20
|
+
export interface LaunchRequest {
|
|
21
|
+
readonly host: LaunchHost;
|
|
22
|
+
readonly plan: LaunchPlan;
|
|
23
|
+
readonly logPath: string;
|
|
24
|
+
readonly readyTimeoutMs: number;
|
|
25
|
+
readonly signal: AbortSignal;
|
|
26
|
+
readonly onCrash: (reason: string) => void;
|
|
27
|
+
|
|
28
|
+
// Takes why the stop of an app that never got ready failed, as the app may still be up.
|
|
29
|
+
readonly onStopFailed: (reason: string) => void;
|
|
30
|
+
|
|
31
|
+
// How long the stop command, then the app after SIGTERM, each get before the next step.
|
|
32
|
+
readonly stopGraceMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Launch {
|
|
36
|
+
readonly endpoints: Endpoints;
|
|
37
|
+
|
|
38
|
+
// Rejects when a part of the app may still be up, or when its output missed its log.
|
|
39
|
+
readonly stop: () => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const appStopGraceMs = 5000;
|
|
43
|
+
|
|
44
|
+
// The app runs in a process group of its own, and every stop signals the whole group, so a server
|
|
45
|
+
// that a shell or a script runner started stops with it. The app machine decides each move; this
|
|
46
|
+
// function starts the process and feeds the machine what the process and its log do.
|
|
47
|
+
export async function startLaunch(request: LaunchRequest): Promise<Launch> {
|
|
48
|
+
const cwd = request.plan.cwd ?? request.host.cwd;
|
|
49
|
+
const env = { ...request.host.env, ...request.plan.env };
|
|
50
|
+
|
|
51
|
+
await createLogFolder(request.host, request.logPath);
|
|
52
|
+
|
|
53
|
+
// The abort listener below never fires for a run that stopped already, so the app would start.
|
|
54
|
+
if (request.signal.aborted) {
|
|
55
|
+
throw new WringerError('environment', { message: 'The run stopped before the app was ready.' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const child = spawnApp(request.host, [...request.host.guard, ...request.plan.command], {
|
|
59
|
+
cwd,
|
|
60
|
+
env,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const logs = Promise.withResolvers<unknown>();
|
|
64
|
+
const log = createLogWriter((text) => request.host.appendLog(request.logPath, text));
|
|
65
|
+
|
|
66
|
+
const app = createActor(appMachine, {
|
|
67
|
+
input: {
|
|
68
|
+
readyTimeoutMs: request.readyTimeoutMs,
|
|
69
|
+
reportCrash: request.onCrash,
|
|
70
|
+
killGroup: () => {
|
|
71
|
+
request.host.killProcessGroup(child.pid);
|
|
72
|
+
},
|
|
73
|
+
stopApp: () =>
|
|
74
|
+
stopApp({
|
|
75
|
+
host: request.host,
|
|
76
|
+
plan: request.plan,
|
|
77
|
+
cwd,
|
|
78
|
+
env,
|
|
79
|
+
child,
|
|
80
|
+
logged: logs.promise,
|
|
81
|
+
closeLog: log.close,
|
|
82
|
+
stopGraceMs: request.stopGraceMs,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.start();
|
|
88
|
+
|
|
89
|
+
const onAbort = (): void => {
|
|
90
|
+
app.send({ type: 'abort' });
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
94
|
+
|
|
95
|
+
const pumpLog = async (
|
|
96
|
+
stream: ReadableStream<Uint8Array>,
|
|
97
|
+
onLine: (line: string) => void,
|
|
98
|
+
): Promise<void> => {
|
|
99
|
+
try {
|
|
100
|
+
await pipeLines(log, stream, onLine);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
app.send({ type: 'log.failed', reason: toWringerError(error).message });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
logs.resolve(
|
|
107
|
+
Promise.all([
|
|
108
|
+
pumpLog(child.stdout, (line) => {
|
|
109
|
+
const endpoints = request.plan.ready(parseLaunchLine(line));
|
|
110
|
+
|
|
111
|
+
if (endpoints !== null) {
|
|
112
|
+
app.send({ type: 'ready', endpoints });
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
pumpLog(child.stderr, () => {
|
|
116
|
+
// The log keeps stderr; only stdout carries the ready line.
|
|
117
|
+
}),
|
|
118
|
+
]),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const watchExit = async (): Promise<void> => {
|
|
122
|
+
const exitCode = await child.exited;
|
|
123
|
+
|
|
124
|
+
app.send({ type: 'exited', exitCode });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
void watchExit();
|
|
128
|
+
|
|
129
|
+
const started = await waitForStart(app, request.signal, onAbort);
|
|
130
|
+
|
|
131
|
+
const endpoints = match(started.start)
|
|
132
|
+
.with({ kind: 'ready' }, (ready) => ready.endpoints)
|
|
133
|
+
.with({ kind: 'failed' }, (failed) => {
|
|
134
|
+
throw buildStartFailure(failed.failure, pickStopFailure(started), request.onStopFailed);
|
|
135
|
+
})
|
|
136
|
+
.with({ kind: 'pending' }, () => {
|
|
137
|
+
throw new WringerError('internal', { message: 'The app settled before its start did.' });
|
|
138
|
+
})
|
|
139
|
+
.exhaustive();
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
endpoints,
|
|
143
|
+
stop: async () => {
|
|
144
|
+
app.send({ type: 'stop' });
|
|
145
|
+
|
|
146
|
+
const done = await waitFor(app, (snapshot) => snapshot.hasTag('done'), {
|
|
147
|
+
timeout: Infinity,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const failure = pickStopFailure(done.context);
|
|
151
|
+
|
|
152
|
+
if (failure !== null) {
|
|
153
|
+
throw failure;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function createLogFolder(host: LaunchHost, logPath: string): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
await host.createDirectory(dirname(logPath));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new WringerError('environment', {
|
|
164
|
+
message: `The folder for the app log could not be created: ${String(error)}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function spawnApp(host: LaunchHost, argv: readonly string[], options: AppSpawnOptions): AppProcess {
|
|
170
|
+
try {
|
|
171
|
+
return host.spawnApp(argv, options);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw buildSpawnError(argv.join(' '), error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface AppStop {
|
|
178
|
+
readonly host: LaunchHost;
|
|
179
|
+
readonly plan: LaunchPlan;
|
|
180
|
+
readonly cwd: string;
|
|
181
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
182
|
+
readonly child: AppProcess;
|
|
183
|
+
readonly logged: Promise<unknown>;
|
|
184
|
+
readonly closeLog: () => Promise<void>;
|
|
185
|
+
readonly stopGraceMs: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A failed stop still closes the log, so a service that reads the log once the stop settles, such
|
|
189
|
+
// as a stream recorder, reads its last line.
|
|
190
|
+
async function stopApp(stop: AppStop): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
await signalApp(stop);
|
|
193
|
+
} finally {
|
|
194
|
+
await stop.closeLog();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const logDrainMs = 2000;
|
|
199
|
+
|
|
200
|
+
// The longest a stop takes: the stop command's grace, the app's grace before SIGKILL, then the
|
|
201
|
+
// drain of its log.
|
|
202
|
+
export const appStopLimitMs = 2 * appStopGraceMs + logDrainMs;
|
|
203
|
+
|
|
204
|
+
// A hung stop command gives way to SIGTERM after the grace, and SIGKILL another grace later. The
|
|
205
|
+
// stop ends once the app's last lines reach its log; a process outside the group can hold the
|
|
206
|
+
// pipes open, so that wait has a bound.
|
|
207
|
+
async function signalApp(stop: AppStop): Promise<void> {
|
|
208
|
+
if (stop.plan.stop !== undefined) {
|
|
209
|
+
const stopping = runStopCommand(stop.host, {
|
|
210
|
+
argv: stop.plan.stop,
|
|
211
|
+
cwd: stop.cwd,
|
|
212
|
+
env: stop.env,
|
|
213
|
+
signal: AbortSignal.timeout(stop.stopGraceMs),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await Promise.allSettled([raceDeadline(stopping, AbortSignal.timeout(stop.stopGraceMs))]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
stop.host.killProcessGroup(stop.child.pid, 'SIGTERM');
|
|
220
|
+
|
|
221
|
+
const forced = setTimeout(() => {
|
|
222
|
+
stop.host.killProcessGroup(stop.child.pid);
|
|
223
|
+
}, stop.stopGraceMs);
|
|
224
|
+
|
|
225
|
+
await stop.child.exited;
|
|
226
|
+
|
|
227
|
+
clearTimeout(forced);
|
|
228
|
+
|
|
229
|
+
stop.host.killProcessGroup(stop.child.pid);
|
|
230
|
+
|
|
231
|
+
const [drained] = await Promise.allSettled([
|
|
232
|
+
raceDeadline(stop.logged, AbortSignal.timeout(logDrainMs)),
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
if (drained.status === 'rejected') {
|
|
236
|
+
throw new WringerError('environment', {
|
|
237
|
+
message: `The app's output still had not reached its log ${logDrainMs} ms after it exited.`,
|
|
238
|
+
hint: 'A process the app started outside its process group may still hold its output open.',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A failed stop command must not keep the app alive, so its failure is dropped: the SIGTERM and
|
|
244
|
+
// SIGKILL that follow end the app whatever it did.
|
|
245
|
+
async function runStopCommand(host: LaunchHost, request: ProcessRequest): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
await host.runProcess(request);
|
|
248
|
+
} catch {
|
|
249
|
+
// Dropped on purpose; see above.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function pipeLines(
|
|
254
|
+
log: LogWriter,
|
|
255
|
+
stream: ReadableStream<Uint8Array>,
|
|
256
|
+
onLine: (line: string) => void,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
// One append per chunk: an app that prints many lines as it stops would otherwise pay one
|
|
259
|
+
// append per line, and miss the bound on the stop's wait for its log.
|
|
260
|
+
for await (const lines of readStreamLineBatches(stream)) {
|
|
261
|
+
await log.append(`${lines.join('\n')}\n`);
|
|
262
|
+
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
onLine(line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface StartedApp {
|
|
270
|
+
readonly start: AppStart;
|
|
271
|
+
readonly logFailure: string | null;
|
|
272
|
+
readonly stopFailure: WringerError | null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function waitForStart(
|
|
276
|
+
app: Actor<typeof appMachine>,
|
|
277
|
+
signal: AbortSignal,
|
|
278
|
+
onAbort: () => void,
|
|
279
|
+
): Promise<StartedApp> {
|
|
280
|
+
try {
|
|
281
|
+
const started = await waitFor(app, (snapshot) => snapshot.hasTag('settled'), {
|
|
282
|
+
timeout: Infinity,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return started.context;
|
|
286
|
+
} finally {
|
|
287
|
+
signal.removeEventListener('abort', onAbort);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The startup failure stays the error; a failed stop adds what may still hold the app, and goes to
|
|
292
|
+
// the caller on its own too.
|
|
293
|
+
function buildStartFailure(
|
|
294
|
+
startFailure: WringerError,
|
|
295
|
+
stopFailure: WringerError | null,
|
|
296
|
+
onStopFailed: (reason: string) => void,
|
|
297
|
+
): WringerError {
|
|
298
|
+
if (stopFailure === null) {
|
|
299
|
+
return startFailure;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
onStopFailed(stopFailure.message);
|
|
303
|
+
|
|
304
|
+
return new WringerError(startFailure.code, {
|
|
305
|
+
message: `${startFailure.message} ${stopFailure.message}`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface StopOutcome {
|
|
310
|
+
readonly logFailure: string | null;
|
|
311
|
+
readonly stopFailure: WringerError | null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A failed stop names what may still be up; with none, output that missed the log still fails it.
|
|
315
|
+
function pickStopFailure(outcome: StopOutcome): WringerError | null {
|
|
316
|
+
if (outcome.stopFailure !== null) {
|
|
317
|
+
return outcome.stopFailure;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (outcome.logFailure !== null) {
|
|
321
|
+
return new WringerError('environment', {
|
|
322
|
+
message: `The app's output could not be logged: ${outcome.logFailure}`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { buildServiceKey } from './build-service-key.ts';
|
|
2
|
+
import { collectDriverConfigs } from './collect-driver-configs.ts';
|
|
3
|
+
import { findCollidingServiceKey } from './find-colliding-service-key.ts';
|
|
4
|
+
import { makeDriverExec } from './make-fenced-exec.ts';
|
|
5
|
+
import type { Endpoints } from './run-events.ts';
|
|
6
|
+
import type { RunHandle, ServiceInstance, TargetDefinition, Wringer } from './types.ts';
|
|
7
|
+
import { WringerError } from './wringer-error.ts';
|
|
8
|
+
|
|
9
|
+
export interface ServicesRequest {
|
|
10
|
+
readonly wringer: Wringer;
|
|
11
|
+
readonly targetName: string;
|
|
12
|
+
readonly target: TargetDefinition;
|
|
13
|
+
readonly endpoints: Endpoints;
|
|
14
|
+
readonly run: RunHandle;
|
|
15
|
+
readonly hint: string;
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
|
|
18
|
+
// Ends the commands a service runs through its exec. It outlives `signal`, so a service's stop
|
|
19
|
+
// can still run a command after the startup was cancelled.
|
|
20
|
+
readonly commandSignal: AbortSignal;
|
|
21
|
+
|
|
22
|
+
// Also ends the commands a service runs while it starts, once the stop gives up on the startup.
|
|
23
|
+
readonly startupCommandSignal: AbortSignal;
|
|
24
|
+
readonly started: Map<string, ServiceInstance>;
|
|
25
|
+
readonly onReady: (key: string) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Each service lands in `started` as soon as it is up, so a later failure still stops it.
|
|
29
|
+
export async function startServices(request: ServicesRequest): Promise<void> {
|
|
30
|
+
const entries = collectDriverConfigs(request.wringer, request.target, {
|
|
31
|
+
target: request.targetName,
|
|
32
|
+
endpoints: request.endpoints,
|
|
33
|
+
run: request.run,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// A second service under one key would replace the first in `started`, so no stop reaches it.
|
|
37
|
+
const collision = findCollidingServiceKey(entries);
|
|
38
|
+
|
|
39
|
+
if (collision !== null) {
|
|
40
|
+
throw new WringerError('usage', {
|
|
41
|
+
message: `Two driver services share the key ${collision}.`,
|
|
42
|
+
hint: 'Rename a driver or a service, so each `<driver>/<service>` key names one service.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
for (const service of entry.serviceNames) {
|
|
48
|
+
const commands = makeServiceCommands(request);
|
|
49
|
+
|
|
50
|
+
const exec = makeDriverExec({
|
|
51
|
+
host: request.target.host,
|
|
52
|
+
access: request.target.access,
|
|
53
|
+
hint: request.hint,
|
|
54
|
+
signal: commands.signal,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const instance = await entry.configured
|
|
58
|
+
.startService(service, {
|
|
59
|
+
target: request.targetName,
|
|
60
|
+
access: request.target.access,
|
|
61
|
+
endpoints: request.endpoints,
|
|
62
|
+
run: request.run,
|
|
63
|
+
host: request.target.host,
|
|
64
|
+
exec,
|
|
65
|
+
signal: request.signal,
|
|
66
|
+
})
|
|
67
|
+
.finally(commands.endStartup);
|
|
68
|
+
|
|
69
|
+
const key = buildServiceKey(entry.name, service);
|
|
70
|
+
|
|
71
|
+
// A service that came up after the abort still goes in `started`, so the stop reaches it.
|
|
72
|
+
request.started.set(key, instance);
|
|
73
|
+
request.signal.throwIfAborted();
|
|
74
|
+
|
|
75
|
+
await request.onReady(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ServiceCommands {
|
|
81
|
+
readonly signal: AbortSignal;
|
|
82
|
+
|
|
83
|
+
// Detaches the startup signal once the service's start settles.
|
|
84
|
+
readonly endStartup: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeServiceCommands(request: ServicesRequest): ServiceCommands {
|
|
88
|
+
const commands = new AbortController();
|
|
89
|
+
|
|
90
|
+
const end = (): void => {
|
|
91
|
+
commands.abort();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const signal of [request.commandSignal, request.startupCommandSignal]) {
|
|
95
|
+
signal.addEventListener('abort', end, { once: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (request.commandSignal.aborted || request.startupCommandSignal.aborted) {
|
|
99
|
+
end();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
signal: commands.signal,
|
|
104
|
+
endStartup: () => {
|
|
105
|
+
request.startupCommandSignal.removeEventListener('abort', end);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { and, assign, fromPromise, not, raise, setup } from 'xstate';
|
|
2
|
+
import { toWringerError } from './to-wringer-error.ts';
|
|
3
|
+
import type { WringerError } from './wringer-error.ts';
|
|
4
|
+
|
|
5
|
+
export interface ShutDownPlan {
|
|
6
|
+
readonly crashReason: string | null;
|
|
7
|
+
|
|
8
|
+
// True when the stop stopped waiting for a startup that ignored the abort.
|
|
9
|
+
readonly gaveUpOnStartup: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The supervisor's I/O, which the machine runs as actors and actions.
|
|
13
|
+
interface SupervisorMachineInput {
|
|
14
|
+
readonly stopWaitMs: number;
|
|
15
|
+
readonly startUp: () => Promise<void>;
|
|
16
|
+
|
|
17
|
+
// Tells the startup to give up, so a stop during startup ends it early.
|
|
18
|
+
readonly abortStartup: () => void;
|
|
19
|
+
readonly shutDown: (plan: ShutDownPlan) => Promise<WringerError | null>;
|
|
20
|
+
|
|
21
|
+
// Stops each part that came up after the stop stopped waiting for it.
|
|
22
|
+
readonly cleanUpLate: () => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SupervisorContext extends SupervisorMachineInput {
|
|
26
|
+
readonly crashReason: string | null;
|
|
27
|
+
readonly gaveUpOnStartup: boolean;
|
|
28
|
+
readonly startupSettled: boolean;
|
|
29
|
+
|
|
30
|
+
// The failure of the final log write, which each stop's caller receives.
|
|
31
|
+
readonly failure: WringerError | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type SupervisorEvent =
|
|
35
|
+
| { readonly type: 'stop'; readonly reason: string | null }
|
|
36
|
+
| { readonly type: 'startup.done' }
|
|
37
|
+
| { readonly type: 'startup.failed'; readonly reason: string };
|
|
38
|
+
|
|
39
|
+
const supervisorTypes: {
|
|
40
|
+
context?: SupervisorContext;
|
|
41
|
+
events?: SupervisorEvent;
|
|
42
|
+
input?: SupervisorMachineInput;
|
|
43
|
+
} = {};
|
|
44
|
+
|
|
45
|
+
interface RunInput<Input, Output> {
|
|
46
|
+
readonly run: (input: Input) => Promise<Output>;
|
|
47
|
+
readonly input: Input;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The run is up once startup is done, and the first stop or crash names the reason. A stop during
|
|
51
|
+
// startup waits a bounded time for the startup; a part that comes up after that still stops, once
|
|
52
|
+
// the run's log is closed. Each state in which the run's log is closed carries the `closed` tag.
|
|
53
|
+
export const supervisorMachine = setup({
|
|
54
|
+
types: supervisorTypes,
|
|
55
|
+
actors: {
|
|
56
|
+
startUp: fromPromise<null, RunInput<null, void>>(async (args) => {
|
|
57
|
+
await args.input.run(null);
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}),
|
|
61
|
+
shutDown: fromPromise<WringerError | null, RunInput<ShutDownPlan, WringerError | null>>(
|
|
62
|
+
(args) => args.input.run(args.input.input),
|
|
63
|
+
),
|
|
64
|
+
cleanUpLate: fromPromise<null, RunInput<null, void>>(async (args) => {
|
|
65
|
+
await args.input.run(null);
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
delays: {
|
|
71
|
+
stopWait: (args) => args.context.stopWaitMs,
|
|
72
|
+
},
|
|
73
|
+
guards: {
|
|
74
|
+
gaveUpOnStartup: (args) => args.context.gaveUpOnStartup,
|
|
75
|
+
startupSettled: (args) => args.context.startupSettled,
|
|
76
|
+
},
|
|
77
|
+
actions: {
|
|
78
|
+
keepStopReason: assign({
|
|
79
|
+
crashReason: (args) => (args.event.type === 'stop' ? args.event.reason : null),
|
|
80
|
+
}),
|
|
81
|
+
keepStartupFailure: assign({
|
|
82
|
+
crashReason: (args) => (args.event.type === 'startup.failed' ? args.event.reason : null),
|
|
83
|
+
}),
|
|
84
|
+
abortStartup: (args) => {
|
|
85
|
+
args.context.abortStartup();
|
|
86
|
+
},
|
|
87
|
+
giveUpOnStartup: assign({ gaveUpOnStartup: true }),
|
|
88
|
+
settleStartup: assign({ startupSettled: true }),
|
|
89
|
+
},
|
|
90
|
+
}).createMachine({
|
|
91
|
+
context: (args) => ({
|
|
92
|
+
...args.input,
|
|
93
|
+
crashReason: null,
|
|
94
|
+
gaveUpOnStartup: false,
|
|
95
|
+
startupSettled: false,
|
|
96
|
+
failure: null,
|
|
97
|
+
}),
|
|
98
|
+
invoke: {
|
|
99
|
+
// oxlint-disable-next-line zgeoff/no-abbreviated-names -- xstate names this key `src`
|
|
100
|
+
src: 'startUp',
|
|
101
|
+
input: (args) => ({ run: args.context.startUp, input: null }),
|
|
102
|
+
onDone: { actions: ['settleStartup', raise({ type: 'startup.done' })] },
|
|
103
|
+
onError: {
|
|
104
|
+
actions: [
|
|
105
|
+
'settleStartup',
|
|
106
|
+
raise((args) => ({
|
|
107
|
+
type: 'startup.failed' as const,
|
|
108
|
+
reason: toWringerError(args.event.error).message,
|
|
109
|
+
})),
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
initial: 'starting',
|
|
114
|
+
states: {
|
|
115
|
+
starting: {
|
|
116
|
+
on: {
|
|
117
|
+
stop: { target: 'waitingForStartup', actions: 'keepStopReason' },
|
|
118
|
+
'startup.done': 'running',
|
|
119
|
+
'startup.failed': { target: 'shuttingDown', actions: 'keepStartupFailure' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
running: {
|
|
123
|
+
on: { stop: { target: 'shuttingDown', actions: 'keepStopReason' } },
|
|
124
|
+
},
|
|
125
|
+
waitingForStartup: {
|
|
126
|
+
entry: 'abortStartup',
|
|
127
|
+
after: { stopWait: { target: 'shuttingDown', actions: 'giveUpOnStartup' } },
|
|
128
|
+
on: { 'startup.done': 'shuttingDown', 'startup.failed': 'shuttingDown' },
|
|
129
|
+
},
|
|
130
|
+
shuttingDown: {
|
|
131
|
+
entry: 'abortStartup',
|
|
132
|
+
invoke: {
|
|
133
|
+
// oxlint-disable-next-line zgeoff/no-abbreviated-names -- xstate names this key `src`
|
|
134
|
+
src: 'shutDown',
|
|
135
|
+
input: (args) => ({
|
|
136
|
+
run: args.context.shutDown,
|
|
137
|
+
input: {
|
|
138
|
+
crashReason: args.context.crashReason,
|
|
139
|
+
gaveUpOnStartup: args.context.gaveUpOnStartup,
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
onDone: { target: 'closed', actions: assign({ failure: (args) => args.event.output }) },
|
|
143
|
+
onError: {
|
|
144
|
+
target: 'closed',
|
|
145
|
+
actions: assign({ failure: (args) => toWringerError(args.event.error) }),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
closed: {
|
|
150
|
+
tags: ['closed'],
|
|
151
|
+
always: [
|
|
152
|
+
{ guard: and(['gaveUpOnStartup', 'startupSettled']), target: 'cleaningUpLate' },
|
|
153
|
+
{ guard: not('gaveUpOnStartup'), target: 'ended' },
|
|
154
|
+
],
|
|
155
|
+
on: { 'startup.done': 'cleaningUpLate', 'startup.failed': 'cleaningUpLate' },
|
|
156
|
+
},
|
|
157
|
+
cleaningUpLate: {
|
|
158
|
+
tags: ['closed'],
|
|
159
|
+
invoke: {
|
|
160
|
+
// oxlint-disable-next-line zgeoff/no-abbreviated-names -- xstate names this key `src`
|
|
161
|
+
src: 'cleanUpLate',
|
|
162
|
+
input: (args) => ({ run: args.context.cleanUpLate, input: null }),
|
|
163
|
+
onDone: 'ended',
|
|
164
|
+
onError: 'ended',
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
ended: { tags: ['closed'], type: 'final' },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { envelopeErrorSchema } from './envelope.ts';
|
|
3
|
+
import { runIDSchema } from './run-events.ts';
|
|
4
|
+
|
|
5
|
+
// One request per connection, one JSON line each way, so a crashed command never leaves a
|
|
6
|
+
// half-read stream on the supervisor's socket.
|
|
7
|
+
export const supervisorRequestSchema = z.strictObject({
|
|
8
|
+
service: z.string(),
|
|
9
|
+
method: z.string(),
|
|
10
|
+
args: z.unknown(),
|
|
11
|
+
|
|
12
|
+
// The caller's deadline, in ms since the epoch. A call to the supervisor itself has none.
|
|
13
|
+
deadlineAt: z.number().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type SupervisorRequest = z.infer<typeof supervisorRequestSchema>;
|
|
17
|
+
|
|
18
|
+
export const supervisorResponseSchema = z.discriminatedUnion('ok', [
|
|
19
|
+
z.strictObject({ ok: z.literal(true), result: z.unknown() }),
|
|
20
|
+
z.strictObject({ ok: z.literal(false), error: envelopeErrorSchema }),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export type SupervisorResponse = z.infer<typeof supervisorResponseSchema>;
|
|
24
|
+
|
|
25
|
+
export const supervisorServiceName = 'supervisor';
|
|
26
|
+
export const supervisorCommandName = '__wringer-supervisor';
|
|
27
|
+
|
|
28
|
+
// The arguments after the hidden command name, which the starter writes and the supervisor
|
|
29
|
+
// process parses.
|
|
30
|
+
export const supervisorArgsSchema = z
|
|
31
|
+
.tuple([runIDSchema, z.string().min(1), z.string().min(1)])
|
|
32
|
+
.transform(([runID, target, root]) => ({ runID, target, root }));
|
|
33
|
+
|
|
34
|
+
export interface SupervisorArgvInput {
|
|
35
|
+
readonly runID: string;
|
|
36
|
+
readonly target: string;
|
|
37
|
+
readonly root: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildSupervisorArgv(args: SupervisorArgvInput): readonly string[] {
|
|
41
|
+
return [supervisorCommandName, args.runID, args.target, args.root];
|
|
42
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { WringerError } from './wringer-error.ts';
|
|
2
|
+
|
|
3
|
+
// A thrown value that is not a WringerError is a bug in wringer or the repo's code: it becomes the
|
|
4
|
+
// code `internal`, and its stack travels in the error's data to the run's log.
|
|
5
|
+
export function toWringerError(error: unknown): WringerError {
|
|
6
|
+
if (error instanceof WringerError) {
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
11
|
+
return new WringerError('timeout');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stack = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
15
|
+
|
|
16
|
+
return new WringerError('internal', {
|
|
17
|
+
message: `wringer failed with an internal error: ${error instanceof Error ? error.message : String(error)}`,
|
|
18
|
+
data: { stack },
|
|
19
|
+
});
|
|
20
|
+
}
|