@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,297 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
|
|
5
|
+
import { readCliPackageIdentity } from "../cli/version.js";
|
|
6
|
+
import { buildGlobalInstallPlan, createPackageCommandPlan, PUBLISHED_TUTTI_PACKAGE_NAME, runPackageCommand, compareExactSemver, parseExactSemver, } from "../package-installation/index.js";
|
|
7
|
+
import { releasePackageUpdateLockForCurrentProcess } from "./package-update-lock.js";
|
|
8
|
+
import { PackageUpdateLogger } from "./package-update-log.js";
|
|
9
|
+
import { updatePackageUpdateRuntimeRecord, writePackageUpdateRuntimeRecord, } from "./package-update-record.js";
|
|
10
|
+
import { restorePackageUpdateHosts, stopPackageUpdateHost, } from "./package-update-hosts.js";
|
|
11
|
+
const WORKER_INPUT_KEYS = [
|
|
12
|
+
"cli_entrypoint",
|
|
13
|
+
"completion_environment",
|
|
14
|
+
"installation",
|
|
15
|
+
"restore_project_ids",
|
|
16
|
+
"schema_version",
|
|
17
|
+
"target",
|
|
18
|
+
"tutti_home",
|
|
19
|
+
];
|
|
20
|
+
const INSTALLATION_KEYS = [
|
|
21
|
+
"currentVersion",
|
|
22
|
+
"globalRoot",
|
|
23
|
+
"nodeExecutable",
|
|
24
|
+
"npmEnvironment",
|
|
25
|
+
"npmExecutable",
|
|
26
|
+
"packageName",
|
|
27
|
+
"packageRoot",
|
|
28
|
+
];
|
|
29
|
+
const NPM_ENV_KEYS = new Set([
|
|
30
|
+
"HOME",
|
|
31
|
+
"LANG",
|
|
32
|
+
"LC_ALL",
|
|
33
|
+
"LC_CTYPE",
|
|
34
|
+
"LOGNAME",
|
|
35
|
+
"NO_PROXY",
|
|
36
|
+
"NPM_CONFIG_UPDATE_NOTIFIER",
|
|
37
|
+
"PATH",
|
|
38
|
+
"TMPDIR",
|
|
39
|
+
"USER",
|
|
40
|
+
]);
|
|
41
|
+
const COMPLETION_ENV_KEYS = new Set([
|
|
42
|
+
"HOME",
|
|
43
|
+
"LANG",
|
|
44
|
+
"LC_ALL",
|
|
45
|
+
"LC_CTYPE",
|
|
46
|
+
"LOGNAME",
|
|
47
|
+
"NO_PROXY",
|
|
48
|
+
"PATH",
|
|
49
|
+
"TMPDIR",
|
|
50
|
+
"TUTTI_HOME",
|
|
51
|
+
"USER",
|
|
52
|
+
]);
|
|
53
|
+
function strictObject(value, keys) {
|
|
54
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const record = value;
|
|
58
|
+
return Object.keys(record).every((key) => keys.includes(key)) ? record : null;
|
|
59
|
+
}
|
|
60
|
+
function strictEnvironment(value, allowedKeys) {
|
|
61
|
+
const record = strictObject(value, [...allowedKeys]);
|
|
62
|
+
if (record === null ||
|
|
63
|
+
Object.entries(record).some(([key, nested]) => !allowedKeys.has(key) || typeof nested !== "string" || nested === "")) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return record;
|
|
67
|
+
}
|
|
68
|
+
export function parsePackageUpdateWorkerInput(value, options = {}) {
|
|
69
|
+
const input = strictObject(value, WORKER_INPUT_KEYS);
|
|
70
|
+
const installation = strictObject(input?.installation, INSTALLATION_KEYS);
|
|
71
|
+
const target = strictObject(input?.target, ["packageName", "version"]);
|
|
72
|
+
const sourceVersion = parseExactSemver(installation?.currentVersion);
|
|
73
|
+
const targetVersion = parseExactSemver(target?.version);
|
|
74
|
+
const npmEnvironment = strictEnvironment(installation?.npmEnvironment, NPM_ENV_KEYS);
|
|
75
|
+
const completionEnvironment = strictEnvironment(input?.completion_environment, COMPLETION_ENV_KEYS);
|
|
76
|
+
const tuttiHome = input?.tutti_home;
|
|
77
|
+
const packageRoot = installation?.packageRoot;
|
|
78
|
+
const globalRoot = installation?.globalRoot;
|
|
79
|
+
if (input?.schema_version !== 1 ||
|
|
80
|
+
typeof tuttiHome !== "string" ||
|
|
81
|
+
!isAbsolute(tuttiHome) ||
|
|
82
|
+
resolve(tuttiHome) !== resolve(options.defaultTuttiHome ?? join(homedir(), ".tutti")) ||
|
|
83
|
+
typeof input.cli_entrypoint !== "string" ||
|
|
84
|
+
!isAbsolute(input.cli_entrypoint) ||
|
|
85
|
+
installation?.packageName !== PUBLISHED_TUTTI_PACKAGE_NAME ||
|
|
86
|
+
sourceVersion === null ||
|
|
87
|
+
target?.packageName !== PUBLISHED_TUTTI_PACKAGE_NAME ||
|
|
88
|
+
targetVersion === null ||
|
|
89
|
+
compareExactSemver(targetVersion, sourceVersion) <= 0 ||
|
|
90
|
+
typeof packageRoot !== "string" ||
|
|
91
|
+
!isAbsolute(packageRoot) ||
|
|
92
|
+
typeof globalRoot !== "string" ||
|
|
93
|
+
!isAbsolute(globalRoot) ||
|
|
94
|
+
typeof installation.npmExecutable !== "string" ||
|
|
95
|
+
!isAbsolute(installation.npmExecutable) ||
|
|
96
|
+
typeof installation.nodeExecutable !== "string" ||
|
|
97
|
+
!isAbsolute(installation.nodeExecutable) ||
|
|
98
|
+
npmEnvironment === null ||
|
|
99
|
+
typeof npmEnvironment.PATH !== "string" ||
|
|
100
|
+
completionEnvironment === null ||
|
|
101
|
+
completionEnvironment.TUTTI_HOME !== tuttiHome ||
|
|
102
|
+
typeof completionEnvironment.PATH !== "string" ||
|
|
103
|
+
!Array.isArray(input.restore_project_ids) ||
|
|
104
|
+
!input.restore_project_ids.every((projectId) => typeof projectId === "string" && isPrefixedId(projectId, ID_PREFIXES.project)) ||
|
|
105
|
+
new Set(input.restore_project_ids).size !== input.restore_project_ids.length) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
if (realpathSync(join(globalRoot, ...PUBLISHED_TUTTI_PACKAGE_NAME.split("/"))) !==
|
|
110
|
+
realpathSync(packageRoot)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
schema_version: 1,
|
|
119
|
+
tutti_home: resolve(tuttiHome),
|
|
120
|
+
cli_entrypoint: resolve(input.cli_entrypoint),
|
|
121
|
+
installation: {
|
|
122
|
+
packageName: PUBLISHED_TUTTI_PACKAGE_NAME,
|
|
123
|
+
currentVersion: sourceVersion,
|
|
124
|
+
packageRoot: resolve(packageRoot),
|
|
125
|
+
globalRoot: resolve(globalRoot),
|
|
126
|
+
npmExecutable: resolve(installation.npmExecutable),
|
|
127
|
+
nodeExecutable: resolve(installation.nodeExecutable),
|
|
128
|
+
npmEnvironment,
|
|
129
|
+
},
|
|
130
|
+
target: { packageName: PUBLISHED_TUTTI_PACKAGE_NAME, version: targetVersion },
|
|
131
|
+
restore_project_ids: [...input.restore_project_ids].sort(),
|
|
132
|
+
completion_environment: completionEnvironment,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function createPackageUpdateWorkerInput(options) {
|
|
136
|
+
if (!isAbsolute(options.tuttiHome) || !isAbsolute(options.cliEntrypoint)) {
|
|
137
|
+
throw new Error("Package update worker paths must be absolute.");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
schema_version: 1,
|
|
141
|
+
tutti_home: resolve(options.tuttiHome),
|
|
142
|
+
cli_entrypoint: resolve(options.cliEntrypoint),
|
|
143
|
+
installation: options.installation,
|
|
144
|
+
target: options.target,
|
|
145
|
+
restore_project_ids: [...new Set(options.restoreProjectIds)].sort(),
|
|
146
|
+
completion_environment: { ...options.completionEnvironment },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export async function verifyPackageUpdatePostInstall(input, runner = runPackageCommand, readIdentity = readCliPackageIdentity) {
|
|
150
|
+
try {
|
|
151
|
+
const identity = readIdentity();
|
|
152
|
+
if (identity.name !== PUBLISHED_TUTTI_PACKAGE_NAME ||
|
|
153
|
+
identity.version !== input.target.version ||
|
|
154
|
+
realpathSync(identity.root) !== realpathSync(input.installation.packageRoot) ||
|
|
155
|
+
!existsSync(input.cli_entrypoint)) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const result = await runner(createPackageCommandPlan({
|
|
159
|
+
executable: input.installation.nodeExecutable,
|
|
160
|
+
args: [input.cli_entrypoint, "--version"],
|
|
161
|
+
env: input.completion_environment,
|
|
162
|
+
}));
|
|
163
|
+
return result.status === 0 && result.stdout.trim() === input.target.version;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export async function runPackageUpdateWorker(input, dependencies) {
|
|
170
|
+
const now = dependencies.now ?? (() => new Date());
|
|
171
|
+
const logger = dependencies.logger ??
|
|
172
|
+
new PackageUpdateLogger({
|
|
173
|
+
tuttiHome: input.tutti_home,
|
|
174
|
+
privatePaths: [
|
|
175
|
+
input.installation.packageRoot,
|
|
176
|
+
input.installation.globalRoot,
|
|
177
|
+
input.tutti_home,
|
|
178
|
+
],
|
|
179
|
+
now,
|
|
180
|
+
});
|
|
181
|
+
const writeRecord = dependencies.writeRecord ??
|
|
182
|
+
((record) => writePackageUpdateRuntimeRecord(input.tutti_home, record));
|
|
183
|
+
const updateRecord = dependencies.updateRecord ??
|
|
184
|
+
((record, options) => updatePackageUpdateRuntimeRecord({
|
|
185
|
+
tuttiHome: input.tutti_home,
|
|
186
|
+
current: record,
|
|
187
|
+
phase: options.phase,
|
|
188
|
+
now,
|
|
189
|
+
...(options.failureReasonCode === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { failureReasonCode: options.failureReasonCode }),
|
|
192
|
+
...(options.restartFailureCount === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { restartFailureCount: options.restartFailureCount }),
|
|
195
|
+
}));
|
|
196
|
+
const releaseLock = dependencies.releaseLock ?? (() => releasePackageUpdateLockForCurrentProcess(input.tutti_home));
|
|
197
|
+
const restoreHosts = dependencies.restoreHosts ??
|
|
198
|
+
((projectIds) => restorePackageUpdateHosts({
|
|
199
|
+
tuttiHome: input.tutti_home,
|
|
200
|
+
projectIds,
|
|
201
|
+
env: input.completion_environment,
|
|
202
|
+
}));
|
|
203
|
+
const stopHost = dependencies.stopHost ??
|
|
204
|
+
((projectId) => stopPackageUpdateHost({ tuttiHome: input.tutti_home, projectId }));
|
|
205
|
+
const startedAt = now().toISOString();
|
|
206
|
+
let record = {
|
|
207
|
+
schema_version: 1,
|
|
208
|
+
source_version: input.installation.currentVersion,
|
|
209
|
+
target_version: input.target.version,
|
|
210
|
+
phase: "preparing",
|
|
211
|
+
restore_project_ids: [...input.restore_project_ids],
|
|
212
|
+
updater_pid: process.pid,
|
|
213
|
+
started_at: startedAt,
|
|
214
|
+
updated_at: startedAt,
|
|
215
|
+
};
|
|
216
|
+
writeRecord(record);
|
|
217
|
+
const log = (entry) => {
|
|
218
|
+
try {
|
|
219
|
+
logger.append(entry);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Update correctness must not depend on the diagnostic log remaining writable.
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
log({ level: "info", message: "Package update worker accepted.", phase: record.phase });
|
|
226
|
+
const fail = async (reasonCode, stoppedProjectIds) => {
|
|
227
|
+
let restartFailureCount = stoppedProjectIds.length;
|
|
228
|
+
try {
|
|
229
|
+
const restored = await restoreHosts(stoppedProjectIds);
|
|
230
|
+
restartFailureCount = restored.failed_project_ids.length;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Every Host in the stopped set remains conservatively classified as not restored.
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
record = updateRecord(record, {
|
|
237
|
+
phase: "failed",
|
|
238
|
+
failureReasonCode: reasonCode,
|
|
239
|
+
restartFailureCount,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// A prior valid phase record is left for startup recovery to classify.
|
|
244
|
+
}
|
|
245
|
+
log({
|
|
246
|
+
level: "error",
|
|
247
|
+
message: "Package update stopped before completion.",
|
|
248
|
+
phase: record.phase,
|
|
249
|
+
reasonCode,
|
|
250
|
+
});
|
|
251
|
+
releaseLock();
|
|
252
|
+
return {
|
|
253
|
+
status: "failed",
|
|
254
|
+
reason_code: reasonCode,
|
|
255
|
+
restart_failure_count: restartFailureCount,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
const stoppedProjectIds = [];
|
|
259
|
+
try {
|
|
260
|
+
record = updateRecord(record, { phase: "stopping_hosts" });
|
|
261
|
+
for (const projectId of input.restore_project_ids) {
|
|
262
|
+
const result = await stopHost(projectId);
|
|
263
|
+
if (result !== "stopped") {
|
|
264
|
+
return await fail(result, stoppedProjectIds);
|
|
265
|
+
}
|
|
266
|
+
stoppedProjectIds.push(projectId);
|
|
267
|
+
}
|
|
268
|
+
record = updateRecord(record, { phase: "installing" });
|
|
269
|
+
const install = await (dependencies.commandRunner ?? runPackageCommand)(buildGlobalInstallPlan({ installation: input.installation, target: input.target }));
|
|
270
|
+
log({
|
|
271
|
+
level: install.status === 0 ? "info" : "error",
|
|
272
|
+
message: install.status === 0 ? "npm install completed." : "npm install failed.",
|
|
273
|
+
phase: record.phase,
|
|
274
|
+
...(install.stdout === "" ? {} : { stdout: install.stdout }),
|
|
275
|
+
...(install.stderr === "" ? {} : { stderr: install.stderr }),
|
|
276
|
+
});
|
|
277
|
+
if (install.status !== 0) {
|
|
278
|
+
return await fail("npm_install_failed", stoppedProjectIds);
|
|
279
|
+
}
|
|
280
|
+
const verified = await (dependencies.verifyPostInstall ?? verifyPackageUpdatePostInstall)(input);
|
|
281
|
+
if (!verified) {
|
|
282
|
+
return await fail("post_install_validation_failed", stoppedProjectIds);
|
|
283
|
+
}
|
|
284
|
+
record = updateRecord(record, { phase: "restarting" });
|
|
285
|
+
await dependencies.startCompletion(input, record);
|
|
286
|
+
log({
|
|
287
|
+
level: "info",
|
|
288
|
+
message: "Package update handed off to the installed CLI.",
|
|
289
|
+
phase: record.phase,
|
|
290
|
+
});
|
|
291
|
+
return { status: "handed_off" };
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return await fail(record.phase === "restarting" ? "completion_start_failed" : "worker_crashed", stoppedProjectIds);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=package-update-worker.js.map
|
|
@@ -3,6 +3,7 @@ import { type ConfigureProjectOpenAiProviderResult, type OpenAiProviderCredentia
|
|
|
3
3
|
import { type FetchLike } from "../cli/host-runtime-endpoint.js";
|
|
4
4
|
import { type RuntimeProjectRow } from "../cli/runtime-commands.js";
|
|
5
5
|
import { type LocalConsoleInvocationContext } from "./invocation-context.js";
|
|
6
|
+
import { LocalConsoleOperationCoordinator } from "./operation-coordinator.js";
|
|
6
7
|
export type LocalConsoleProject = {
|
|
7
8
|
project_id: ProjectId;
|
|
8
9
|
display_name: string;
|
|
@@ -55,6 +56,7 @@ export declare class LocalConsoleProjectService {
|
|
|
55
56
|
serviceEnvironment?: NodeJS.ProcessEnv;
|
|
56
57
|
fetchImpl?: FetchLike;
|
|
57
58
|
validateProviderCredential?: OpenAiProviderCredentialValidator;
|
|
59
|
+
operations?: LocalConsoleOperationCoordinator;
|
|
58
60
|
});
|
|
59
61
|
listProjects(context: LocalConsoleInvocationContext): Promise<LocalConsoleProject[]>;
|
|
60
62
|
discoverModels(input: {
|
|
@@ -12,6 +12,7 @@ import { resolveManagedProjectContext } from "../cli/project-resolver.js";
|
|
|
12
12
|
import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
|
|
13
13
|
import { readCliVersion } from "../cli/version.js";
|
|
14
14
|
import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
|
|
15
|
+
import { LocalConsoleOperationConflictError, LocalConsoleOperationCoordinator, } from "./operation-coordinator.js";
|
|
15
16
|
const OPEN_UPDATE_SHUTDOWN_WAIT_MS = 5_000;
|
|
16
17
|
export class LocalConsoleProjectError extends Error {
|
|
17
18
|
code;
|
|
@@ -153,12 +154,13 @@ export class LocalConsoleProjectService {
|
|
|
153
154
|
#serviceEnvironment;
|
|
154
155
|
#fetchImpl;
|
|
155
156
|
#validateProviderCredential;
|
|
157
|
+
#operations;
|
|
156
158
|
#launches = new Map();
|
|
157
|
-
#mutationTail = Promise.resolve();
|
|
158
159
|
constructor(options) {
|
|
159
160
|
this.#tuttiHome = resolve(options.tuttiHome);
|
|
160
161
|
this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
|
|
161
162
|
this.#fetchImpl = options.fetchImpl ?? fetch;
|
|
163
|
+
this.#operations = options.operations ?? new LocalConsoleOperationCoordinator();
|
|
162
164
|
this.#validateProviderCredential =
|
|
163
165
|
options.validateProviderCredential ?? validateOpenAiCredential;
|
|
164
166
|
}
|
|
@@ -172,11 +174,6 @@ export class LocalConsoleProjectService {
|
|
|
172
174
|
}),
|
|
173
175
|
};
|
|
174
176
|
}
|
|
175
|
-
#runMutation(operation) {
|
|
176
|
-
const result = this.#mutationTail.then(operation, operation);
|
|
177
|
-
this.#mutationTail = result.then(() => undefined, () => undefined);
|
|
178
|
-
return result;
|
|
179
|
-
}
|
|
180
177
|
async listProjects(context) {
|
|
181
178
|
const operation = this.#operationOptions(context);
|
|
182
179
|
const rows = await listRuntimeProjects({
|
|
@@ -293,13 +290,17 @@ export class LocalConsoleProjectService {
|
|
|
293
290
|
if (active !== undefined) {
|
|
294
291
|
return await active;
|
|
295
292
|
}
|
|
296
|
-
const launch = this.#
|
|
293
|
+
const launch = this.#operations
|
|
294
|
+
.runProjectOperation(async () => await this.#launchProject({
|
|
297
295
|
workspacePath,
|
|
298
296
|
...(input.project === undefined
|
|
299
297
|
? {}
|
|
300
298
|
: { project: validateProjectMetadata(input.project) }),
|
|
301
|
-
...(input.provider === undefined
|
|
302
|
-
|
|
299
|
+
...(input.provider === undefined
|
|
300
|
+
? {}
|
|
301
|
+
: { provider: validateProvider(input.provider) }),
|
|
302
|
+
}, context))
|
|
303
|
+
.finally(() => {
|
|
303
304
|
this.#launches.delete(workspacePath);
|
|
304
305
|
});
|
|
305
306
|
this.#launches.set(workspacePath, launch);
|
|
@@ -380,7 +381,7 @@ export class LocalConsoleProjectService {
|
|
|
380
381
|
}
|
|
381
382
|
}
|
|
382
383
|
async openProject(projectId, context) {
|
|
383
|
-
return await this.#
|
|
384
|
+
return await this.#operations.runProjectOperation(async () => {
|
|
384
385
|
const current = await this.#readProject(projectId, context);
|
|
385
386
|
if (current.open_url === undefined) {
|
|
386
387
|
throw new LocalConsoleProjectError("project_open_unavailable", "This project does not have a Relay destination yet.");
|
|
@@ -461,7 +462,7 @@ export class LocalConsoleProjectService {
|
|
|
461
462
|
});
|
|
462
463
|
}
|
|
463
464
|
async refreshInvite(projectId, context) {
|
|
464
|
-
return await this.#
|
|
465
|
+
return await this.#operations.runProjectOperation(async () => {
|
|
465
466
|
const project = resolveManagedProjectContext({
|
|
466
467
|
target: projectId,
|
|
467
468
|
...this.#operationOptions(context),
|
|
@@ -491,7 +492,7 @@ export class LocalConsoleProjectService {
|
|
|
491
492
|
});
|
|
492
493
|
}
|
|
493
494
|
async stopProject(projectId, context) {
|
|
494
|
-
return await this.#
|
|
495
|
+
return await this.#operations.runProjectOperation(async () => {
|
|
495
496
|
try {
|
|
496
497
|
return {
|
|
497
498
|
message: await runStopCommand(projectId, this.#operationOptions(context)),
|
|
@@ -506,6 +507,9 @@ export class LocalConsoleProjectService {
|
|
|
506
507
|
if (error instanceof LocalConsoleProjectError) {
|
|
507
508
|
return error;
|
|
508
509
|
}
|
|
510
|
+
if (error instanceof LocalConsoleOperationConflictError) {
|
|
511
|
+
return new LocalConsoleProjectError(error.code, error.message, error.statusCode);
|
|
512
|
+
}
|
|
509
513
|
if (error instanceof LaunchError) {
|
|
510
514
|
return new LocalConsoleProjectError(error.code, error.message);
|
|
511
515
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type FastifyInstance } from "fastify";
|
|
2
|
+
import { type PackageUpdateCompletionResult } from "./package-update-record.js";
|
|
3
|
+
import { LocalConsolePackageUpdateService, type LocalConsolePackageUpdatePreparation } from "./package-update-service.js";
|
|
4
|
+
import { LocalConsoleOperationCoordinator } from "./operation-coordinator.js";
|
|
2
5
|
export declare const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
|
|
3
6
|
type LocalConsoleRuntimeMetadata = {
|
|
4
7
|
instanceId: string;
|
|
@@ -17,6 +20,12 @@ type LocalConsoleServerOptions = {
|
|
|
17
20
|
runtime?: LocalConsoleRuntimeMetadata;
|
|
18
21
|
onActivity?: () => void;
|
|
19
22
|
onShutdown?: () => void;
|
|
23
|
+
operationCoordinator?: LocalConsoleOperationCoordinator;
|
|
24
|
+
packageUpdateService?: Pick<LocalConsolePackageUpdateService, "getStatus" | "beginUpdate">;
|
|
25
|
+
startPackageUpdate?: (preparation: LocalConsolePackageUpdatePreparation) => Promise<{
|
|
26
|
+
pid: number;
|
|
27
|
+
}>;
|
|
28
|
+
getPackageUpdateCompletion?: () => PackageUpdateCompletionResult | undefined;
|
|
20
29
|
};
|
|
21
30
|
export declare function createLocalConsoleServer(options: LocalConsoleServerOptions): FastifyInstance;
|
|
22
31
|
export declare function runLocalConsoleProcess(options?: {
|
|
@@ -7,7 +7,12 @@ import { readCliVersion } from "../cli/version.js";
|
|
|
7
7
|
import { registerHostWebStaticRoutes, resolvePackagedWebStaticRoot } from "../http/static-web.js";
|
|
8
8
|
import { detectLocalFolderPicker, pickLocalFolder } from "./folder-picker.js";
|
|
9
9
|
import { LOCAL_CONSOLE_INVOCATION_ENV_KEYS, createLocalConsoleServiceEnvironment, } from "./invocation-context.js";
|
|
10
|
+
import { spawnPackageUpdateWorkerProcess } from "./package-update-process.js";
|
|
11
|
+
import { consumePackageUpdateCompletion, } from "./package-update-record.js";
|
|
12
|
+
import { LocalConsolePackageUpdateService, } from "./package-update-service.js";
|
|
13
|
+
import { createPackageUpdateWorkerInput } from "./package-update-worker.js";
|
|
10
14
|
import { LocalConsoleProjectError, LocalConsoleProjectService, } from "./project-service.js";
|
|
15
|
+
import { LocalConsoleOperationConflictError, LocalConsoleOperationCoordinator, } from "./operation-coordinator.js";
|
|
11
16
|
import { LOCAL_CONSOLE_SESSION_COOKIE, LocalConsoleSessionRegistry, localConsoleSessionCookie, parseCookieHeader, } from "./session.js";
|
|
12
17
|
import { createLocalConsoleSecret, deleteLocalConsoleRuntimeEndpoint, writeLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
|
|
13
18
|
export const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
|
|
@@ -66,10 +71,51 @@ export function createLocalConsoleServer(options) {
|
|
|
66
71
|
const sessions = new LocalConsoleSessionRegistry({
|
|
67
72
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
68
73
|
});
|
|
74
|
+
const operations = options.operationCoordinator ?? new LocalConsoleOperationCoordinator();
|
|
69
75
|
const projects = new LocalConsoleProjectService({
|
|
70
76
|
tuttiHome,
|
|
77
|
+
operations,
|
|
71
78
|
...(options.env === undefined ? {} : { serviceEnvironment: options.env }),
|
|
72
79
|
});
|
|
80
|
+
const packageUpdates = options.packageUpdateService ??
|
|
81
|
+
new LocalConsolePackageUpdateService({
|
|
82
|
+
tuttiHome,
|
|
83
|
+
operations,
|
|
84
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
85
|
+
});
|
|
86
|
+
const startPackageUpdate = options.startPackageUpdate ??
|
|
87
|
+
(async (preparation) => {
|
|
88
|
+
try {
|
|
89
|
+
const entrypoint = options.runtime?.entrypoint;
|
|
90
|
+
if (entrypoint === undefined) {
|
|
91
|
+
throw new Error("Local Console package update entrypoint is unavailable.");
|
|
92
|
+
}
|
|
93
|
+
const completionEnvironment = createLocalConsoleServiceEnvironment({
|
|
94
|
+
env: options.env ?? process.env,
|
|
95
|
+
tuttiHome,
|
|
96
|
+
});
|
|
97
|
+
completionEnvironment.PATH = preparation.installation.npmEnvironment.PATH;
|
|
98
|
+
const noProxy = preparation.installation.npmEnvironment.NO_PROXY;
|
|
99
|
+
if (noProxy !== undefined && noProxy !== "") {
|
|
100
|
+
completionEnvironment.NO_PROXY = noProxy;
|
|
101
|
+
}
|
|
102
|
+
return await spawnPackageUpdateWorkerProcess({
|
|
103
|
+
input: createPackageUpdateWorkerInput({
|
|
104
|
+
tuttiHome,
|
|
105
|
+
cliEntrypoint: entrypoint,
|
|
106
|
+
installation: preparation.installation,
|
|
107
|
+
target: preparation.target,
|
|
108
|
+
restoreProjectIds: preparation.restore_project_ids,
|
|
109
|
+
completionEnvironment,
|
|
110
|
+
}),
|
|
111
|
+
lease: preparation.lease,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
preparation.lease.release();
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
73
119
|
app.addHook("onRequest", (request, reply, done) => {
|
|
74
120
|
try {
|
|
75
121
|
requireLoopbackHost(request);
|
|
@@ -88,7 +134,9 @@ export function createLocalConsoleServer(options) {
|
|
|
88
134
|
}
|
|
89
135
|
});
|
|
90
136
|
app.setErrorHandler((error, _request, reply) => {
|
|
91
|
-
const normalized = error instanceof LocalConsoleHttpError ||
|
|
137
|
+
const normalized = error instanceof LocalConsoleHttpError ||
|
|
138
|
+
error instanceof LocalConsoleProjectError ||
|
|
139
|
+
error instanceof LocalConsoleOperationConflictError
|
|
92
140
|
? error
|
|
93
141
|
: isValidationError(error)
|
|
94
142
|
? new LocalConsoleHttpError(400, "bad_request", "Local Console request is invalid.")
|
|
@@ -222,6 +270,7 @@ export function createLocalConsoleServer(options) {
|
|
|
222
270
|
});
|
|
223
271
|
app.get(`${LOCAL_CONSOLE_API_BASE}/bootstrap`, (request) => {
|
|
224
272
|
const session = requireSession(request);
|
|
273
|
+
const packageUpdateCompletion = options.getPackageUpdateCompletion?.();
|
|
225
274
|
return {
|
|
226
275
|
csrf_token: session.csrfToken,
|
|
227
276
|
invocation: session.context.source === "terminal"
|
|
@@ -232,8 +281,42 @@ export function createLocalConsoleServer(options) {
|
|
|
232
281
|
: { source: "desktop" },
|
|
233
282
|
platform: process.platform,
|
|
234
283
|
folder_picker: detectLocalFolderPicker(process.platform, session.context.environment).kind,
|
|
284
|
+
...(packageUpdateCompletion === undefined
|
|
285
|
+
? {}
|
|
286
|
+
: { package_update_completion: packageUpdateCompletion }),
|
|
235
287
|
};
|
|
236
288
|
});
|
|
289
|
+
app.get(`${LOCAL_CONSOLE_API_BASE}/package-update`, {
|
|
290
|
+
schema: {
|
|
291
|
+
querystring: {
|
|
292
|
+
type: "object",
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
properties: { refresh: { type: "string", enum: ["true"] } },
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
}, async (request) => {
|
|
298
|
+
requireSession(request);
|
|
299
|
+
return await packageUpdates.getStatus({ refresh: request.query.refresh === "true" });
|
|
300
|
+
});
|
|
301
|
+
app.post(`${LOCAL_CONSOLE_API_BASE}/package-update`, async (request, reply) => {
|
|
302
|
+
requireSession(request, true);
|
|
303
|
+
if (request.body !== undefined) {
|
|
304
|
+
throw new LocalConsoleHttpError(400, "bad_request", "Package update does not accept browser-controlled input.");
|
|
305
|
+
}
|
|
306
|
+
const begin = await packageUpdates.beginUpdate();
|
|
307
|
+
if (begin.status === "blocked") {
|
|
308
|
+
throw new LocalConsoleHttpError(409, begin.update.reason_code ?? "package_update_blocked", "Package update is currently unavailable.");
|
|
309
|
+
}
|
|
310
|
+
await startPackageUpdate(begin.preparation);
|
|
311
|
+
setImmediate(() => options.onShutdown?.());
|
|
312
|
+
return await reply.status(202).send({
|
|
313
|
+
current_version: begin.preparation.installation.currentVersion,
|
|
314
|
+
latest_version: begin.preparation.target.version,
|
|
315
|
+
status: "updating",
|
|
316
|
+
can_update: false,
|
|
317
|
+
phase: "preparing",
|
|
318
|
+
});
|
|
319
|
+
});
|
|
237
320
|
app.get(`${LOCAL_CONSOLE_API_BASE}/projects`, async (request) => {
|
|
238
321
|
const session = requireSession(request);
|
|
239
322
|
return { projects: await projects.listProjects(session.context) };
|
|
@@ -244,7 +327,7 @@ export function createLocalConsoleServer(options) {
|
|
|
244
327
|
});
|
|
245
328
|
app.post(`${LOCAL_CONSOLE_API_BASE}/folders/pick`, async (request) => {
|
|
246
329
|
const session = requireSession(request, true);
|
|
247
|
-
return await pickLocalFolder({ env: session.context.environment });
|
|
330
|
+
return await operations.runProjectOperation(async () => await pickLocalFolder({ env: session.context.environment }));
|
|
248
331
|
});
|
|
249
332
|
app.post(`${LOCAL_CONSOLE_API_BASE}/providers/models`, {
|
|
250
333
|
schema: {
|
|
@@ -260,10 +343,10 @@ export function createLocalConsoleServer(options) {
|
|
|
260
343
|
},
|
|
261
344
|
}, async (request) => {
|
|
262
345
|
requireSession(request, true);
|
|
263
|
-
return await projects.discoverModels({
|
|
346
|
+
return await operations.runProjectOperation(async () => await projects.discoverModels({
|
|
264
347
|
baseUrl: request.body.base_url,
|
|
265
348
|
apiKey: request.body.api_key,
|
|
266
|
-
});
|
|
349
|
+
}));
|
|
267
350
|
});
|
|
268
351
|
app.post(`${LOCAL_CONSOLE_API_BASE}/projects/launch`, {
|
|
269
352
|
schema: {
|
|
@@ -359,6 +442,10 @@ export async function runLocalConsoleProcess(options = {}) {
|
|
|
359
442
|
throw new Error("The Tutti CLI entrypoint is unavailable.");
|
|
360
443
|
}
|
|
361
444
|
let lastActivityAt = now().getTime();
|
|
445
|
+
const packageUpdateCompletion = consumePackageUpdateCompletion({
|
|
446
|
+
tuttiHome,
|
|
447
|
+
currentVersion: version,
|
|
448
|
+
});
|
|
362
449
|
let requestClose = () => undefined;
|
|
363
450
|
const app = createLocalConsoleServer({
|
|
364
451
|
controlToken,
|
|
@@ -376,6 +463,7 @@ export async function runLocalConsoleProcess(options = {}) {
|
|
|
376
463
|
lastActivityAt = now().getTime();
|
|
377
464
|
},
|
|
378
465
|
onShutdown: () => requestClose(),
|
|
466
|
+
getPackageUpdateCompletion: () => packageUpdateCompletion,
|
|
379
467
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
380
468
|
});
|
|
381
469
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type PackageCommandPlan = {
|
|
2
|
+
executable: string;
|
|
3
|
+
args: readonly string[];
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
maxOutputBytes: number;
|
|
7
|
+
shell: false;
|
|
8
|
+
};
|
|
9
|
+
export type PackageCommandResult = {
|
|
10
|
+
status: number | null;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
errorCode?: "spawn_failed" | "timeout" | "output_limit";
|
|
14
|
+
};
|
|
15
|
+
export type SyncPackageCommandRunner = (plan: PackageCommandPlan) => PackageCommandResult;
|
|
16
|
+
export type PackageCommandRunner = (plan: PackageCommandPlan) => Promise<PackageCommandResult>;
|
|
17
|
+
export declare const runPackageCommandSync: SyncPackageCommandRunner;
|
|
18
|
+
export declare const runPackageCommand: PackageCommandRunner;
|
|
19
|
+
export declare function collectNpmExecutableCandidates(options: {
|
|
20
|
+
env: NodeJS.ProcessEnv;
|
|
21
|
+
nodeExecutable: string;
|
|
22
|
+
packageRoot: string;
|
|
23
|
+
packageName: string;
|
|
24
|
+
}): string[];
|
|
25
|
+
export declare function createNpmCommandEnvironment(options: {
|
|
26
|
+
env: NodeJS.ProcessEnv;
|
|
27
|
+
nodeExecutable: string;
|
|
28
|
+
}): NodeJS.ProcessEnv;
|
|
29
|
+
export declare function createPackageCommandPlan(options: {
|
|
30
|
+
executable: string;
|
|
31
|
+
args: readonly string[];
|
|
32
|
+
env: NodeJS.ProcessEnv;
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
maxOutputBytes?: number;
|
|
35
|
+
}): PackageCommandPlan;
|
|
36
|
+
//# sourceMappingURL=command.d.ts.map
|