@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,117 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import type { Duplex } from 'node:stream';
|
|
3
|
+
import { finished } from 'node:stream/promises';
|
|
4
|
+
import { supervisorRequestSchema } from './supervisor-protocol.ts';
|
|
5
|
+
import type { SupervisorRequest, SupervisorResponse } from './supervisor-protocol.ts';
|
|
6
|
+
import { toWringerError } from './to-wringer-error.ts';
|
|
7
|
+
|
|
8
|
+
export type SupervisorRequestHandler = (
|
|
9
|
+
request: SupervisorRequest,
|
|
10
|
+
signal: AbortSignal,
|
|
11
|
+
) => Promise<unknown>;
|
|
12
|
+
|
|
13
|
+
export interface SupervisorConnectionOptions {
|
|
14
|
+
readonly answerRequest: SupervisorRequestHandler;
|
|
15
|
+
|
|
16
|
+
// A connection that fails, such as one the client resets, ends that connection only.
|
|
17
|
+
readonly onTransportError: (error: unknown) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Answers the first line, then ends the connection, and settles once the answer has flushed. Every
|
|
21
|
+
// failure is caught here. When `closing` aborts, a connection that sent no request yet ends at once,
|
|
22
|
+
// so it cannot keep the process alive.
|
|
23
|
+
export async function answerSupervisorConnection(
|
|
24
|
+
connection: Duplex,
|
|
25
|
+
options: SupervisorConnectionOptions,
|
|
26
|
+
closing: AbortSignal,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
let reported: unknown = null;
|
|
29
|
+
|
|
30
|
+
// The line reader rejects with the error the connection emitted, so each is reported once.
|
|
31
|
+
const fail = (error: unknown): void => {
|
|
32
|
+
if (error !== reported) {
|
|
33
|
+
reported = error;
|
|
34
|
+
|
|
35
|
+
options.onTransportError(error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
connection.destroy();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// A connection error with no listener is thrown, and would end the supervisor.
|
|
42
|
+
connection.on('error', fail);
|
|
43
|
+
|
|
44
|
+
const caller = new AbortController();
|
|
45
|
+
|
|
46
|
+
// A caller that gives up, such as at its deadline, closes the connection before the answer.
|
|
47
|
+
connection.once('close', () => {
|
|
48
|
+
caller.abort();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const lines = createInterface({ input: connection });
|
|
52
|
+
|
|
53
|
+
// readline re-emits the connection's errors, also after the loop below has stopped listening,
|
|
54
|
+
// such as an EPIPE once the caller left.
|
|
55
|
+
lines.on('error', fail);
|
|
56
|
+
|
|
57
|
+
let answering = false;
|
|
58
|
+
|
|
59
|
+
// A destroyed connection never ends the line loop, so the reader closes too.
|
|
60
|
+
const endIdle = (): void => {
|
|
61
|
+
if (!answering) {
|
|
62
|
+
lines.close();
|
|
63
|
+
connection.destroy();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
closing.addEventListener('abort', endIdle, { once: true });
|
|
68
|
+
|
|
69
|
+
if (closing.aborted) {
|
|
70
|
+
endIdle();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
for await (const line of lines) {
|
|
75
|
+
answering = true;
|
|
76
|
+
|
|
77
|
+
const response = await answerLine(line, options.answerRequest, caller.signal);
|
|
78
|
+
|
|
79
|
+
connection.end(`${response}\n`);
|
|
80
|
+
|
|
81
|
+
await finished(connection, { readable: false });
|
|
82
|
+
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
fail(error);
|
|
87
|
+
} finally {
|
|
88
|
+
closing.removeEventListener('abort', endIdle);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A service may return a value JSON cannot hold, such as a bigint, so the result is serialized
|
|
93
|
+
// inside the try and its failure reaches the caller as an error response.
|
|
94
|
+
async function answerLine(
|
|
95
|
+
line: string,
|
|
96
|
+
handle: SupervisorRequestHandler,
|
|
97
|
+
signal: AbortSignal,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
try {
|
|
100
|
+
const request = supervisorRequestSchema.parse(JSON.parse(line));
|
|
101
|
+
|
|
102
|
+
const result = await handle(request, signal);
|
|
103
|
+
|
|
104
|
+
const response: SupervisorResponse = { ok: true, result: result ?? null };
|
|
105
|
+
|
|
106
|
+
return JSON.stringify(response);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const failure = toWringerError(error);
|
|
109
|
+
|
|
110
|
+
const response: SupervisorResponse = {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: { code: failure.code, message: failure.message, hint: failure.hint ?? null },
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return JSON.stringify(response);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { match } from 'ts-pattern';
|
|
2
|
+
import { assign, fromPromise, setup } from 'xstate';
|
|
3
|
+
import type { Endpoints } from './run-events.ts';
|
|
4
|
+
import { toWringerError } from './to-wringer-error.ts';
|
|
5
|
+
import { WringerError } from './wringer-error.ts';
|
|
6
|
+
|
|
7
|
+
// The process and log work of one launched app, which the machine runs as actions and actors.
|
|
8
|
+
interface AppMachineInput {
|
|
9
|
+
readonly readyTimeoutMs: number;
|
|
10
|
+
|
|
11
|
+
// Tells the run the app failed after its start, so the run stops.
|
|
12
|
+
readonly reportCrash: (reason: string) => void;
|
|
13
|
+
|
|
14
|
+
// Kills what is left of the app's process group once the app itself has exited.
|
|
15
|
+
readonly killGroup: () => void;
|
|
16
|
+
|
|
17
|
+
// Runs the stop command, signals the app, waits for it, then waits a bounded time for its log.
|
|
18
|
+
readonly stopApp: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AppStart =
|
|
22
|
+
| { readonly kind: 'pending' }
|
|
23
|
+
| { readonly kind: 'ready'; readonly endpoints: Endpoints }
|
|
24
|
+
| { readonly kind: 'failed'; readonly failure: WringerError };
|
|
25
|
+
|
|
26
|
+
interface AppContext extends AppMachineInput {
|
|
27
|
+
readonly start: AppStart;
|
|
28
|
+
|
|
29
|
+
// Why the app's output could not reach its log, at any point of its life.
|
|
30
|
+
readonly logFailure: string | null;
|
|
31
|
+
readonly stopFailure: WringerError | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type AppEvent =
|
|
35
|
+
| { readonly type: 'ready'; readonly endpoints: Endpoints }
|
|
36
|
+
| { readonly type: 'exited'; readonly exitCode: number }
|
|
37
|
+
| { readonly type: 'log.failed'; readonly reason: string }
|
|
38
|
+
| { readonly type: 'abort' }
|
|
39
|
+
| { readonly type: 'stop' };
|
|
40
|
+
|
|
41
|
+
const appTypes: { context?: AppContext; events?: AppEvent; input?: AppMachineInput } = {};
|
|
42
|
+
const stoppedBeforeReady = 'The run stopped before the app was ready.';
|
|
43
|
+
|
|
44
|
+
// An exit or a lost log fails a start that is not ready, and is a crash the run hears of once after
|
|
45
|
+
// it. Each stop waits for the app and its log. The caller waits for the tags: `settled` once the
|
|
46
|
+
// start is decided, and `done` once the app is down.
|
|
47
|
+
export const appMachine = setup({
|
|
48
|
+
types: appTypes,
|
|
49
|
+
actors: {
|
|
50
|
+
stopApp: fromPromise<null, { readonly run: () => Promise<void> }>(async (args) => {
|
|
51
|
+
await args.input.run();
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
delays: {
|
|
57
|
+
readyTimeout: (args) => args.context.readyTimeoutMs,
|
|
58
|
+
},
|
|
59
|
+
actions: {
|
|
60
|
+
keepEndpoints: assign({
|
|
61
|
+
start: (args): AppStart =>
|
|
62
|
+
args.event.type === 'ready'
|
|
63
|
+
? { kind: 'ready', endpoints: args.event.endpoints }
|
|
64
|
+
: args.context.start,
|
|
65
|
+
}),
|
|
66
|
+
keepLogFailure: assign({
|
|
67
|
+
logFailure: (args) =>
|
|
68
|
+
args.event.type === 'log.failed' ? args.event.reason : args.context.logFailure,
|
|
69
|
+
}),
|
|
70
|
+
failStart: assign({
|
|
71
|
+
start: (args): AppStart => ({
|
|
72
|
+
kind: 'failed',
|
|
73
|
+
failure: new WringerError('environment', { message: describeFailure(args.event) }),
|
|
74
|
+
}),
|
|
75
|
+
}),
|
|
76
|
+
failStartOnTimeout: assign({
|
|
77
|
+
start: (args): AppStart => ({
|
|
78
|
+
kind: 'failed',
|
|
79
|
+
failure: new WringerError('timeout', {
|
|
80
|
+
message: `The app printed no ready line within ${args.context.readyTimeoutMs} ms.`,
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
reportCrash: (args) => {
|
|
85
|
+
args.context.reportCrash(describeFailure(args.event));
|
|
86
|
+
},
|
|
87
|
+
killGroup: (args) => {
|
|
88
|
+
args.context.killGroup();
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}).createMachine({
|
|
92
|
+
context: (args) => ({
|
|
93
|
+
...args.input,
|
|
94
|
+
start: { kind: 'pending' },
|
|
95
|
+
logFailure: null,
|
|
96
|
+
stopFailure: null,
|
|
97
|
+
}),
|
|
98
|
+
initial: 'starting',
|
|
99
|
+
states: {
|
|
100
|
+
starting: {
|
|
101
|
+
after: { readyTimeout: { target: 'failingStart', actions: 'failStartOnTimeout' } },
|
|
102
|
+
on: {
|
|
103
|
+
ready: { target: 'running', actions: 'keepEndpoints' },
|
|
104
|
+
exited: { target: 'failingStart', actions: ['killGroup', 'failStart', 'reportCrash'] },
|
|
105
|
+
|
|
106
|
+
// The start failure names this log failure; only a later one fails the stop as well.
|
|
107
|
+
'log.failed': { target: 'failingStart', actions: ['failStart', 'reportCrash'] },
|
|
108
|
+
abort: { target: 'failingStart', actions: 'failStart' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
failingStart: {
|
|
112
|
+
invoke: {
|
|
113
|
+
// oxlint-disable-next-line zgeoff/no-abbreviated-names -- xstate names this key `src`
|
|
114
|
+
src: 'stopApp',
|
|
115
|
+
input: (args) => ({ run: args.context.stopApp }),
|
|
116
|
+
onDone: 'startFailed',
|
|
117
|
+
onError: {
|
|
118
|
+
target: 'startFailed',
|
|
119
|
+
actions: assign({ stopFailure: (args) => toWringerError(args.event.error) }),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
on: {
|
|
123
|
+
exited: { actions: 'killGroup' },
|
|
124
|
+
'log.failed': { actions: 'keepLogFailure' },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
startFailed: { tags: ['settled', 'done'], type: 'final' },
|
|
128
|
+
running: {
|
|
129
|
+
tags: ['settled'],
|
|
130
|
+
on: {
|
|
131
|
+
exited: { target: 'crashed', actions: ['killGroup', 'reportCrash'] },
|
|
132
|
+
'log.failed': { target: 'crashed', actions: ['keepLogFailure', 'reportCrash'] },
|
|
133
|
+
stop: 'stopping',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
crashed: {
|
|
137
|
+
tags: ['settled'],
|
|
138
|
+
on: {
|
|
139
|
+
exited: { actions: 'killGroup' },
|
|
140
|
+
'log.failed': { actions: 'keepLogFailure' },
|
|
141
|
+
stop: 'stopping',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
stopping: {
|
|
145
|
+
tags: ['settled'],
|
|
146
|
+
invoke: {
|
|
147
|
+
// oxlint-disable-next-line zgeoff/no-abbreviated-names -- xstate names this key `src`
|
|
148
|
+
src: 'stopApp',
|
|
149
|
+
input: (args) => ({ run: args.context.stopApp }),
|
|
150
|
+
onDone: 'stopped',
|
|
151
|
+
onError: {
|
|
152
|
+
target: 'stopped',
|
|
153
|
+
actions: assign({ stopFailure: (args) => toWringerError(args.event.error) }),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
on: {
|
|
157
|
+
exited: { actions: 'killGroup' },
|
|
158
|
+
'log.failed': { actions: 'keepLogFailure' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
stopped: { tags: ['settled', 'done'], type: 'final' },
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function describeFailure(event: AppEvent): string {
|
|
166
|
+
return match(event)
|
|
167
|
+
.with({ type: 'exited' }, (exited) => `The app exited with code ${exited.exitCode}.`)
|
|
168
|
+
.with(
|
|
169
|
+
{ type: 'log.failed' },
|
|
170
|
+
(failed) => `The app's output could not be logged: ${failed.reason}`,
|
|
171
|
+
)
|
|
172
|
+
.with({ type: 'ready' }, { type: 'abort' }, { type: 'stop' }, () => stoppedBeforeReady)
|
|
173
|
+
.exhaustive();
|
|
174
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { withFileLock } from '@zgeoff/std/with-file-lock';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import type { StateFileSystem } from './create-node-state-file-system.ts';
|
|
5
|
+
import { getLockValue } from './get-lock-value.ts';
|
|
6
|
+
import { parseRunLog } from './parse-run-log.ts';
|
|
7
|
+
import type { RunEvent, RunEventType } from './run-events.ts';
|
|
8
|
+
import { WringerError } from './wringer-error.ts';
|
|
9
|
+
|
|
10
|
+
export interface AppendRunEventRequest {
|
|
11
|
+
readonly fs: StateFileSystem;
|
|
12
|
+
readonly logPath: string;
|
|
13
|
+
readonly lockPath: string;
|
|
14
|
+
readonly lockTimeoutMs: number;
|
|
15
|
+
readonly line: string;
|
|
16
|
+
readonly event: RunEvent;
|
|
17
|
+
|
|
18
|
+
// Decides from the log as it stands under the lock whether the event may still be written.
|
|
19
|
+
readonly refuse: ((events: readonly RunEvent[]) => WringerError | null) | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The only writer of a run's log, under the run's lock. Resolves whether it wrote, and rejects
|
|
23
|
+
// with the refusal when `refuse` returns one.
|
|
24
|
+
export async function appendRunEvent(request: AppendRunEventRequest): Promise<boolean> {
|
|
25
|
+
await request.fs.createDirectory(dirname(request.lockPath));
|
|
26
|
+
|
|
27
|
+
const result = await withFileLock(
|
|
28
|
+
request.lockPath,
|
|
29
|
+
async () => {
|
|
30
|
+
const read = await request.fs.readText(request.logPath);
|
|
31
|
+
|
|
32
|
+
const text = read ?? '';
|
|
33
|
+
const refusal = request.refuse === null ? null : request.refuse(parseRunLog(text));
|
|
34
|
+
|
|
35
|
+
if (refusal !== null) {
|
|
36
|
+
return refusal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const plan = pickLogAppend(text, request.event);
|
|
40
|
+
|
|
41
|
+
if (plan.truncateBytes !== null) {
|
|
42
|
+
await request.fs.truncateFile(request.logPath, plan.truncateBytes);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (plan.append) {
|
|
46
|
+
await request.fs.appendText(request.logPath, request.line);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return plan.append;
|
|
50
|
+
},
|
|
51
|
+
{ waitMs: request.lockTimeoutMs },
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const written = getLockValue(request.lockPath, result);
|
|
55
|
+
|
|
56
|
+
if (written instanceof WringerError) {
|
|
57
|
+
throw written;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return written;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface LogAppendPlan {
|
|
64
|
+
readonly truncateBytes: number | null;
|
|
65
|
+
readonly append: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A writer killed mid-line leaves a torn last line, which is cut so the new event starts a line of
|
|
69
|
+
// its own. An event whose status move the table refuses, such as a late `crashed` after
|
|
70
|
+
// `run-ended`, is dropped.
|
|
71
|
+
function pickLogAppend(text: string, event: RunEvent): LogAppendPlan {
|
|
72
|
+
const whole = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
73
|
+
const truncateBytes = whole.length < text.length ? Buffer.byteLength(whole) : null;
|
|
74
|
+
const status = foldRunStatus(parseRunLog(whole));
|
|
75
|
+
const move = findRunStatusMove(event.type);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
truncateBytes,
|
|
79
|
+
append: status === null || move === null || canMoveRunStatus(status, move),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type RunStatus = 'starting' | 'running' | 'crashed' | 'ended';
|
|
84
|
+
|
|
85
|
+
// Null for a log that has not started yet.
|
|
86
|
+
function foldRunStatus(events: readonly RunEvent[]): RunStatus | null {
|
|
87
|
+
const [first, ...rest] = events;
|
|
88
|
+
|
|
89
|
+
if (first?.type !== 'run-started') {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return rest.reduce<RunStatus>((status, event) => {
|
|
94
|
+
const move = findRunStatusMove(event.type);
|
|
95
|
+
|
|
96
|
+
return move !== null && canMoveRunStatus(status, move) ? move : status;
|
|
97
|
+
}, 'starting');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function findRunStatusMove(eventType: RunEventType): RunStatus | null {
|
|
101
|
+
return match(eventType)
|
|
102
|
+
.returnType<RunStatus | null>()
|
|
103
|
+
.with('run-ready', () => 'running')
|
|
104
|
+
.with('crashed', () => 'crashed')
|
|
105
|
+
.with('run-ended', () => 'ended')
|
|
106
|
+
.with(
|
|
107
|
+
'run-started',
|
|
108
|
+
'supervisor-ready',
|
|
109
|
+
'launch-ready',
|
|
110
|
+
'service-ready',
|
|
111
|
+
'doctor-passed',
|
|
112
|
+
'doctor-failed',
|
|
113
|
+
'envelope',
|
|
114
|
+
'internal-error',
|
|
115
|
+
'late-failure',
|
|
116
|
+
() => null,
|
|
117
|
+
)
|
|
118
|
+
.exhaustive();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A run's status lives only in its log, as events. These are the moves an event may make.
|
|
122
|
+
const runStatusMoves: Readonly<Record<RunStatus, readonly RunStatus[]>> = {
|
|
123
|
+
starting: ['running', 'crashed', 'ended'],
|
|
124
|
+
running: ['crashed', 'ended'],
|
|
125
|
+
crashed: ['ended'],
|
|
126
|
+
ended: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function canMoveRunStatus(from: RunStatus, to: RunStatus): boolean {
|
|
130
|
+
return runStatusMoves[from].includes(to);
|
|
131
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionDefinition,
|
|
3
|
+
AnyDrivers,
|
|
4
|
+
CheckDefinition,
|
|
5
|
+
CommandSpec,
|
|
6
|
+
TargetDefinition,
|
|
7
|
+
} from './types.ts';
|
|
8
|
+
|
|
9
|
+
export interface BoundHelpers<Ds extends AnyDrivers> {
|
|
10
|
+
readonly drivers: Ds;
|
|
11
|
+
readonly defineAction: <Input, Data>(
|
|
12
|
+
action: ActionDefinition<Input, Data, Ds>,
|
|
13
|
+
) => ActionDefinition<Input, Data, Ds>;
|
|
14
|
+
readonly defineTarget: <Target extends TargetDefinition<Ds>>(target: Target) => Target;
|
|
15
|
+
readonly defineCommand: <Args, Data>(
|
|
16
|
+
spec: CommandSpec<Args, Data, Ds>,
|
|
17
|
+
) => CommandSpec<Args, Data, Ds>;
|
|
18
|
+
readonly defineCheck: <Input, Data>(
|
|
19
|
+
check: CheckDefinition<Input, Data, Ds>,
|
|
20
|
+
) => CheckDefinition<Input, Data, Ds>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The repo binds its helpers once, in its drivers file, so every action, target, command and check
|
|
24
|
+
// it defines elsewhere is typed against its own drivers without naming them again.
|
|
25
|
+
export function bindDrivers<const Ds extends AnyDrivers>(drivers: Ds): BoundHelpers<Ds> {
|
|
26
|
+
return {
|
|
27
|
+
drivers,
|
|
28
|
+
defineAction: (action) => action,
|
|
29
|
+
defineTarget: (target) => target,
|
|
30
|
+
defineCommand: (spec) => spec,
|
|
31
|
+
defineCheck: (check) => check,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { prepareArgs } from './prepare-args.ts';
|
|
2
|
+
import type {
|
|
3
|
+
ActionDefinition,
|
|
4
|
+
AnyDrivers,
|
|
5
|
+
BuiltinCommandSpec,
|
|
6
|
+
CheckDefinition,
|
|
7
|
+
CommandSpec,
|
|
8
|
+
DriveContext,
|
|
9
|
+
DriverClients,
|
|
10
|
+
RegisteredCommand,
|
|
11
|
+
RunnableRepoCheck,
|
|
12
|
+
} from './types.ts';
|
|
13
|
+
import { WringerError } from './wringer-error.ts';
|
|
14
|
+
|
|
15
|
+
// Each function here closes over one command's own types and hands the registry an erased entry,
|
|
16
|
+
// so commands of every argument type share one map without a type assertion.
|
|
17
|
+
export function buildBuiltinCommand<Args, Data>(
|
|
18
|
+
spec: BuiltinCommandSpec<Args, Data>,
|
|
19
|
+
): RegisteredCommand {
|
|
20
|
+
return {
|
|
21
|
+
kind: 'builtin',
|
|
22
|
+
info: spec,
|
|
23
|
+
prepare: (raw) =>
|
|
24
|
+
prepareArgs(spec.args, raw, (args) => ({
|
|
25
|
+
effect: spec.effect ?? 'write',
|
|
26
|
+
run: (ctx) => spec.run(args, ctx),
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildRepoCommand<Args, Data, Ds extends AnyDrivers>(
|
|
32
|
+
spec: CommandSpec<Args, Data, Ds>,
|
|
33
|
+
targets: readonly string[] | undefined,
|
|
34
|
+
driverNames: readonly string[],
|
|
35
|
+
): RegisteredCommand {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'repo',
|
|
38
|
+
info: spec,
|
|
39
|
+
targets,
|
|
40
|
+
prepare: (raw) =>
|
|
41
|
+
prepareArgs(spec.args, raw, (args) => ({
|
|
42
|
+
effect: spec.decideEffect?.(args) ?? spec.effect ?? 'write',
|
|
43
|
+
run: (ctx: DriveContext) => spec.run(args, toTypedDriveContext<Ds>(ctx, driverNames)),
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildActionCommand<Input, Data, Ds extends AnyDrivers>(
|
|
49
|
+
action: ActionDefinition<Input, Data, Ds>,
|
|
50
|
+
driverNames: readonly string[],
|
|
51
|
+
): RegisteredCommand {
|
|
52
|
+
return buildRepoCommand<Input, Data, Ds>(
|
|
53
|
+
{
|
|
54
|
+
path: ['action', action.name],
|
|
55
|
+
summary: action.summary,
|
|
56
|
+
args: action.input,
|
|
57
|
+
data: action.data,
|
|
58
|
+
effect: action.effect,
|
|
59
|
+
timeoutMs: action.timeoutMs,
|
|
60
|
+
run: action.run,
|
|
61
|
+
},
|
|
62
|
+
action.targets,
|
|
63
|
+
driverNames,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildRepoCheck<Input, Data, Ds extends AnyDrivers>(
|
|
68
|
+
check: CheckDefinition<Input, Data, Ds>,
|
|
69
|
+
driverNames: readonly string[],
|
|
70
|
+
): RunnableRepoCheck {
|
|
71
|
+
return {
|
|
72
|
+
name: check.name,
|
|
73
|
+
timeoutMs: check.timeoutMs,
|
|
74
|
+
run: async (ctx) => {
|
|
75
|
+
const typed = toTypedDriveContext<Ds>(ctx, driverNames);
|
|
76
|
+
|
|
77
|
+
const data = await check.run(check.input.parse({}), typed);
|
|
78
|
+
|
|
79
|
+
check.data.parse(data);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Dispatch puts a client for every registered driver in `drivers`, and a target that gives a driver
|
|
85
|
+
// no config gets a client that refuses each call, so every name the repo typed is present.
|
|
86
|
+
function toTypedDriveContext<Ds extends AnyDrivers>(
|
|
87
|
+
ctx: DriveContext,
|
|
88
|
+
driverNames: readonly string[],
|
|
89
|
+
): DriveContext<Ds> {
|
|
90
|
+
const clients = ctx.drivers;
|
|
91
|
+
|
|
92
|
+
if (!hasDriverClients<Ds>(clients, driverNames)) {
|
|
93
|
+
throw new WringerError('internal', { message: 'The drive context lacks a driver client.' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ...ctx, drivers: clients };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasDriverClients<Ds extends AnyDrivers>(
|
|
100
|
+
clients: DriveContext['drivers'],
|
|
101
|
+
driverNames: readonly string[],
|
|
102
|
+
): clients is DriverClients<Ds> {
|
|
103
|
+
return driverNames.every((name) => Object.hasOwn(clients, name));
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { envelopeSchema, streamEventSchema } from './envelope.ts';
|
|
3
|
+
|
|
4
|
+
// zod leaves the envelope's two refinements out of the JSON Schema, so they are written here: an
|
|
5
|
+
// envelope has an error exactly when it is not ok, and `truncated` comes with `truncatedPath`.
|
|
6
|
+
const envelopeRules = {
|
|
7
|
+
oneOf: [
|
|
8
|
+
{ type: 'object', properties: { ok: { const: true }, error: { type: 'null' } } },
|
|
9
|
+
{ type: 'object', properties: { ok: { const: false }, error: { type: 'object' } } },
|
|
10
|
+
],
|
|
11
|
+
dependentRequired: { truncated: ['truncatedPath'], truncatedPath: ['truncated'] },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// The published schema covers both lines a command can write: an NDJSON event of a streaming
|
|
15
|
+
// command, and the one envelope that ends every command.
|
|
16
|
+
export function buildEnvelopeJSONSchema(): Record<string, unknown> {
|
|
17
|
+
return {
|
|
18
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
19
|
+
$id: 'https://unpkg.com/@wringer/core/envelope.schema.json',
|
|
20
|
+
title: 'wringer envelope',
|
|
21
|
+
$defs: {
|
|
22
|
+
envelope: { ...buildSubschema(envelopeSchema), ...envelopeRules },
|
|
23
|
+
streamEvent: buildSubschema(streamEventSchema),
|
|
24
|
+
},
|
|
25
|
+
$ref: '#/$defs/envelope',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Only the root of a JSON Schema document may name its dialect.
|
|
30
|
+
function buildSubschema(schema: z.ZodType): Record<string, unknown> {
|
|
31
|
+
const { $schema: _dialect, ...subschema } = z.toJSONSchema(schema, { io: 'output' });
|
|
32
|
+
|
|
33
|
+
return subschema;
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { envelopeVersion } from './envelope-version.ts';
|
|
2
|
+
import type { Envelope, Evidence } from './envelope.ts';
|
|
3
|
+
import type { WringerError } from './wringer-error.ts';
|
|
4
|
+
|
|
5
|
+
export interface EnvelopeInput {
|
|
6
|
+
readonly command: string;
|
|
7
|
+
readonly target: string | null;
|
|
8
|
+
readonly instance: string | null;
|
|
9
|
+
readonly feature: string | null;
|
|
10
|
+
readonly sha: string | null;
|
|
11
|
+
readonly durationMs: number;
|
|
12
|
+
readonly data: unknown;
|
|
13
|
+
readonly evidence: readonly Evidence[];
|
|
14
|
+
readonly cursors: Readonly<Record<string, number>>;
|
|
15
|
+
readonly truncatedPath: string | null;
|
|
16
|
+
readonly error: WringerError | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildEnvelope(input: EnvelopeInput): Envelope {
|
|
20
|
+
return {
|
|
21
|
+
wringer: envelopeVersion,
|
|
22
|
+
ok: input.error === null,
|
|
23
|
+
command: input.command,
|
|
24
|
+
target: input.target,
|
|
25
|
+
instance: input.instance,
|
|
26
|
+
feature: input.feature,
|
|
27
|
+
sha: input.sha,
|
|
28
|
+
durationMs: Math.max(0, Math.round(input.durationMs)),
|
|
29
|
+
data: input.data ?? null,
|
|
30
|
+
evidence: [...input.evidence],
|
|
31
|
+
cursors: { ...input.cursors },
|
|
32
|
+
...(input.truncatedPath === null
|
|
33
|
+
? {}
|
|
34
|
+
: { truncated: true, truncatedPath: input.truncatedPath }),
|
|
35
|
+
error:
|
|
36
|
+
input.error === null
|
|
37
|
+
? null
|
|
38
|
+
: { code: input.error.code, message: input.error.message, hint: input.error.hint ?? null },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// setpriv asks the kernel to kill the app when the supervisor dies; hosts without setpriv, such as
|
|
2
|
+
// macOS, run the app without that guard.
|
|
3
|
+
export function buildLaunchGuard(setprivPath: string | null): readonly string[] {
|
|
4
|
+
return setprivPath === null ? [] : ['setpriv', '--pdeathsig', 'KILL', '--'];
|
|
5
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RunView } from './build-run-view.ts';
|
|
2
|
+
|
|
3
|
+
export interface ManifestWrite {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A run with no log yet gets no manifest.
|
|
9
|
+
export function buildManifestWrite(path: string, view: RunView | undefined): ManifestWrite | null {
|
|
10
|
+
if (view === undefined) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const manifest = {
|
|
15
|
+
target: view.target,
|
|
16
|
+
instance: view.runID,
|
|
17
|
+
mode: view.mode,
|
|
18
|
+
fingerprint: view.fingerprint,
|
|
19
|
+
status: view.status,
|
|
20
|
+
doctor: { state: view.doctor, results: view.doctorResults },
|
|
21
|
+
startedAt: view.startedAt,
|
|
22
|
+
endedAt: view.endedAt,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return { path, text: `${JSON.stringify(manifest, null, 2)}\n` };
|
|
26
|
+
}
|