@xfey/tutti 0.1.60 → 0.1.62
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/collaboration-state/clarification-records.d.ts +0 -9
- package/dist/collaboration-state/clarification-records.js +0 -57
- package/dist/collaboration-state/index.d.ts +6 -5
- package/dist/collaboration-state/index.js +5 -4
- package/dist/collaboration-state/messages.d.ts +4 -6
- package/dist/collaboration-state/messages.js +22 -29
- package/dist/collaboration-state/scratchpad-source-state.d.ts +4 -12
- package/dist/collaboration-state/scratchpad-source-state.js +24 -58
- package/dist/collaboration-state/scratchpad.d.ts +1 -2
- package/dist/collaboration-state/scratchpad.js +0 -59
- package/dist/collaboration-state/storage-types.d.ts +25 -5
- package/dist/collaboration-state/task-compile-context.d.ts +40 -0
- package/dist/collaboration-state/task-compile-context.js +273 -0
- package/dist/collaboration-state/types.d.ts +39 -9
- package/dist/control-plane/clarification-commands.d.ts +0 -1
- package/dist/control-plane/clarification-commands.js +0 -1
- package/dist/control-plane/index.js +16 -3
- package/dist/control-plane/project-context-bootstrap.d.ts +4 -4
- package/dist/control-plane/project-context-bootstrap.js +7 -12
- package/dist/control-plane/scratchpad-auto-refresh.js +12 -1
- package/dist/control-plane/scratchpad-refresh-start.js +77 -94
- package/dist/control-plane/scratchpad-refresh.js +3 -1
- package/dist/control-plane/scratchpad-source-messages.d.ts +15 -7
- package/dist/control-plane/scratchpad-source-messages.js +53 -69
- package/dist/control-plane/task-compile-clarification-context.d.ts +9 -0
- package/dist/control-plane/task-compile-clarification-context.js +70 -0
- package/dist/control-plane/task-compile-continuation.js +28 -56
- package/dist/control-plane/task-compile-output.js +50 -14
- package/dist/control-plane/task-compile-start.js +119 -55
- package/dist/control-plane/workflows/openai.d.ts +2 -1
- package/dist/control-plane/workflows/openai.js +13 -27
- package/dist/control-plane/workflows/types.d.ts +27 -17
- 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/migrations/0014_scratchpad_task_compile_context.sql +147 -0
- package/migrations/README.md +2 -1
- package/package.json +1 -1
- package/prompts/procedures/README.md +3 -3
- package/prompts/procedures/scratchpad-refresh.md +17 -9
- package/prompts/procedures/task-compile.md +6 -3
- package/prompts/prompt-flow-map.md +21 -14
- 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,223 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
5
|
+
export function packageUpdateLockPath(tuttiHome) {
|
|
6
|
+
return join(tuttiHome, "runtime", "package-update.lock");
|
|
7
|
+
}
|
|
8
|
+
function isPositiveInteger(value) {
|
|
9
|
+
return Number.isInteger(value) && value > 0;
|
|
10
|
+
}
|
|
11
|
+
function parseRecord(path) {
|
|
12
|
+
try {
|
|
13
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
if (Object.keys(value).sort().join("\n") !==
|
|
15
|
+
["acquired_at", "owner_id", "pid", "schema_version", "updated_at"].join("\n") ||
|
|
16
|
+
value.schema_version !== 1 ||
|
|
17
|
+
!isPositiveInteger(value.pid) ||
|
|
18
|
+
typeof value.owner_id !== "string" ||
|
|
19
|
+
value.owner_id === "" ||
|
|
20
|
+
typeof value.acquired_at !== "string" ||
|
|
21
|
+
typeof value.updated_at !== "string" ||
|
|
22
|
+
!Number.isFinite(Date.parse(value.acquired_at)) ||
|
|
23
|
+
!Number.isFinite(Date.parse(value.updated_at))) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function defaultProcessAlive(pid) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return (typeof error === "object" &&
|
|
39
|
+
error !== null &&
|
|
40
|
+
"code" in error &&
|
|
41
|
+
error.code === "EPERM");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function fileAge(path, now) {
|
|
45
|
+
try {
|
|
46
|
+
return Math.max(0, now() - statSync(path).mtimeMs);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function inspectPackageUpdateLock(options) {
|
|
53
|
+
const path = packageUpdateLockPath(options.tuttiHome);
|
|
54
|
+
const age = fileAge(path, options.now ?? Date.now);
|
|
55
|
+
if (age === null) {
|
|
56
|
+
return { status: "unlocked" };
|
|
57
|
+
}
|
|
58
|
+
const record = parseRecord(path);
|
|
59
|
+
if (record === null) {
|
|
60
|
+
return { status: "recovery_required" };
|
|
61
|
+
}
|
|
62
|
+
if ((options.processAlive ?? defaultProcessAlive)(record.pid)) {
|
|
63
|
+
return { status: "active" };
|
|
64
|
+
}
|
|
65
|
+
return age < (options.staleMs ?? DEFAULT_STALE_MS)
|
|
66
|
+
? { status: "active" }
|
|
67
|
+
: { status: "recovery_required" };
|
|
68
|
+
}
|
|
69
|
+
function ensurePrivateRuntimeDirectory(path) {
|
|
70
|
+
const directory = dirname(path);
|
|
71
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
72
|
+
chmodSync(directory, 0o700);
|
|
73
|
+
}
|
|
74
|
+
function writeRecord(path, record) {
|
|
75
|
+
const temporaryPath = `${path}.${record.owner_id}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(temporaryPath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
78
|
+
chmodSync(temporaryPath, 0o600);
|
|
79
|
+
renameSync(temporaryPath, path);
|
|
80
|
+
chmodSync(path, 0o600);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(temporaryPath);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Atomic rename normally removes the temporary path.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function tryAcquirePackageUpdateLock(options) {
|
|
92
|
+
const path = packageUpdateLockPath(options.tuttiHome);
|
|
93
|
+
const now = options.now ?? Date.now;
|
|
94
|
+
ensurePrivateRuntimeDirectory(path);
|
|
95
|
+
const ownerId = randomBytes(16).toString("base64url");
|
|
96
|
+
const timestamp = new Date(now()).toISOString();
|
|
97
|
+
const record = {
|
|
98
|
+
schema_version: 1,
|
|
99
|
+
pid: options.pid ?? process.pid,
|
|
100
|
+
owner_id: ownerId,
|
|
101
|
+
acquired_at: timestamp,
|
|
102
|
+
updated_at: timestamp,
|
|
103
|
+
};
|
|
104
|
+
let descriptor;
|
|
105
|
+
try {
|
|
106
|
+
descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (typeof error === "object" &&
|
|
110
|
+
error !== null &&
|
|
111
|
+
"code" in error &&
|
|
112
|
+
error.code === "EEXIST") {
|
|
113
|
+
const inspection = inspectPackageUpdateLock(options);
|
|
114
|
+
return inspection.status === "unlocked" ? { status: "active" } : inspection;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
closeSync(descriptor);
|
|
123
|
+
}
|
|
124
|
+
chmodSync(path, 0o600);
|
|
125
|
+
let released = false;
|
|
126
|
+
return {
|
|
127
|
+
status: "acquired",
|
|
128
|
+
lease: {
|
|
129
|
+
path,
|
|
130
|
+
transferTo: (pid) => {
|
|
131
|
+
if (released || !isPositiveInteger(pid)) {
|
|
132
|
+
throw new Error("Package update lock transfer is invalid.");
|
|
133
|
+
}
|
|
134
|
+
const current = parseRecord(path);
|
|
135
|
+
if (current?.owner_id !== ownerId) {
|
|
136
|
+
throw new Error("Package update lock ownership changed before transfer.");
|
|
137
|
+
}
|
|
138
|
+
writeRecord(path, {
|
|
139
|
+
...current,
|
|
140
|
+
pid,
|
|
141
|
+
updated_at: new Date(now()).toISOString(),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
release: () => {
|
|
145
|
+
if (released) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
released = true;
|
|
149
|
+
if (parseRecord(path)?.owner_id !== ownerId) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(path);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (typeof error !== "object" ||
|
|
157
|
+
error === null ||
|
|
158
|
+
!("code" in error) ||
|
|
159
|
+
error.code !== "ENOENT") {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export function removeRecoverablePackageUpdateLock(options) {
|
|
168
|
+
if (inspectPackageUpdateLock(options).status !== "recovery_required") {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(packageUpdateLockPath(options.tuttiHome));
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function transferPackageUpdateLockFromCurrentProcess(options) {
|
|
180
|
+
if (!isPositiveInteger(options.nextPid)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const path = packageUpdateLockPath(options.tuttiHome);
|
|
184
|
+
const record = parseRecord(path);
|
|
185
|
+
if (record === null || record.pid !== process.pid) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
writeRecord(path, {
|
|
189
|
+
...record,
|
|
190
|
+
pid: options.nextPid,
|
|
191
|
+
updated_at: new Date((options.now ?? Date.now)()).toISOString(),
|
|
192
|
+
});
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
export function releasePackageUpdateLockForCurrentProcess(tuttiHome) {
|
|
196
|
+
const path = packageUpdateLockPath(tuttiHome);
|
|
197
|
+
const record = parseRecord(path);
|
|
198
|
+
if (record === null || record.pid !== process.pid) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
unlinkSync(path);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
export function releasePackageUpdateLockForExpectedPid(tuttiHome, expectedPid) {
|
|
210
|
+
const path = packageUpdateLockPath(tuttiHome);
|
|
211
|
+
const record = parseRecord(path);
|
|
212
|
+
if (record === null || record.pid !== expectedPid) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
unlinkSync(path);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=package-update-lock.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare function packageUpdateLogPath(tuttiHome: string): string;
|
|
2
|
+
export declare function withPackageUpdateLogDescriptor<T>(tuttiHome: string, run: (descriptor: number) => T): T;
|
|
3
|
+
export declare function redactPackageUpdateOutput(value: string, privatePaths: readonly string[]): string;
|
|
4
|
+
export declare class PackageUpdateLogger {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(options: {
|
|
7
|
+
tuttiHome: string;
|
|
8
|
+
privatePaths?: readonly string[];
|
|
9
|
+
now?: () => Date;
|
|
10
|
+
});
|
|
11
|
+
append(options: {
|
|
12
|
+
level: "info" | "warn" | "error";
|
|
13
|
+
message: string;
|
|
14
|
+
phase?: string;
|
|
15
|
+
reasonCode?: string;
|
|
16
|
+
stdout?: string;
|
|
17
|
+
stderr?: string;
|
|
18
|
+
}): void;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=package-update-log.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { redactAndTruncateText, serializeRuntimeLogLine } from "@tutti/shared/utils";
|
|
5
|
+
const PACKAGE_UPDATE_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const PACKAGE_UPDATE_OUTPUT_MAX_CHARS = 16 * 1024;
|
|
7
|
+
export function packageUpdateLogPath(tuttiHome) {
|
|
8
|
+
return join(tuttiHome, "logs", "package-update.log");
|
|
9
|
+
}
|
|
10
|
+
function preparePackageUpdateLog(tuttiHome) {
|
|
11
|
+
const path = packageUpdateLogPath(tuttiHome);
|
|
12
|
+
const directory = dirname(path);
|
|
13
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
14
|
+
chmodSync(directory, 0o700);
|
|
15
|
+
if (existsSync(path) && statSync(path).size >= PACKAGE_UPDATE_LOG_MAX_BYTES) {
|
|
16
|
+
const previous = `${path}.1`;
|
|
17
|
+
if (existsSync(previous)) {
|
|
18
|
+
unlinkSync(previous);
|
|
19
|
+
}
|
|
20
|
+
renameSync(path, previous);
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
export function withPackageUpdateLogDescriptor(tuttiHome, run) {
|
|
25
|
+
const path = preparePackageUpdateLog(tuttiHome);
|
|
26
|
+
const descriptor = openSync(path, "a", 0o600);
|
|
27
|
+
chmodSync(path, 0o600);
|
|
28
|
+
try {
|
|
29
|
+
return run(descriptor);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
closeSync(descriptor);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function redactPackageUpdateOutput(value, privatePaths) {
|
|
36
|
+
let redacted = redactAndTruncateText(value, PACKAGE_UPDATE_OUTPUT_MAX_CHARS);
|
|
37
|
+
for (const path of [...privatePaths, tmpdir()].filter((entry) => entry !== "")) {
|
|
38
|
+
redacted = redacted.replaceAll(path, "[REDACTED:local_path]");
|
|
39
|
+
}
|
|
40
|
+
return redacted.replace(/(^|[\s"'`=:(])(?:\/tmp\/|\/private\/tmp\/)[^\s"',)]+/gu, "$1[REDACTED:local_path]");
|
|
41
|
+
}
|
|
42
|
+
export class PackageUpdateLogger {
|
|
43
|
+
#tuttiHome;
|
|
44
|
+
#privatePaths;
|
|
45
|
+
#now;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.#tuttiHome = options.tuttiHome;
|
|
48
|
+
this.#privatePaths = options.privatePaths ?? [];
|
|
49
|
+
this.#now = options.now ?? (() => new Date());
|
|
50
|
+
}
|
|
51
|
+
append(options) {
|
|
52
|
+
const path = preparePackageUpdateLog(this.#tuttiHome);
|
|
53
|
+
const fields = {
|
|
54
|
+
...(options.phase === undefined ? {} : { phase: options.phase }),
|
|
55
|
+
...(options.reasonCode === undefined ? {} : { reason_code: options.reasonCode }),
|
|
56
|
+
...(options.stdout === undefined
|
|
57
|
+
? {}
|
|
58
|
+
: { stdout: redactPackageUpdateOutput(options.stdout, this.#privatePaths) }),
|
|
59
|
+
...(options.stderr === undefined
|
|
60
|
+
? {}
|
|
61
|
+
: { stderr: redactPackageUpdateOutput(options.stderr, this.#privatePaths) }),
|
|
62
|
+
};
|
|
63
|
+
const line = serializeRuntimeLogLine({
|
|
64
|
+
level: options.level,
|
|
65
|
+
service: "tutti-package-update",
|
|
66
|
+
component: "worker",
|
|
67
|
+
message: options.message,
|
|
68
|
+
fields,
|
|
69
|
+
now: this.#now,
|
|
70
|
+
});
|
|
71
|
+
appendFileSync(path, `${line}\n`, { encoding: "utf8", mode: 0o600 });
|
|
72
|
+
chmodSync(path, 0o600);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=package-update-log.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PackageUpdatePreparationLease } from "./package-update-service.js";
|
|
2
|
+
import { type PackageUpdateWorkerInput } from "./package-update-worker.js";
|
|
3
|
+
export type HiddenPackageUpdateProcessPlan = {
|
|
4
|
+
executable: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: NodeJS.ProcessEnv;
|
|
8
|
+
shell: false;
|
|
9
|
+
};
|
|
10
|
+
export declare function buildHiddenPackageUpdateProcessPlan(options: {
|
|
11
|
+
input: PackageUpdateWorkerInput;
|
|
12
|
+
role: "worker" | "completion";
|
|
13
|
+
}): HiddenPackageUpdateProcessPlan;
|
|
14
|
+
export declare function spawnPackageUpdateWorkerProcess(options: {
|
|
15
|
+
input: PackageUpdateWorkerInput;
|
|
16
|
+
lease: PackageUpdatePreparationLease;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
pid: number;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function runPackageUpdateWorkerProtocol(): Promise<void>;
|
|
21
|
+
export declare function runPackageUpdateCompletionProtocol(): Promise<void>;
|
|
22
|
+
//# sourceMappingURL=package-update-process.d.ts.map
|
|
@@ -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
|