@xfey/tutti 0.1.61 → 0.1.63
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/README.md +2 -2
- package/dist/server-shell/cli/args.d.ts +4 -0
- package/dist/server-shell/cli/args.js +11 -0
- package/dist/server-shell/cli/cli.js +35 -11
- package/dist/server-shell/cli/host-server-runtime.d.ts +2 -0
- package/dist/server-shell/cli/host-server-runtime.js +12 -5
- package/dist/server-shell/cli/local-control-client.d.ts +5 -1
- package/dist/server-shell/cli/local-control-client.js +19 -0
- package/dist/server-shell/desktop-integration/manager.d.ts +2 -0
- package/dist/server-shell/desktop-integration/manager.js +19 -31
- package/dist/server-shell/http/routes/local-control.d.ts +2 -0
- package/dist/server-shell/http/routes/local-control.js +24 -0
- package/dist/server-shell/local-console/invocation-context.js +10 -1
- package/dist/server-shell/local-console/operation-coordinator.d.ts +16 -0
- package/dist/server-shell/local-console/operation-coordinator.js +50 -0
- package/dist/server-shell/local-console/package-update-completion.d.ts +25 -0
- package/dist/server-shell/local-console/package-update-completion.js +91 -0
- package/dist/server-shell/local-console/package-update-hosts.d.ts +17 -0
- package/dist/server-shell/local-console/package-update-hosts.js +92 -0
- package/dist/server-shell/local-console/package-update-lock.d.ts +46 -0
- package/dist/server-shell/local-console/package-update-lock.js +223 -0
- package/dist/server-shell/local-console/package-update-log.d.ts +20 -0
- package/dist/server-shell/local-console/package-update-log.js +75 -0
- package/dist/server-shell/local-console/package-update-process.d.ts +22 -0
- package/dist/server-shell/local-console/package-update-process.js +252 -0
- package/dist/server-shell/local-console/package-update-record.d.ts +53 -0
- package/dist/server-shell/local-console/package-update-record.js +180 -0
- package/dist/server-shell/local-console/package-update-recovery.d.ts +34 -0
- package/dist/server-shell/local-console/package-update-recovery.js +140 -0
- package/dist/server-shell/local-console/package-update-service.d.ts +72 -0
- package/dist/server-shell/local-console/package-update-service.js +400 -0
- package/dist/server-shell/local-console/package-update-worker.d.ts +55 -0
- package/dist/server-shell/local-console/package-update-worker.js +297 -0
- package/dist/server-shell/local-console/project-service.d.ts +2 -0
- package/dist/server-shell/local-console/project-service.js +16 -12
- package/dist/server-shell/local-console/server.d.ts +9 -0
- package/dist/server-shell/local-console/server.js +92 -4
- package/dist/server-shell/package-installation/command.d.ts +36 -0
- package/dist/server-shell/package-installation/command.js +162 -0
- package/dist/server-shell/package-installation/discovery.d.ts +51 -0
- package/dist/server-shell/package-installation/discovery.js +137 -0
- package/dist/server-shell/package-installation/index.d.ts +5 -0
- package/dist/server-shell/package-installation/index.js +5 -0
- package/dist/server-shell/package-installation/installation.d.ts +34 -0
- package/dist/server-shell/package-installation/installation.js +123 -0
- package/dist/server-shell/package-installation/semver.d.ts +9 -0
- package/dist/server-shell/package-installation/semver.js +84 -0
- package/package.json +1 -1
- package/web/assets/{homepage-motion-scene-CY7o4hnR.js → homepage-motion-scene-CRmrkkEH.js} +1 -1
- package/web/assets/{index-B08r3x8o.js → index-C9JrplHV.js} +14 -14
- package/web/assets/{index-DpabiRgp.css → index-IZKcmU_g.css} +1 -1
- package/web/index.html +2 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { releasePackageUpdateLockForCurrentProcess, releasePackageUpdateLockForExpectedPid, transferPackageUpdateLockFromCurrentProcess, } from "./package-update-lock.js";
|
|
3
|
+
import { PackageUpdateLogger, withPackageUpdateLogDescriptor } from "./package-update-log.js";
|
|
4
|
+
import { updatePackageUpdateRuntimeRecord, } from "./package-update-record.js";
|
|
5
|
+
import { parsePackageUpdateWorkerInput, runPackageUpdateWorker, } from "./package-update-worker.js";
|
|
6
|
+
import { runPackageUpdateCompletion } from "./package-update-completion.js";
|
|
7
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
8
|
+
export function buildHiddenPackageUpdateProcessPlan(options) {
|
|
9
|
+
return {
|
|
10
|
+
executable: options.input.installation.nodeExecutable,
|
|
11
|
+
args: [
|
|
12
|
+
options.input.cli_entrypoint,
|
|
13
|
+
"internal",
|
|
14
|
+
options.role === "worker" ? "package-update-worker" : "package-update-complete",
|
|
15
|
+
],
|
|
16
|
+
cwd: options.input.tutti_home,
|
|
17
|
+
env: {
|
|
18
|
+
...options.input.completion_environment,
|
|
19
|
+
TUTTI_HOME: options.input.tutti_home,
|
|
20
|
+
},
|
|
21
|
+
shell: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function sendProcessMessage(message) {
|
|
25
|
+
return new Promise((resolveSend, rejectSend) => {
|
|
26
|
+
if (process.send === undefined) {
|
|
27
|
+
rejectSend(new Error("Package update process IPC is unavailable."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
process.send(message, (error) => {
|
|
31
|
+
if (error === null) {
|
|
32
|
+
resolveSend();
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
rejectSend(error);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function sendChildMessage(child, message) {
|
|
41
|
+
return new Promise((resolveSend, rejectSend) => {
|
|
42
|
+
if (!child.connected || child.send === undefined) {
|
|
43
|
+
rejectSend(new Error("Package update child IPC is unavailable."));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
child.send(message, (error) => {
|
|
47
|
+
if (error === null) {
|
|
48
|
+
resolveSend();
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
rejectSend(error);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function waitForChildMessage(child) {
|
|
57
|
+
return new Promise((resolveMessage, rejectMessage) => {
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
cleanup();
|
|
60
|
+
rejectMessage(new Error("Package update child handshake timed out."));
|
|
61
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
62
|
+
const onMessage = (message) => {
|
|
63
|
+
cleanup();
|
|
64
|
+
resolveMessage(message);
|
|
65
|
+
};
|
|
66
|
+
const onError = (error) => {
|
|
67
|
+
cleanup();
|
|
68
|
+
rejectMessage(error);
|
|
69
|
+
};
|
|
70
|
+
const onExit = () => {
|
|
71
|
+
cleanup();
|
|
72
|
+
rejectMessage(new Error("Package update child exited during handshake."));
|
|
73
|
+
};
|
|
74
|
+
const cleanup = () => {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
child.off("message", onMessage);
|
|
77
|
+
child.off("error", onError);
|
|
78
|
+
child.off("exit", onExit);
|
|
79
|
+
};
|
|
80
|
+
child.once("message", onMessage);
|
|
81
|
+
child.once("error", onError);
|
|
82
|
+
child.once("exit", onExit);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function waitForProcessMessage() {
|
|
86
|
+
return new Promise((resolveMessage, rejectMessage) => {
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
cleanup();
|
|
89
|
+
rejectMessage(new Error("Package update parent handshake timed out."));
|
|
90
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
91
|
+
const onMessage = (message) => {
|
|
92
|
+
cleanup();
|
|
93
|
+
resolveMessage(message);
|
|
94
|
+
};
|
|
95
|
+
const onDisconnect = () => {
|
|
96
|
+
cleanup();
|
|
97
|
+
rejectMessage(new Error("Package update parent disconnected during handshake."));
|
|
98
|
+
};
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
process.off("message", onMessage);
|
|
102
|
+
process.off("disconnect", onDisconnect);
|
|
103
|
+
};
|
|
104
|
+
process.once("message", onMessage);
|
|
105
|
+
process.once("disconnect", onDisconnect);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function spawnHiddenPackageUpdateProcess(options) {
|
|
109
|
+
const plan = buildHiddenPackageUpdateProcessPlan(options);
|
|
110
|
+
return withPackageUpdateLogDescriptor(options.input.tutti_home, (descriptor) => spawn(plan.executable, plan.args, {
|
|
111
|
+
cwd: plan.cwd,
|
|
112
|
+
detached: true,
|
|
113
|
+
env: plan.env,
|
|
114
|
+
shell: plan.shell,
|
|
115
|
+
stdio: ["ignore", descriptor, descriptor, "ipc"],
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
function disconnectAndUnref(child) {
|
|
119
|
+
child.unref();
|
|
120
|
+
child.channel?.unref();
|
|
121
|
+
if (child.connected) {
|
|
122
|
+
child.disconnect();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function spawnPackageUpdateWorkerProcess(options) {
|
|
126
|
+
const child = spawnHiddenPackageUpdateProcess({ input: options.input, role: "worker" });
|
|
127
|
+
let handedOff = false;
|
|
128
|
+
try {
|
|
129
|
+
const ready = await waitForChildMessage(child);
|
|
130
|
+
if (ready.kind !== "ready" || ready.role !== "worker") {
|
|
131
|
+
throw new Error("Package update worker returned an invalid readiness message.");
|
|
132
|
+
}
|
|
133
|
+
await sendChildMessage(child, { kind: "input", input: options.input });
|
|
134
|
+
const accepted = await waitForChildMessage(child);
|
|
135
|
+
if (accepted.kind !== "accepted" || accepted.role !== "worker") {
|
|
136
|
+
throw new Error("Package update worker rejected its internal input.");
|
|
137
|
+
}
|
|
138
|
+
if (child.pid === undefined) {
|
|
139
|
+
throw new Error("Package update worker did not expose a process id.");
|
|
140
|
+
}
|
|
141
|
+
options.lease.handoffTo(child.pid);
|
|
142
|
+
handedOff = true;
|
|
143
|
+
await sendChildMessage(child, { kind: "start" });
|
|
144
|
+
options.lease.release();
|
|
145
|
+
disconnectAndUnref(child);
|
|
146
|
+
return { pid: child.pid };
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
try {
|
|
150
|
+
child.kill("SIGTERM");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// The worker already exited.
|
|
154
|
+
}
|
|
155
|
+
if (handedOff) {
|
|
156
|
+
options.lease.cancel();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
options.lease.release();
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function startInstalledCompletion(input, record) {
|
|
165
|
+
const child = spawnHiddenPackageUpdateProcess({ input, role: "completion" });
|
|
166
|
+
let transferred = false;
|
|
167
|
+
try {
|
|
168
|
+
const ready = await waitForChildMessage(child);
|
|
169
|
+
if (ready.kind !== "ready" || ready.role !== "completion" || child.pid === undefined) {
|
|
170
|
+
throw new Error("Installed package completion did not become ready.");
|
|
171
|
+
}
|
|
172
|
+
updatePackageUpdateRuntimeRecord({
|
|
173
|
+
tuttiHome: input.tutti_home,
|
|
174
|
+
current: record,
|
|
175
|
+
phase: "restarting",
|
|
176
|
+
updaterPid: child.pid,
|
|
177
|
+
});
|
|
178
|
+
if (!transferPackageUpdateLockFromCurrentProcess({
|
|
179
|
+
tuttiHome: input.tutti_home,
|
|
180
|
+
nextPid: child.pid,
|
|
181
|
+
})) {
|
|
182
|
+
throw new Error("Package update lock could not be handed to completion.");
|
|
183
|
+
}
|
|
184
|
+
transferred = true;
|
|
185
|
+
await sendChildMessage(child, { kind: "start" });
|
|
186
|
+
disconnectAndUnref(child);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
try {
|
|
190
|
+
child.kill("SIGTERM");
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// The completion process already exited.
|
|
194
|
+
}
|
|
195
|
+
if (transferred && child.pid !== undefined) {
|
|
196
|
+
releasePackageUpdateLockForExpectedPid(input.tutti_home, child.pid);
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export async function runPackageUpdateWorkerProtocol() {
|
|
202
|
+
await sendProcessMessage({ kind: "ready", role: "worker" });
|
|
203
|
+
let input = null;
|
|
204
|
+
try {
|
|
205
|
+
const message = await waitForProcessMessage();
|
|
206
|
+
input = message.kind === "input" ? parsePackageUpdateWorkerInput(message.input) : null;
|
|
207
|
+
if (input === null) {
|
|
208
|
+
await sendProcessMessage({ kind: "rejected", role: "worker" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await sendProcessMessage({ kind: "accepted", role: "worker" });
|
|
212
|
+
const start = await waitForProcessMessage();
|
|
213
|
+
if (start.kind !== "start") {
|
|
214
|
+
throw new Error("Package update worker did not receive its start signal.");
|
|
215
|
+
}
|
|
216
|
+
await runPackageUpdateWorker(input, {
|
|
217
|
+
startCompletion: startInstalledCompletion,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (input !== null) {
|
|
222
|
+
new PackageUpdateLogger({
|
|
223
|
+
tuttiHome: input.tutti_home,
|
|
224
|
+
privatePaths: [input.tutti_home, input.installation.packageRoot],
|
|
225
|
+
}).append({
|
|
226
|
+
level: "error",
|
|
227
|
+
message: "Package update worker crashed.",
|
|
228
|
+
reasonCode: "worker_crashed",
|
|
229
|
+
});
|
|
230
|
+
releasePackageUpdateLockForCurrentProcess(input.tutti_home);
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export async function runPackageUpdateCompletionProtocol() {
|
|
236
|
+
await sendProcessMessage({ kind: "ready", role: "completion" });
|
|
237
|
+
const start = await waitForProcessMessage();
|
|
238
|
+
if (start.kind !== "start") {
|
|
239
|
+
throw new Error("Package update completion did not receive its start signal.");
|
|
240
|
+
}
|
|
241
|
+
const tuttiHome = process.env.TUTTI_HOME;
|
|
242
|
+
const cliEntrypoint = process.argv[1];
|
|
243
|
+
if (tuttiHome === undefined || cliEntrypoint === undefined) {
|
|
244
|
+
throw new Error("Package update completion environment is unavailable.");
|
|
245
|
+
}
|
|
246
|
+
await runPackageUpdateCompletion({
|
|
247
|
+
tuttiHome,
|
|
248
|
+
cliEntrypoint,
|
|
249
|
+
env: process.env,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=package-update-process.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ProjectId } from "@tutti/shared/ids";
|
|
2
|
+
export type PackageUpdateRuntimePhase = "preparing" | "stopping_hosts" | "installing" | "restarting" | "failed";
|
|
3
|
+
export type PackageUpdateRuntimeRecord = {
|
|
4
|
+
schema_version: 1;
|
|
5
|
+
source_version: string;
|
|
6
|
+
target_version: string;
|
|
7
|
+
phase: PackageUpdateRuntimePhase;
|
|
8
|
+
restore_project_ids: ProjectId[];
|
|
9
|
+
updater_pid: number;
|
|
10
|
+
started_at: string;
|
|
11
|
+
updated_at: string;
|
|
12
|
+
failure_reason_code?: string;
|
|
13
|
+
restart_failure_count?: number;
|
|
14
|
+
};
|
|
15
|
+
export type PackageUpdateRuntimeRecordRead = {
|
|
16
|
+
status: "missing";
|
|
17
|
+
} | {
|
|
18
|
+
status: "valid";
|
|
19
|
+
record: PackageUpdateRuntimeRecord;
|
|
20
|
+
} | {
|
|
21
|
+
status: "invalid";
|
|
22
|
+
};
|
|
23
|
+
export type PackageUpdateCompletionResult = {
|
|
24
|
+
source_version: string;
|
|
25
|
+
target_version: string;
|
|
26
|
+
restart_failure_count: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function packageUpdateRuntimeRecordPath(tuttiHome: string): string;
|
|
29
|
+
export declare function readPackageUpdateRuntimeRecord(options: {
|
|
30
|
+
tuttiHome: string;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
maxAgeMs?: number;
|
|
33
|
+
}): PackageUpdateRuntimeRecordRead;
|
|
34
|
+
export declare function writePackageUpdateRuntimeRecord(tuttiHome: string, record: PackageUpdateRuntimeRecord): void;
|
|
35
|
+
export declare function deletePackageUpdateRuntimeRecord(options: {
|
|
36
|
+
tuttiHome: string;
|
|
37
|
+
expectedTargetVersion?: string;
|
|
38
|
+
expectedUpdaterPid?: number;
|
|
39
|
+
}): boolean;
|
|
40
|
+
export declare function updatePackageUpdateRuntimeRecord(options: {
|
|
41
|
+
tuttiHome: string;
|
|
42
|
+
current: PackageUpdateRuntimeRecord;
|
|
43
|
+
phase: PackageUpdateRuntimePhase;
|
|
44
|
+
now?: () => Date;
|
|
45
|
+
updaterPid?: number;
|
|
46
|
+
failureReasonCode?: string;
|
|
47
|
+
restartFailureCount?: number;
|
|
48
|
+
}): PackageUpdateRuntimeRecord;
|
|
49
|
+
export declare function consumePackageUpdateCompletion(options: {
|
|
50
|
+
tuttiHome: string;
|
|
51
|
+
currentVersion: string;
|
|
52
|
+
}): PackageUpdateCompletionResult | undefined;
|
|
53
|
+
//# sourceMappingURL=package-update-record.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
|
|
4
|
+
import { compareExactSemver, parseExactSemver } from "../package-installation/index.js";
|
|
5
|
+
const DEFAULT_MAX_RECORD_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
6
|
+
const RECORD_KEYS = [
|
|
7
|
+
"failure_reason_code",
|
|
8
|
+
"phase",
|
|
9
|
+
"restart_failure_count",
|
|
10
|
+
"restore_project_ids",
|
|
11
|
+
"schema_version",
|
|
12
|
+
"source_version",
|
|
13
|
+
"started_at",
|
|
14
|
+
"target_version",
|
|
15
|
+
"updated_at",
|
|
16
|
+
"updater_pid",
|
|
17
|
+
];
|
|
18
|
+
export function packageUpdateRuntimeRecordPath(tuttiHome) {
|
|
19
|
+
return join(tuttiHome, "runtime", "package-update.json");
|
|
20
|
+
}
|
|
21
|
+
function isIsoTimestamp(value) {
|
|
22
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
23
|
+
}
|
|
24
|
+
function isStableReason(value) {
|
|
25
|
+
return typeof value === "string" && /^[a-z][a-z0-9_]{0,79}$/u.test(value);
|
|
26
|
+
}
|
|
27
|
+
function parseRecord(value) {
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const candidate = value;
|
|
32
|
+
if (Object.keys(candidate).some((key) => !RECORD_KEYS.includes(key))) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const source = parseExactSemver(candidate.source_version);
|
|
36
|
+
const target = parseExactSemver(candidate.target_version);
|
|
37
|
+
const phases = [
|
|
38
|
+
"preparing",
|
|
39
|
+
"stopping_hosts",
|
|
40
|
+
"installing",
|
|
41
|
+
"restarting",
|
|
42
|
+
"failed",
|
|
43
|
+
];
|
|
44
|
+
if (candidate.schema_version !== 1 ||
|
|
45
|
+
source === null ||
|
|
46
|
+
target === null ||
|
|
47
|
+
compareExactSemver(target, source) <= 0 ||
|
|
48
|
+
!phases.includes(candidate.phase) ||
|
|
49
|
+
!Array.isArray(candidate.restore_project_ids) ||
|
|
50
|
+
!candidate.restore_project_ids.every((projectId) => typeof projectId === "string" && isPrefixedId(projectId, ID_PREFIXES.project)) ||
|
|
51
|
+
new Set(candidate.restore_project_ids).size !== candidate.restore_project_ids.length ||
|
|
52
|
+
!Number.isInteger(candidate.updater_pid) ||
|
|
53
|
+
candidate.updater_pid <= 0 ||
|
|
54
|
+
!isIsoTimestamp(candidate.started_at) ||
|
|
55
|
+
!isIsoTimestamp(candidate.updated_at) ||
|
|
56
|
+
Date.parse(candidate.updated_at) < Date.parse(candidate.started_at) ||
|
|
57
|
+
(candidate.failure_reason_code !== undefined &&
|
|
58
|
+
!isStableReason(candidate.failure_reason_code)) ||
|
|
59
|
+
(candidate.restart_failure_count !== undefined &&
|
|
60
|
+
(!Number.isInteger(candidate.restart_failure_count) ||
|
|
61
|
+
candidate.restart_failure_count < 0))) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
schema_version: 1,
|
|
66
|
+
source_version: source,
|
|
67
|
+
target_version: target,
|
|
68
|
+
phase: candidate.phase,
|
|
69
|
+
restore_project_ids: [...candidate.restore_project_ids].sort(),
|
|
70
|
+
updater_pid: candidate.updater_pid,
|
|
71
|
+
started_at: candidate.started_at,
|
|
72
|
+
updated_at: candidate.updated_at,
|
|
73
|
+
...(candidate.failure_reason_code === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { failure_reason_code: candidate.failure_reason_code }),
|
|
76
|
+
...(candidate.restart_failure_count === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { restart_failure_count: candidate.restart_failure_count }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function readPackageUpdateRuntimeRecord(options) {
|
|
82
|
+
const path = packageUpdateRuntimeRecordPath(options.tuttiHome);
|
|
83
|
+
if (!existsSync(path)) {
|
|
84
|
+
return { status: "missing" };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const record = parseRecord(JSON.parse(readFileSync(path, "utf8")));
|
|
88
|
+
if (record === null ||
|
|
89
|
+
(options.now ?? Date.now)() - Date.parse(record.updated_at) >
|
|
90
|
+
(options.maxAgeMs ?? DEFAULT_MAX_RECORD_AGE_MS)) {
|
|
91
|
+
return { status: "invalid" };
|
|
92
|
+
}
|
|
93
|
+
return { status: "valid", record };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { status: "invalid" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function writePackageUpdateRuntimeRecord(tuttiHome, record) {
|
|
100
|
+
const parsed = parseRecord(record);
|
|
101
|
+
if (parsed === null) {
|
|
102
|
+
throw new Error("Package update runtime record is invalid.");
|
|
103
|
+
}
|
|
104
|
+
const path = packageUpdateRuntimeRecordPath(tuttiHome);
|
|
105
|
+
const directory = dirname(path);
|
|
106
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
107
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
108
|
+
chmodSync(directory, 0o700);
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(temporaryPath, `${JSON.stringify(parsed)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
111
|
+
chmodSync(temporaryPath, 0o600);
|
|
112
|
+
renameSync(temporaryPath, path);
|
|
113
|
+
chmodSync(path, 0o600);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(temporaryPath);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Atomic rename normally removes the temporary path.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function deletePackageUpdateRuntimeRecord(options) {
|
|
125
|
+
const read = readPackageUpdateRuntimeRecord({ tuttiHome: options.tuttiHome });
|
|
126
|
+
if (read.status === "missing") {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (read.status !== "valid" ||
|
|
130
|
+
(options.expectedTargetVersion !== undefined &&
|
|
131
|
+
read.record.target_version !== options.expectedTargetVersion) ||
|
|
132
|
+
(options.expectedUpdaterPid !== undefined &&
|
|
133
|
+
read.record.updater_pid !== options.expectedUpdaterPid)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
unlinkSync(packageUpdateRuntimeRecordPath(options.tuttiHome));
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function updatePackageUpdateRuntimeRecord(options) {
|
|
145
|
+
const updated = {
|
|
146
|
+
...options.current,
|
|
147
|
+
phase: options.phase,
|
|
148
|
+
updater_pid: options.updaterPid ?? options.current.updater_pid,
|
|
149
|
+
updated_at: (options.now ?? (() => new Date()))().toISOString(),
|
|
150
|
+
...(options.failureReasonCode === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { failure_reason_code: options.failureReasonCode }),
|
|
153
|
+
...(options.restartFailureCount === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { restart_failure_count: options.restartFailureCount }),
|
|
156
|
+
};
|
|
157
|
+
writePackageUpdateRuntimeRecord(options.tuttiHome, updated);
|
|
158
|
+
return updated;
|
|
159
|
+
}
|
|
160
|
+
export function consumePackageUpdateCompletion(options) {
|
|
161
|
+
const read = readPackageUpdateRuntimeRecord({ tuttiHome: options.tuttiHome });
|
|
162
|
+
if (read.status !== "valid" ||
|
|
163
|
+
read.record.phase !== "restarting" ||
|
|
164
|
+
read.record.target_version !== options.currentVersion ||
|
|
165
|
+
read.record.restart_failure_count === undefined) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!deletePackageUpdateRuntimeRecord({
|
|
169
|
+
tuttiHome: options.tuttiHome,
|
|
170
|
+
expectedTargetVersion: read.record.target_version,
|
|
171
|
+
})) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
source_version: read.record.source_version,
|
|
176
|
+
target_version: read.record.target_version,
|
|
177
|
+
restart_failure_count: read.record.restart_failure_count,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=package-update-record.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ProjectId } from "@tutti/shared/ids";
|
|
2
|
+
import { type PackageUpdateLockAcquisition, type PackageUpdateLockInspection } from "./package-update-lock.js";
|
|
3
|
+
import { type PackageUpdateRuntimeRecord, type PackageUpdateRuntimeRecordRead } from "./package-update-record.js";
|
|
4
|
+
export type PackageUpdateStartupRecoveryResult = {
|
|
5
|
+
status: "continue";
|
|
6
|
+
} | {
|
|
7
|
+
status: "completed";
|
|
8
|
+
url: string;
|
|
9
|
+
restart_failure_count: number;
|
|
10
|
+
};
|
|
11
|
+
type HostRestoreResult = {
|
|
12
|
+
restored_project_ids: ProjectId[];
|
|
13
|
+
failed_project_ids: ProjectId[];
|
|
14
|
+
};
|
|
15
|
+
export declare function recoverPackageUpdateForConsoleStartup(options: {
|
|
16
|
+
tuttiHome: string;
|
|
17
|
+
cliEntrypoint: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
env: NodeJS.ProcessEnv;
|
|
20
|
+
source: "terminal" | "desktop";
|
|
21
|
+
currentVersion?: string;
|
|
22
|
+
readRecord?: () => PackageUpdateRuntimeRecordRead;
|
|
23
|
+
inspectLock?: () => PackageUpdateLockInspection;
|
|
24
|
+
removeRecoverableLock?: () => boolean;
|
|
25
|
+
acquireLock?: () => PackageUpdateLockAcquisition;
|
|
26
|
+
restoreHosts?: (record: PackageUpdateRuntimeRecord) => Promise<HostRestoreResult>;
|
|
27
|
+
deleteRecord?: (record: PackageUpdateRuntimeRecord) => boolean;
|
|
28
|
+
ensureConsole?: () => Promise<{
|
|
29
|
+
url: string;
|
|
30
|
+
}>;
|
|
31
|
+
now?: () => Date;
|
|
32
|
+
}): Promise<PackageUpdateStartupRecoveryResult>;
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=package-update-recovery.d.ts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readCliVersion } from "../cli/version.js";
|
|
2
|
+
import { ensureLocalConsole } from "./managed-console.js";
|
|
3
|
+
import { runPackageUpdateCompletion } from "./package-update-completion.js";
|
|
4
|
+
import { restorePackageUpdateHosts } from "./package-update-hosts.js";
|
|
5
|
+
import { inspectPackageUpdateLock, removeRecoverablePackageUpdateLock, tryAcquirePackageUpdateLock, } from "./package-update-lock.js";
|
|
6
|
+
import { PackageUpdateLogger } from "./package-update-log.js";
|
|
7
|
+
import { deletePackageUpdateRuntimeRecord, readPackageUpdateRuntimeRecord, updatePackageUpdateRuntimeRecord, } from "./package-update-record.js";
|
|
8
|
+
export async function recoverPackageUpdateForConsoleStartup(options) {
|
|
9
|
+
const currentVersion = options.currentVersion ?? readCliVersion();
|
|
10
|
+
const readRecord = options.readRecord ?? (() => readPackageUpdateRuntimeRecord({ tuttiHome: options.tuttiHome }));
|
|
11
|
+
const initialRecord = readRecord();
|
|
12
|
+
if (initialRecord.status !== "valid" || initialRecord.record.phase === "failed") {
|
|
13
|
+
return { status: "continue" };
|
|
14
|
+
}
|
|
15
|
+
const inspectLock = options.inspectLock ?? (() => inspectPackageUpdateLock({ tuttiHome: options.tuttiHome }));
|
|
16
|
+
const lockState = inspectLock();
|
|
17
|
+
if (lockState.status === "active") {
|
|
18
|
+
return { status: "continue" };
|
|
19
|
+
}
|
|
20
|
+
if (lockState.status === "recovery_required") {
|
|
21
|
+
const removed = (options.removeRecoverableLock ??
|
|
22
|
+
(() => removeRecoverablePackageUpdateLock({ tuttiHome: options.tuttiHome })))();
|
|
23
|
+
if (!removed) {
|
|
24
|
+
return { status: "continue" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const acquisition = (options.acquireLock ?? (() => tryAcquirePackageUpdateLock({ tuttiHome: options.tuttiHome })))();
|
|
28
|
+
if (acquisition.status !== "acquired") {
|
|
29
|
+
return { status: "continue" };
|
|
30
|
+
}
|
|
31
|
+
const logger = new PackageUpdateLogger({
|
|
32
|
+
tuttiHome: options.tuttiHome,
|
|
33
|
+
privatePaths: [options.tuttiHome],
|
|
34
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
35
|
+
});
|
|
36
|
+
const log = (entry) => {
|
|
37
|
+
try {
|
|
38
|
+
logger.append(entry);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Recovery correctness must not depend on the diagnostic log.
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
let lockReleased = false;
|
|
45
|
+
const releaseLock = () => {
|
|
46
|
+
if (!lockReleased) {
|
|
47
|
+
lockReleased = true;
|
|
48
|
+
acquisition.lease.release();
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
const current = readRecord();
|
|
53
|
+
if (current.status !== "valid" || current.record.phase === "failed") {
|
|
54
|
+
releaseLock();
|
|
55
|
+
return { status: "continue" };
|
|
56
|
+
}
|
|
57
|
+
const record = current.record;
|
|
58
|
+
const restoreHosts = options.restoreHosts ??
|
|
59
|
+
((value) => restorePackageUpdateHosts({
|
|
60
|
+
tuttiHome: options.tuttiHome,
|
|
61
|
+
projectIds: value.restore_project_ids,
|
|
62
|
+
env: options.env,
|
|
63
|
+
}));
|
|
64
|
+
if (record.source_version === currentVersion) {
|
|
65
|
+
let restored;
|
|
66
|
+
try {
|
|
67
|
+
restored = await restoreHosts(record);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
restored = {
|
|
71
|
+
restored_project_ids: [],
|
|
72
|
+
failed_project_ids: [...record.restore_project_ids],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const deleted = (options.deleteRecord ??
|
|
76
|
+
((value) => deletePackageUpdateRuntimeRecord({
|
|
77
|
+
tuttiHome: options.tuttiHome,
|
|
78
|
+
expectedTargetVersion: value.target_version,
|
|
79
|
+
expectedUpdaterPid: value.updater_pid,
|
|
80
|
+
})))(record);
|
|
81
|
+
releaseLock();
|
|
82
|
+
const recoveryIncomplete = restored.failed_project_ids.length > 0 || !deleted;
|
|
83
|
+
log({
|
|
84
|
+
level: restored.failed_project_ids.length === 0 && deleted ? "info" : "warn",
|
|
85
|
+
message: restored.failed_project_ids.length === 0 && deleted
|
|
86
|
+
? "Recovered an interrupted package update using the source CLI."
|
|
87
|
+
: "Package update source recovery requires follow-up.",
|
|
88
|
+
...(recoveryIncomplete ? { reasonCode: "update_recovery_required" } : {}),
|
|
89
|
+
});
|
|
90
|
+
return { status: "continue" };
|
|
91
|
+
}
|
|
92
|
+
if (record.target_version !== currentVersion) {
|
|
93
|
+
releaseLock();
|
|
94
|
+
return { status: "continue" };
|
|
95
|
+
}
|
|
96
|
+
const completionRecord = updatePackageUpdateRuntimeRecord({
|
|
97
|
+
tuttiHome: options.tuttiHome,
|
|
98
|
+
current: record,
|
|
99
|
+
phase: "restarting",
|
|
100
|
+
updaterPid: process.pid,
|
|
101
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
102
|
+
});
|
|
103
|
+
let consoleUrl;
|
|
104
|
+
try {
|
|
105
|
+
const completion = await runPackageUpdateCompletion({
|
|
106
|
+
tuttiHome: options.tuttiHome,
|
|
107
|
+
cliEntrypoint: options.cliEntrypoint,
|
|
108
|
+
env: options.env,
|
|
109
|
+
currentVersion,
|
|
110
|
+
logger,
|
|
111
|
+
restoreHosts: async () => await restoreHosts(completionRecord),
|
|
112
|
+
ensureConsole: async () => {
|
|
113
|
+
const result = await (options.ensureConsole ??
|
|
114
|
+
(() => ensureLocalConsole({
|
|
115
|
+
cwd: options.cwd,
|
|
116
|
+
env: options.env,
|
|
117
|
+
source: options.source,
|
|
118
|
+
})))();
|
|
119
|
+
consoleUrl = result.url;
|
|
120
|
+
return result;
|
|
121
|
+
},
|
|
122
|
+
openBrowser: () => false,
|
|
123
|
+
releaseLock,
|
|
124
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
125
|
+
});
|
|
126
|
+
if (consoleUrl === undefined) {
|
|
127
|
+
throw new Error("Recovered package update did not start a Local Console.");
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
status: "completed",
|
|
131
|
+
url: consoleUrl,
|
|
132
|
+
restart_failure_count: completion.restart_failure_count,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
releaseLock();
|
|
137
|
+
return { status: "continue" };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=package-update-recovery.js.map
|