@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.
- 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,32 @@
|
|
|
1
|
+
import type { RunView } from '../build-run-view.ts';
|
|
2
|
+
import type { SupervisorReach } from './call-run-supervisor.ts';
|
|
3
|
+
import type { StopData } from './stop-data-schema.ts';
|
|
4
|
+
|
|
5
|
+
export interface StopInput {
|
|
6
|
+
readonly runID: string | undefined;
|
|
7
|
+
readonly status: RunView['status'] | undefined;
|
|
8
|
+
readonly reach: SupervisorReach;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StopPlan {
|
|
12
|
+
readonly data: StopData;
|
|
13
|
+
readonly markCrashed: boolean;
|
|
14
|
+
readonly clearPointer: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A supervisor that answered the stop ended the run itself. One that is gone left a run that still
|
|
18
|
+
// reads as live, so the run is marked crashed and its log ends in a final status.
|
|
19
|
+
export function pickStopPlan(input: StopInput): StopPlan {
|
|
20
|
+
if (input.runID === undefined) {
|
|
21
|
+
return { data: { run: null, stopped: false }, markCrashed: false, clearPointer: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stopped = input.reach === 'answered';
|
|
25
|
+
const live = input.status === 'running' || input.status === 'starting';
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
data: { run: input.runID, stopped },
|
|
29
|
+
markCrashed: !stopped && live,
|
|
30
|
+
clearPointer: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { RunView } from '../build-run-view.ts';
|
|
3
|
+
import { findOwnValue } from '../find-own-value.ts';
|
|
4
|
+
import { readDefaultBase } from '../read-default-base.ts';
|
|
5
|
+
import { readFingerprint } from '../read-fingerprint.ts';
|
|
6
|
+
import { supervisorServiceName } from '../supervisor-protocol.ts';
|
|
7
|
+
import { toWringerError } from '../to-wringer-error.ts';
|
|
8
|
+
import type { BuiltinContext } from '../types.ts';
|
|
9
|
+
import { WringerError } from '../wringer-error.ts';
|
|
10
|
+
import { writeLateFailure } from '../write-late-failure.ts';
|
|
11
|
+
import { callRunSupervisor, isSupervisorGone } from './call-run-supervisor.ts';
|
|
12
|
+
import { pickStartAction } from './pick-start-action.ts';
|
|
13
|
+
import type { StartFacts } from './pick-start-action.ts';
|
|
14
|
+
|
|
15
|
+
export const runDataSchema = z.strictObject({
|
|
16
|
+
run: z.string(),
|
|
17
|
+
target: z.string(),
|
|
18
|
+
status: z.enum(['starting', 'running', 'crashed', 'ended']),
|
|
19
|
+
endpoints: z.record(z.string(), z.string()),
|
|
20
|
+
services: z.array(z.string()),
|
|
21
|
+
reused: z.boolean(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type RunData = z.infer<typeof runDataSchema>;
|
|
25
|
+
|
|
26
|
+
export interface StartRunRequest {
|
|
27
|
+
readonly command: 'up' | 'attach';
|
|
28
|
+
readonly restart: boolean;
|
|
29
|
+
readonly readyTimeoutMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `up` and `attach` hold the target's lock across check-and-create, so two at once never start two
|
|
33
|
+
// supervisors. A live run is reused, keeping its doctor pass, unless the caller asks for a restart.
|
|
34
|
+
export function startRun(ctx: BuiltinContext, request: StartRunRequest): Promise<RunData> {
|
|
35
|
+
return ctx.state.withTargetLock(ctx.targetName, async () => {
|
|
36
|
+
const pointers = await ctx.state.readPointers();
|
|
37
|
+
|
|
38
|
+
const currentID = findOwnValue(pointers.currentRun, ctx.targetName);
|
|
39
|
+
const current = currentID === undefined ? undefined : await ctx.state.readRun(currentID);
|
|
40
|
+
|
|
41
|
+
const reused = await settleCurrentRun(ctx, current, request.restart);
|
|
42
|
+
|
|
43
|
+
if (reused !== null) {
|
|
44
|
+
ctx.bindRun(reused.runID);
|
|
45
|
+
|
|
46
|
+
return toRunData(reused, true);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const runID = buildRunID(ctx.now());
|
|
50
|
+
|
|
51
|
+
ctx.bindRun(runID);
|
|
52
|
+
|
|
53
|
+
const base = await readDefaultBase(ctx.session.repoHost, ctx.session.root);
|
|
54
|
+
|
|
55
|
+
const fingerprint = await readFingerprint(ctx.session.repoHost, {
|
|
56
|
+
cwd: ctx.session.root,
|
|
57
|
+
base,
|
|
58
|
+
exclude: [ctx.wringer.stateDir],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The caller has its timeout already once the deadline passes, so no run starts after it.
|
|
62
|
+
if (ctx.signal.aborted) {
|
|
63
|
+
throw new WringerError('timeout', {
|
|
64
|
+
message: `The command's deadline passed before run ${runID} started.`,
|
|
65
|
+
hint: 'Pass a longer `--timeout-ms`, then run `up` again.',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await ctx.state.appendEvent(runID, {
|
|
70
|
+
type: 'run-started',
|
|
71
|
+
at: ctx.now().toISOString(),
|
|
72
|
+
target: ctx.targetName,
|
|
73
|
+
mode: ctx.target.mode,
|
|
74
|
+
fingerprint,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await ctx.state.writePointers((latest) => ({
|
|
78
|
+
...latest,
|
|
79
|
+
currentRun: { ...latest.currentRun, [ctx.targetName]: runID },
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await ctx.session.supervisor.start({ runID, target: ctx.targetName });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// A run whose supervisor never started can never finish starting, so it ends here.
|
|
86
|
+
await ctx.state.appendEvent(runID, {
|
|
87
|
+
type: 'crashed',
|
|
88
|
+
at: ctx.now().toISOString(),
|
|
89
|
+
reason: `the supervisor could not start: ${toWringerError(error).message}`,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const ready = await waitForReady(ctx, runID, request.readyTimeoutMs);
|
|
97
|
+
|
|
98
|
+
return toRunData(ready, false);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await stopAbandonedRun(ctx, runID, request.command);
|
|
101
|
+
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Carries out what `pickStartAction` asks for until the current run is reused or out of the way.
|
|
108
|
+
// Resolves the run to reuse, or null once a new run may start. A ping that times out rejects, so a
|
|
109
|
+
// slow supervisor is never taken for a dead one.
|
|
110
|
+
async function settleCurrentRun(
|
|
111
|
+
ctx: BuiltinContext,
|
|
112
|
+
current: RunView | undefined,
|
|
113
|
+
restart: boolean,
|
|
114
|
+
): Promise<RunView | null> {
|
|
115
|
+
let facts: StartFacts = { status: current?.status ?? null, restart, ping: null, stop: null };
|
|
116
|
+
|
|
117
|
+
for (;;) {
|
|
118
|
+
const action = pickStartAction(facts);
|
|
119
|
+
|
|
120
|
+
if (current === undefined || action === 'start') {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (action === 'reuse') {
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (action === 'mark-crashed') {
|
|
129
|
+
await ctx.state.appendEvent(current.runID, {
|
|
130
|
+
type: 'crashed',
|
|
131
|
+
at: ctx.now().toISOString(),
|
|
132
|
+
reason: "the run's supervisor stopped answering",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
facts =
|
|
139
|
+
action === 'ping'
|
|
140
|
+
? {
|
|
141
|
+
...facts,
|
|
142
|
+
ping: await callRunSupervisor(
|
|
143
|
+
ctx.session.supervisor,
|
|
144
|
+
current,
|
|
145
|
+
'ping',
|
|
146
|
+
makePingSignal(ctx),
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
: {
|
|
150
|
+
...facts,
|
|
151
|
+
stop: await callRunSupervisor(ctx.session.supervisor, current, 'stop', ctx.signal),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const pingTimeoutMs = 2000;
|
|
157
|
+
|
|
158
|
+
function makePingSignal(ctx: BuiltinContext): AbortSignal {
|
|
159
|
+
return AbortSignal.any([ctx.signal, AbortSignal.timeout(pingTimeoutMs)]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toRunData(view: RunView, reused: boolean): RunData {
|
|
163
|
+
return {
|
|
164
|
+
run: view.runID,
|
|
165
|
+
target: view.target,
|
|
166
|
+
status: view.status,
|
|
167
|
+
endpoints: view.endpoints,
|
|
168
|
+
services: [...view.services],
|
|
169
|
+
reused,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildRunID(date: Date): string {
|
|
174
|
+
const random = Math.floor(Math.random() * 36 ** 4)
|
|
175
|
+
.toString(36)
|
|
176
|
+
.padStart(4, '0');
|
|
177
|
+
|
|
178
|
+
return `${date.getTime().toString(36)}-${random}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const readyPollMs = 50;
|
|
182
|
+
|
|
183
|
+
async function waitForReady(
|
|
184
|
+
ctx: BuiltinContext,
|
|
185
|
+
runID: string,
|
|
186
|
+
timeoutMs: number,
|
|
187
|
+
): Promise<RunView> {
|
|
188
|
+
const deadline = ctx.now().getTime() + timeoutMs;
|
|
189
|
+
|
|
190
|
+
while (ctx.now().getTime() < deadline) {
|
|
191
|
+
if (ctx.signal.aborted) {
|
|
192
|
+
throw new WringerError('timeout', {
|
|
193
|
+
message: `The command's deadline passed while run ${runID} started.`,
|
|
194
|
+
hint: 'Pass a longer `--timeout-ms`, then run `up` again.',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const view = await ctx.state.readRun(runID);
|
|
199
|
+
|
|
200
|
+
if (view?.status === 'running') {
|
|
201
|
+
return view;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (view?.status === 'crashed' || view?.status === 'ended') {
|
|
205
|
+
throw new WringerError('environment', {
|
|
206
|
+
message: `Run ${runID} stopped while it started: ${view.crashReason ?? 'it ended'}.`,
|
|
207
|
+
hint: `Read .wringer/runs/${runID}/children/app.log and supervisor.log.`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await ctx.wait(readyPollMs);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
throw new WringerError('timeout', {
|
|
215
|
+
message: `Run ${runID} was not ready within ${timeoutMs} ms.`,
|
|
216
|
+
hint: `Read .wringer/runs/${runID}/children/app.log, then run \`up\` again.`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A run that missed its deadline must not keep its app and services up behind the error. The stop
|
|
221
|
+
// waits for a socket the supervisor may not have opened yet, and as the command's own error answers
|
|
222
|
+
// first, a failed stop can only go to the run's log.
|
|
223
|
+
async function stopAbandonedRun(
|
|
224
|
+
ctx: BuiltinContext,
|
|
225
|
+
runID: string,
|
|
226
|
+
command: StartRunRequest['command'],
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
const socket = await readSupervisorSocket(ctx, runID);
|
|
229
|
+
|
|
230
|
+
if (socket === null) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await writeLateFailure(callSupervisorStop(ctx, socket), {
|
|
235
|
+
state: ctx.state,
|
|
236
|
+
runID,
|
|
237
|
+
command,
|
|
238
|
+
answered: null,
|
|
239
|
+
now: ctx.now,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const socketWaitMs = 10_000;
|
|
244
|
+
|
|
245
|
+
// Resolves null once the run has ended, or when its supervisor never opens a socket.
|
|
246
|
+
async function readSupervisorSocket(ctx: BuiltinContext, runID: string): Promise<string | null> {
|
|
247
|
+
const deadline = ctx.now().getTime() + socketWaitMs;
|
|
248
|
+
|
|
249
|
+
while (ctx.now().getTime() < deadline) {
|
|
250
|
+
const view = await ctx.state.readRun(runID);
|
|
251
|
+
|
|
252
|
+
if (view === undefined || view.status === 'crashed' || view.status === 'ended') {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (view.supervisor !== null) {
|
|
257
|
+
return view.supervisor.socket;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await ctx.wait(readyPollMs);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const stopTimeoutMs = 15_000;
|
|
267
|
+
|
|
268
|
+
// A supervisor that is already gone is the state the stop wants.
|
|
269
|
+
async function callSupervisorStop(ctx: BuiltinContext, socket: string): Promise<void> {
|
|
270
|
+
try {
|
|
271
|
+
await ctx.session.supervisor.call({
|
|
272
|
+
socket,
|
|
273
|
+
request: { service: supervisorServiceName, method: 'stop', args: null },
|
|
274
|
+
signal: AbortSignal.timeout(stopTimeoutMs),
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (!isSupervisorGone(error)) {
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { findOwnValue } from '../find-own-value.ts';
|
|
2
|
+
import type { BuiltinContext } from '../types.ts';
|
|
3
|
+
import { callRunSupervisor } from './call-run-supervisor.ts';
|
|
4
|
+
import { pickStopPlan } from './pick-stop-plan.ts';
|
|
5
|
+
import type { StopData } from './stop-data-schema.ts';
|
|
6
|
+
|
|
7
|
+
// `down` and `detach` end the target's current run and clear its pointer, as `pickStopPlan`
|
|
8
|
+
// decides. A stop call that fails other than with a gone supervisor rejects, and leaves the run and
|
|
9
|
+
// its pointer for the next `down`.
|
|
10
|
+
export function stopRun(ctx: BuiltinContext): Promise<StopData> {
|
|
11
|
+
return ctx.state.withTargetLock(ctx.targetName, async () => {
|
|
12
|
+
const pointers = await ctx.state.readPointers();
|
|
13
|
+
|
|
14
|
+
const runID = findOwnValue(pointers.currentRun, ctx.targetName);
|
|
15
|
+
const view = runID === undefined ? undefined : await ctx.state.readRun(runID);
|
|
16
|
+
|
|
17
|
+
if (runID !== undefined) {
|
|
18
|
+
ctx.bindRun(runID);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const reach =
|
|
22
|
+
view === undefined
|
|
23
|
+
? 'gone'
|
|
24
|
+
: await callRunSupervisor(ctx.session.supervisor, view, 'stop', ctx.signal);
|
|
25
|
+
|
|
26
|
+
const plan = pickStopPlan({ runID, status: view?.status, reach });
|
|
27
|
+
|
|
28
|
+
if (runID !== undefined && plan.markCrashed) {
|
|
29
|
+
await ctx.state.appendEvent(runID, {
|
|
30
|
+
type: 'crashed',
|
|
31
|
+
at: ctx.now().toISOString(),
|
|
32
|
+
reason: "the run's supervisor was gone when the run was stopped",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await ctx.state.writeManifest(runID);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (plan.clearPointer) {
|
|
39
|
+
await ctx.state.writePointers((latest) => ({
|
|
40
|
+
...latest,
|
|
41
|
+
currentRun: Object.fromEntries(
|
|
42
|
+
Object.entries(latest.currentRun).filter(([target]) => target !== ctx.targetName),
|
|
43
|
+
),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return plan.data;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { isErrorCode } from './is-error-code.ts';
|
|
4
|
+
import { supervisorResponseSchema } from './supervisor-protocol.ts';
|
|
5
|
+
import type { SupervisorRequest } from './supervisor-protocol.ts';
|
|
6
|
+
import { WringerError } from './wringer-error.ts';
|
|
7
|
+
|
|
8
|
+
export interface SupervisorCall {
|
|
9
|
+
readonly socket: string;
|
|
10
|
+
readonly request: SupervisorRequest;
|
|
11
|
+
readonly signal: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function callSupervisor(call: SupervisorCall): Promise<unknown> {
|
|
15
|
+
const line = await exchangeLine(call);
|
|
16
|
+
|
|
17
|
+
const response = supervisorResponseSchema.parse(JSON.parse(line));
|
|
18
|
+
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new WringerError(response.error.code, {
|
|
21
|
+
message: response.error.message,
|
|
22
|
+
...(response.error.hint === null ? {} : { hint: response.error.hint }),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return response.result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The longest a call waits for the supervisor's answer once the caller's signal aborts.
|
|
30
|
+
export const supervisorCancelWaitMs = 2000;
|
|
31
|
+
|
|
32
|
+
async function exchangeLine(call: SupervisorCall): Promise<string> {
|
|
33
|
+
// An abort that came before the listener never fires it, so a late call would still run.
|
|
34
|
+
if (call.signal.aborted) {
|
|
35
|
+
throw buildLateError();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const connection = createConnection({ path: call.socket });
|
|
39
|
+
const lines = createInterface({ input: connection });
|
|
40
|
+
const reply = Promise.withResolvers<string>();
|
|
41
|
+
let giveUp: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
|
|
43
|
+
// The supervisor cancels the call itself before the caller's deadline, and its answer tells the
|
|
44
|
+
// caller the cancelled work has stopped, so the caller waits a bounded time for that answer.
|
|
45
|
+
const onAbort = (): void => {
|
|
46
|
+
giveUp = setTimeout(() => {
|
|
47
|
+
connection.destroy();
|
|
48
|
+
|
|
49
|
+
const failure =
|
|
50
|
+
call.request.deadlineAt === undefined ? buildLateError() : buildUnconfirmedError();
|
|
51
|
+
|
|
52
|
+
reply.reject(failure);
|
|
53
|
+
}, supervisorCancelWaitMs);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
call.signal.addEventListener('abort', onAbort, { once: true });
|
|
57
|
+
|
|
58
|
+
// readline re-emits its input's errors, so both need a listener.
|
|
59
|
+
connection.on('error', (error) => {
|
|
60
|
+
reply.reject(toConnectionError(error));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
lines.on('error', (error) => {
|
|
64
|
+
reply.reject(toConnectionError(error));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
lines.once('line', (line) => {
|
|
68
|
+
reply.resolve(line);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
lines.once('close', () => {
|
|
72
|
+
reply.reject(new WringerError('environment', { message: 'The supervisor closed the socket.' }));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
connection.write(`${JSON.stringify(call.request)}\n`);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
return await reply.promise;
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(giveUp);
|
|
81
|
+
|
|
82
|
+
call.signal.removeEventListener('abort', onAbort);
|
|
83
|
+
lines.close();
|
|
84
|
+
connection.destroy();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildLateError(): WringerError {
|
|
89
|
+
return new WringerError('timeout', { message: 'The supervisor did not answer in time.' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A timeout tells the caller the cancelled work stopped, so a cancel the supervisor never answered
|
|
93
|
+
// fails with an environment error.
|
|
94
|
+
export function buildUnconfirmedError(): WringerError {
|
|
95
|
+
return new WringerError('environment', {
|
|
96
|
+
message: `The run's supervisor did not confirm within ${supervisorCancelWaitMs} ms that the cancelled call stopped, so its work may still run.`,
|
|
97
|
+
hint: 'Run `up --restart` to start a new run.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toConnectionError(error: Error): WringerError {
|
|
102
|
+
const dead = isErrorCode(error, 'ECONNREFUSED') || isErrorCode(error, 'ENOENT');
|
|
103
|
+
const code = dead ? 'not-running' : 'environment';
|
|
104
|
+
|
|
105
|
+
return new WringerError(code, {
|
|
106
|
+
message: `The run's supervisor is not reachable: ${error.message}`,
|
|
107
|
+
hint: 'Run `up --restart`, or `attach` again, to start a new run.',
|
|
108
|
+
});
|
|
109
|
+
}
|
package/src/cap-data.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createEvidenceWriter } from './create-evidence-writer.ts';
|
|
2
|
+
import type { Evidence } from './envelope.ts';
|
|
3
|
+
import { toWringerError } from './to-wringer-error.ts';
|
|
4
|
+
import type { EvidenceFiles } from './types.ts';
|
|
5
|
+
import type { WringerError } from './wringer-error.ts';
|
|
6
|
+
|
|
7
|
+
export const dataCapBytes = 32 * 1024;
|
|
8
|
+
|
|
9
|
+
export interface CapRequest {
|
|
10
|
+
readonly root: string;
|
|
11
|
+
readonly directory: string;
|
|
12
|
+
readonly now: () => Date;
|
|
13
|
+
readonly files: EvidenceFiles;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CappedData {
|
|
17
|
+
readonly data: unknown;
|
|
18
|
+
readonly evidence: readonly Evidence[];
|
|
19
|
+
readonly truncatedPath: string | null;
|
|
20
|
+
readonly failure: WringerError | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Over the cap, the full data goes to an evidence file and the envelope carries its path. Data
|
|
24
|
+
// that fails to serialize or to write still yields an envelope, one that carries the failure.
|
|
25
|
+
export async function capData(data: unknown, request: CapRequest): Promise<CappedData> {
|
|
26
|
+
try {
|
|
27
|
+
const text = JSON.stringify(data ?? null);
|
|
28
|
+
|
|
29
|
+
if (Buffer.byteLength(text) <= dataCapBytes) {
|
|
30
|
+
return { data, evidence: [], truncatedPath: null, failure: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const evidence = await createEvidenceWriter(request).writer.writeFile(
|
|
34
|
+
'payload',
|
|
35
|
+
'payload.json',
|
|
36
|
+
text,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return { data: null, evidence: [evidence], truncatedPath: evidence.path, failure: null };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { data: null, evidence: [], truncatedPath: null, failure: toWringerError(error) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ConfiguredDriver, DriverConfigContext, TargetDefinition, Wringer } from './types.ts';
|
|
2
|
+
import { WringerError } from './wringer-error.ts';
|
|
3
|
+
|
|
4
|
+
export interface ConfiguredEntry {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly serviceNames: readonly string[];
|
|
7
|
+
readonly configured: ConfiguredDriver;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A driver works on a target only when the target gives it a config. The target's config function
|
|
11
|
+
// sees the run's endpoints, so the config is built per run.
|
|
12
|
+
export function collectDriverConfigs(
|
|
13
|
+
wringer: Wringer,
|
|
14
|
+
target: TargetDefinition,
|
|
15
|
+
ctx: DriverConfigContext,
|
|
16
|
+
): readonly ConfiguredEntry[] {
|
|
17
|
+
return wringer.drivers.flatMap((driver) => {
|
|
18
|
+
const build = target.drivers?.[driver.name];
|
|
19
|
+
|
|
20
|
+
if (build === undefined) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const configured = driver.configure(build(ctx));
|
|
25
|
+
|
|
26
|
+
if (!configured.ok) {
|
|
27
|
+
throw new WringerError('usage', {
|
|
28
|
+
message: `The ${driver.name} config of target ${ctx.target} is not valid: ${configured.message}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
{ name: driver.name, serviceNames: driver.serviceNames, configured: configured.prepared },
|
|
34
|
+
];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { basename, join, relative } from 'node:path';
|
|
2
|
+
import type { Evidence } from './envelope.ts';
|
|
3
|
+
import type { EvidenceFiles, EvidenceWriter } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export interface EvidenceWriterOptions {
|
|
6
|
+
readonly root: string;
|
|
7
|
+
readonly directory: string;
|
|
8
|
+
readonly now: () => Date;
|
|
9
|
+
readonly files: EvidenceFiles;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CollectingEvidenceWriter {
|
|
13
|
+
readonly writer: EvidenceWriter;
|
|
14
|
+
readonly collected: readonly Evidence[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Each path in an envelope is relative to the worktree root, so it reads the same from any folder.
|
|
18
|
+
export function createEvidenceWriter(options: EvidenceWriterOptions): CollectingEvidenceWriter {
|
|
19
|
+
const collected: Evidence[] = [];
|
|
20
|
+
|
|
21
|
+
const record = (kind: string, path: string, content: Uint8Array): Evidence => {
|
|
22
|
+
const evidence = {
|
|
23
|
+
kind,
|
|
24
|
+
path: relative(options.root, path),
|
|
25
|
+
sha256: new Bun.CryptoHasher('sha256').update(content).digest('hex'),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
collected.push(evidence);
|
|
29
|
+
|
|
30
|
+
return evidence;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
collected,
|
|
35
|
+
writer: {
|
|
36
|
+
directory: options.directory,
|
|
37
|
+
writeFile: async (kind, name, content) => {
|
|
38
|
+
const bytes = typeof content === 'string' ? Buffer.from(content) : content;
|
|
39
|
+
|
|
40
|
+
await options.files.createDirectory(options.directory);
|
|
41
|
+
|
|
42
|
+
const path = await writeNumberedFile(
|
|
43
|
+
options.files,
|
|
44
|
+
options.directory,
|
|
45
|
+
`${formatStamp(options.now())}-${basename(name)}`,
|
|
46
|
+
bytes,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return record(kind, path, bytes);
|
|
50
|
+
},
|
|
51
|
+
addFile: async (kind, path) => {
|
|
52
|
+
const bytes = await options.files.readBytes(path);
|
|
53
|
+
|
|
54
|
+
return record(kind, path, bytes);
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Two writes in one millisecond, from this process or another, can share a name, so the write
|
|
61
|
+
// creates the file exclusively and numbers the name until one is free.
|
|
62
|
+
async function writeNumberedFile(
|
|
63
|
+
files: EvidenceFiles,
|
|
64
|
+
directory: string,
|
|
65
|
+
name: string,
|
|
66
|
+
bytes: Uint8Array,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
69
|
+
const numbered = attempt === 0 ? name : `${attempt}-${name}`;
|
|
70
|
+
const path = join(directory, numbered);
|
|
71
|
+
|
|
72
|
+
const created = await files.writeNewFile(path, bytes);
|
|
73
|
+
|
|
74
|
+
if (created) {
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatStamp(date: Date): string {
|
|
81
|
+
return date.toISOString().replaceAll(/[:.]/gu, '-');
|
|
82
|
+
}
|