@xfey/tutti 0.1.61 → 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.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/dist/server-shell/cli/args.d.ts +4 -0
  3. package/dist/server-shell/cli/args.js +11 -0
  4. package/dist/server-shell/cli/cli.js +35 -11
  5. package/dist/server-shell/cli/host-server-runtime.d.ts +2 -0
  6. package/dist/server-shell/cli/host-server-runtime.js +12 -5
  7. package/dist/server-shell/cli/local-control-client.d.ts +5 -1
  8. package/dist/server-shell/cli/local-control-client.js +19 -0
  9. package/dist/server-shell/desktop-integration/manager.d.ts +2 -0
  10. package/dist/server-shell/desktop-integration/manager.js +19 -31
  11. package/dist/server-shell/http/routes/local-control.d.ts +2 -0
  12. package/dist/server-shell/http/routes/local-control.js +24 -0
  13. package/dist/server-shell/local-console/invocation-context.js +10 -1
  14. package/dist/server-shell/local-console/operation-coordinator.d.ts +16 -0
  15. package/dist/server-shell/local-console/operation-coordinator.js +50 -0
  16. package/dist/server-shell/local-console/package-update-completion.d.ts +25 -0
  17. package/dist/server-shell/local-console/package-update-completion.js +91 -0
  18. package/dist/server-shell/local-console/package-update-hosts.d.ts +17 -0
  19. package/dist/server-shell/local-console/package-update-hosts.js +92 -0
  20. package/dist/server-shell/local-console/package-update-lock.d.ts +46 -0
  21. package/dist/server-shell/local-console/package-update-lock.js +223 -0
  22. package/dist/server-shell/local-console/package-update-log.d.ts +20 -0
  23. package/dist/server-shell/local-console/package-update-log.js +75 -0
  24. package/dist/server-shell/local-console/package-update-process.d.ts +22 -0
  25. package/dist/server-shell/local-console/package-update-process.js +252 -0
  26. package/dist/server-shell/local-console/package-update-record.d.ts +53 -0
  27. package/dist/server-shell/local-console/package-update-record.js +180 -0
  28. package/dist/server-shell/local-console/package-update-recovery.d.ts +34 -0
  29. package/dist/server-shell/local-console/package-update-recovery.js +140 -0
  30. package/dist/server-shell/local-console/package-update-service.d.ts +72 -0
  31. package/dist/server-shell/local-console/package-update-service.js +400 -0
  32. package/dist/server-shell/local-console/package-update-worker.d.ts +55 -0
  33. package/dist/server-shell/local-console/package-update-worker.js +297 -0
  34. package/dist/server-shell/local-console/project-service.d.ts +2 -0
  35. package/dist/server-shell/local-console/project-service.js +16 -12
  36. package/dist/server-shell/local-console/server.d.ts +9 -0
  37. package/dist/server-shell/local-console/server.js +92 -4
  38. package/dist/server-shell/package-installation/command.d.ts +36 -0
  39. package/dist/server-shell/package-installation/command.js +162 -0
  40. package/dist/server-shell/package-installation/discovery.d.ts +51 -0
  41. package/dist/server-shell/package-installation/discovery.js +137 -0
  42. package/dist/server-shell/package-installation/index.d.ts +5 -0
  43. package/dist/server-shell/package-installation/index.js +5 -0
  44. package/dist/server-shell/package-installation/installation.d.ts +34 -0
  45. package/dist/server-shell/package-installation/installation.js +123 -0
  46. package/dist/server-shell/package-installation/semver.d.ts +9 -0
  47. package/dist/server-shell/package-installation/semver.js +84 -0
  48. package/package.json +1 -1
  49. package/web/assets/{homepage-motion-scene-CY7o4hnR.js → homepage-motion-scene-CRmrkkEH.js} +1 -1
  50. package/web/assets/{index-B08r3x8o.js → index-C9JrplHV.js} +14 -14
  51. package/web/assets/{index-DpabiRgp.css → index-IZKcmU_g.css} +1 -1
  52. package/web/index.html +2 -2
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  它负责:
8
8
 
9
9
  - `tutti launch` 后的项目绑定、恢复和接管入口
10
- - 裸 `tutti` 经 lifecycle lock 启动 / 复用的版本感知 loopback Local Console、独立 invocation context,以及既有项目 Host 编排
10
+ - 裸 `tutti` 经 lifecycle lock 启动 / 复用的版本感知 loopback Local Console、独立 invocation context、既有项目 Host 编排,以及可信全局npm安装的idle-only worker / completion自更新交接
11
11
  - 首次 eligible 裸 `tutti`、显式 `tutti desktop` 与严格 `tutti://projects` bridge 共用的用户级 desktop integration manager
12
12
  - Fastify API、SSE、packaged static Web 产物服务和 server 生命周期
13
13
  - SQLite store、migrations、repository 与协作真相读写
@@ -28,7 +28,7 @@
28
28
 
29
29
  ## 当前状态
30
30
 
31
- - 已具备 Fastify host shell、后台 host manager CLI 套件、用户级 desktop integration / strict `tutti://projects` bridge、带 `service status / stop / restart`、卸载前 `shutdown`、并发启动锁、版本替换、strict terminal / desktop browser context、idle / package update cleanup 与私有日志的机器级 Local Console、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
31
+ - 已具备 Fastify host shell、后台 host manager CLI 套件、用户级 desktop integration / strict `tutti://projects` bridge、带 `service status / stop / restart`、卸载前 `shutdown`、并发启动锁、版本替换、strict terminal / desktop browser context、idle / package update cleanup、全机readiness、exact npm global install、跨版本worker / recovery与私有日志的机器级 Local Console、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
32
32
  - 新 Host project 的空群聊会由 collaboration-state 幂等写入一条 Tutti 欢迎消息;它不经过 browser message command,不触发 `@tutti`、Chat read-state、Scratchpad 或 Timeline / execution 流程。
33
33
  - Host server 与 Local Console 支持在显式 `webStatic.root` 或 packaged `web/` 存在时服务 Web 静态产物;`/api/*`、SSE、health、host-local control 和 `/local-console/*` API 不被 SPA fallback 吞掉。
34
34
  - Root `pack:dry-run` 会先清理 build output、重新构建,再复制 `apps/web/dist` 到 package-local `web/`,生成临时 staging `@xfey/tutti` artifact,并校验 npm pack 文件列表包含 CLI、migrations、prompts、Web index、bundled `@tutti/shared` / `@tutti/relay-client` 以及它们的外部 runtime dependencies,同时排除源码、source map、日志、SQLite、`.env`、`.tutti-dev` 与测试产物。
@@ -28,6 +28,10 @@ export type CliCommand = {
28
28
  } | {
29
29
  kind: "internal-desktop-open";
30
30
  url?: string;
31
+ } | {
32
+ kind: "internal-package-update-worker";
33
+ } | {
34
+ kind: "internal-package-update-complete";
31
35
  } | {
32
36
  kind: "ps";
33
37
  manage: boolean;
@@ -138,6 +138,17 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
138
138
  }
139
139
  if (command === "internal") {
140
140
  const internalCommand = args.shift();
141
+ if (internalCommand === "package-update-worker" ||
142
+ internalCommand === "package-update-complete") {
143
+ if (args.length > 0) {
144
+ throw new LaunchError("git_bootstrap_failed", `Internal ${internalCommand} does not accept options`, "Internal commands are managed by Tutti.");
145
+ }
146
+ return {
147
+ kind: internalCommand === "package-update-worker"
148
+ ? "internal-package-update-worker"
149
+ : "internal-package-update-complete",
150
+ };
151
+ }
141
152
  if (internalCommand === "console-run") {
142
153
  if (args.length > 0) {
143
154
  throw new LaunchError("git_bootstrap_failed", "Internal console-run does not accept options", "Internal commands are managed by Tutti.");
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname, resolve } from "node:path";
3
+ import { resolveTuttiHome } from "../../providers/openai/index.js";
3
4
  import { formatCliError, formatCliErrorReason, LaunchError } from "./errors.js";
4
5
  import { parseCliArgs, shouldEnsureDesktopIntegrationAutomatically } from "./args.js";
5
6
  import { confirmTextFromTty, runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
@@ -9,6 +10,8 @@ import { runArchiveCommand, runDoctorCommand, runForgetCommand, runInviteCommand
9
10
  import { readCliVersion } from "./version.js";
10
11
  import { ensureDesktopIntegrationAutomatically, parseTuttiDesktopUrl, runDesktopIntegrationCommand, TUTTI_DESKTOP_PROJECTS_URL, TuttiDesktopProtocolError, } from "../desktop-integration/index.js";
11
12
  import { ensureLocalConsole, openLocalConsoleBrowser, readLocalConsoleServiceStatus, restartLocalConsoleService, runLocalConsoleProcess, stopLocalConsoleService, } from "../local-console/index.js";
13
+ import { runPackageUpdateCompletionProtocol, runPackageUpdateWorkerProtocol, } from "../local-console/package-update-process.js";
14
+ import { recoverPackageUpdateForConsoleStartup } from "../local-console/package-update-recovery.js";
12
15
  const HELP_TEXT = `Usage:
13
16
  tutti
14
17
  tutti launch [target] [--yes] [--foreground]
@@ -209,13 +212,30 @@ function ensureDesktopIntegrationForConsole() {
209
212
  process.stderr.write("Tutti could not update its desktop entry. Run `tutti desktop` for details.\n");
210
213
  }
211
214
  }
215
+ async function ensureUserLocalConsole(options) {
216
+ const env = process.env;
217
+ const entrypointArgument = process.argv[1];
218
+ if (entrypointArgument !== undefined && entrypointArgument.trim() !== "") {
219
+ const recovery = await recoverPackageUpdateForConsoleStartup({
220
+ tuttiHome: resolveTuttiHome(env.TUTTI_HOME, options.cwd),
221
+ cliEntrypoint: resolve(entrypointArgument),
222
+ cwd: options.cwd,
223
+ env,
224
+ source: options.source,
225
+ });
226
+ if (recovery.status === "completed") {
227
+ return recovery.url;
228
+ }
229
+ }
230
+ return (await ensureLocalConsole({ cwd: options.cwd, env, source: options.source })).url;
231
+ }
212
232
  async function runLocalConsoleCommand() {
213
- const console = await ensureLocalConsole();
214
- if (openLocalConsoleBrowser(console.url)) {
215
- process.stdout.write(`Tutti is ready at ${console.endpoint.base_url}/console\n`);
233
+ const url = await ensureUserLocalConsole({ cwd: process.cwd(), source: "terminal" });
234
+ if (openLocalConsoleBrowser(url)) {
235
+ process.stdout.write(`Tutti is ready at ${new URL(url).origin}/console\n`);
216
236
  return;
217
237
  }
218
- process.stdout.write(["Open Tutti in a browser:", console.url, ""].join("\n"));
238
+ process.stdout.write(["Open Tutti in a browser:", url, ""].join("\n"));
219
239
  }
220
240
  function desktopResultMessage(result) {
221
241
  if (result.status === "installed") {
@@ -239,9 +259,9 @@ function runDesktopCommand(action) {
239
259
  return desktopResultMessage(runDesktopIntegrationCommand(action));
240
260
  }
241
261
  async function runDesktopOpenCommand(value) {
242
- const url = value ?? TUTTI_DESKTOP_PROJECTS_URL;
262
+ const desktopUrl = value ?? TUTTI_DESKTOP_PROJECTS_URL;
243
263
  try {
244
- parseTuttiDesktopUrl(url);
264
+ parseTuttiDesktopUrl(desktopUrl);
245
265
  }
246
266
  catch (error) {
247
267
  if (error instanceof TuttiDesktopProtocolError) {
@@ -250,11 +270,9 @@ async function runDesktopOpenCommand(value) {
250
270
  throw error;
251
271
  }
252
272
  const cliEntrypoint = process.argv[1];
253
- const console = await ensureLocalConsole({
254
- source: "desktop",
255
- ...(cliEntrypoint === undefined ? {} : { cwd: dirname(resolve(cliEntrypoint)) }),
256
- });
257
- if (!openLocalConsoleBrowser(console.url)) {
273
+ const cwd = cliEntrypoint === undefined ? process.cwd() : dirname(resolve(cliEntrypoint));
274
+ const url = await ensureUserLocalConsole({ source: "desktop", cwd });
275
+ if (!openLocalConsoleBrowser(url)) {
258
276
  throw new LaunchError("desktop_open_failed", "The Local Console started, but no desktop browser opener is available.", "Run `tutti` from a terminal to print a one-time browser URL.");
259
277
  }
260
278
  }
@@ -378,6 +396,12 @@ try {
378
396
  case "internal-desktop-open":
379
397
  await runDesktopOpenCommand(command.url);
380
398
  break;
399
+ case "internal-package-update-worker":
400
+ await runPackageUpdateWorkerProtocol();
401
+ break;
402
+ case "internal-package-update-complete":
403
+ await runPackageUpdateCompletionProtocol();
404
+ break;
381
405
  case "ps":
382
406
  if (command.manage) {
383
407
  await runPsManageCommand({ all: command.all });
@@ -1,4 +1,5 @@
1
1
  import type { FastifyInstance } from "fastify";
2
+ import type { ExecutionStatusProjection } from "@tutti/shared/schemas/api";
2
3
  import { type HostProjectStore } from "../../store/index.js";
3
4
  import type { createHostServer } from "../http/create-server.js";
4
5
  import type { HostLocalLaunchStatus } from "../http/routes/local-control.js";
@@ -8,6 +9,7 @@ import type { HostRelayConnectionManager } from "./host-relay-connection-manager
8
9
  import type { LaunchPreparationResult } from "./launch.js";
9
10
  import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
10
11
  export declare const HOST_SERVER_VERSION: string;
12
+ export declare function packageUpdateReadinessFromExecutionStatus(execution: Pick<ExecutionStatusProjection, "status" | "active_activity" | "active_activities" | "blocked_by">): "ready" | "busy";
11
13
  export type HostServerFactory = typeof createHostServer;
12
14
  export type HostServerHandle = {
13
15
  app: FastifyInstance;
@@ -23,6 +23,15 @@ import { relayStateFromLaunchStatus } from "./host-relay-status.js";
23
23
  import { appendHostLogLine, readHostRegistrationSecret, createMachineRuntimeEndpointToken, deleteMachineRuntimeEndpoint, ensureHostLogFile, getHostLogFilePath, updateMachineProjectDisplayNameSnapshot, writeMachineRuntimeEndpoint, } from "./machine-local.js";
24
24
  import { readProjectDescription, readProjectDisplayName, writeProjectDescription, writeProjectDisplayName, } from "./project-identity.js";
25
25
  export const HOST_SERVER_VERSION = readCliVersion();
26
+ export function packageUpdateReadinessFromExecutionStatus(execution) {
27
+ const active = execution.active_activity !== undefined || (execution.active_activities?.length ?? 0) > 0;
28
+ return active ||
29
+ execution.blocked_by !== undefined ||
30
+ execution.status === "running" ||
31
+ execution.status === "pending"
32
+ ? "busy"
33
+ : "ready";
34
+ }
26
35
  function createHostRuntimeLogger(options) {
27
36
  return {
28
37
  info(fields, message) {
@@ -279,13 +288,11 @@ export async function startForegroundHostServer(options) {
279
288
  });
280
289
  let close = async () => { };
281
290
  let relayConnectionManager;
291
+ const packageUpdateReadiness = () => packageUpdateReadinessFromExecutionStatus(controlPlane.getExecutionStatus());
282
292
  const localControl = {
283
293
  token,
284
- requestIdleShutdown: () => {
285
- const execution = controlPlane.getExecutionStatus();
286
- const hasActiveActivities = execution.active_activity !== undefined || (execution.active_activities?.length ?? 0) > 0;
287
- return hasActiveActivities ? "busy" : "accepted";
288
- },
294
+ getPackageUpdateReadiness: packageUpdateReadiness,
295
+ requestIdleShutdown: () => packageUpdateReadiness() === "ready" ? "accepted" : "busy",
289
296
  requestShutdown: () => {
290
297
  void close();
291
298
  },
@@ -1,4 +1,4 @@
1
- import type { HostLocalIdleShutdownDisposition, HostLocalLaunchStatus, HostLocalProjectProjection, HostLocalProviderConfigBody } from "../http/routes/local-control.js";
1
+ import type { HostLocalIdleShutdownDisposition, HostLocalLaunchStatus, HostLocalPackageUpdateReadiness, HostLocalProjectProjection, HostLocalProviderConfigBody } from "../http/routes/local-control.js";
2
2
  import type { HostProviderConfigProjection } from "../http/routes/project-api/types.js";
3
3
  import type { MachineRuntimeEndpointRecord } from "./machine-local.js";
4
4
  export type LocalControlFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
@@ -6,6 +6,10 @@ export declare function readHostLocalLaunchStatus(options: {
6
6
  endpoint: MachineRuntimeEndpointRecord;
7
7
  fetchImpl?: LocalControlFetch;
8
8
  }): Promise<HostLocalLaunchStatus>;
9
+ export declare function readHostLocalPackageUpdateReadiness(options: {
10
+ endpoint: MachineRuntimeEndpointRecord;
11
+ fetchImpl?: LocalControlFetch;
12
+ }): Promise<HostLocalPackageUpdateReadiness>;
9
13
  export declare function rotateHostLocalInvite(options: {
10
14
  endpoint: MachineRuntimeEndpointRecord;
11
15
  fetchImpl?: LocalControlFetch;
@@ -35,6 +35,25 @@ export async function readHostLocalLaunchStatus(options) {
35
35
  path: "/host-local/v1/launch-status",
36
36
  });
37
37
  }
38
+ export async function readHostLocalPackageUpdateReadiness(options) {
39
+ const response = await fetchWithTimeout(options.fetchImpl ?? fetch, new URL("/host-local/v1/package-update-readiness", options.endpoint.base_url), {
40
+ headers: {
41
+ accept: "application/json",
42
+ authorization: `Bearer ${options.endpoint.token}`,
43
+ },
44
+ });
45
+ if (response.status === 404) {
46
+ return "unsupported";
47
+ }
48
+ if (!response.ok) {
49
+ throw new Error(`host-local package-update-readiness returned HTTP ${response.status}`);
50
+ }
51
+ const body = (await response.json());
52
+ if (body.status !== "ready" && body.status !== "busy" && body.status !== "unsupported") {
53
+ throw new Error("host-local package-update-readiness returned an invalid response");
54
+ }
55
+ return body.status;
56
+ }
38
57
  export async function rotateHostLocalInvite(options) {
39
58
  return await requestLocalJson({
40
59
  ...options,
@@ -1,3 +1,4 @@
1
+ import { type SyncPackageCommandRunner } from "../package-installation/index.js";
1
2
  import { type DesktopCommandRunner, type DesktopInstallationIdentity, type DesktopPlatformAdapter } from "./platform.js";
2
3
  export type DesktopIntegrationResult = {
3
4
  status: "installed";
@@ -32,6 +33,7 @@ declare function createRealContext(options: {
32
33
  platform?: NodeJS.Platform;
33
34
  now?: () => Date;
34
35
  runner?: DesktopCommandRunner;
36
+ packageRunner?: SyncPackageCommandRunner;
35
37
  requireStableGlobal?: boolean;
36
38
  }): ManagerContext | DesktopIntegrationResult;
37
39
  export declare function ensureDesktopIntegrationAutomatically(options?: Parameters<typeof createRealContext>[0]): DesktopIntegrationResult;
@@ -1,14 +1,13 @@
1
- import { existsSync, realpathSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
  import { resolveTuttiHome } from "../../providers/openai/index.js";
6
5
  import { readCliPackageIdentity } from "../cli/version.js";
7
- import { createLinuxDesktopAdapter, findDesktopExecutable, linuxDesktopRequirementsAvailable, } from "./linux.js";
6
+ import { resolveGlobalPackageInstallation, } from "../package-installation/index.js";
7
+ import { createLinuxDesktopAdapter, linuxDesktopRequirementsAvailable } from "./linux.js";
8
8
  import { createMacosDesktopAdapter, macosDesktopRequirementsAvailable } from "./macos.js";
9
9
  import { runDesktopCommand, } from "./platform.js";
10
10
  import { readDesktopIntegrationRecord, writeDesktopIntegrationRecord, } from "./record.js";
11
- const PUBLISHED_CLI_PACKAGE_NAME = "@xfey/tutti";
12
11
  function desktopAssetRoot() {
13
12
  return resolve(dirname(fileURLToPath(import.meta.url)), "../../../desktop-assets");
14
13
  }
@@ -136,39 +135,28 @@ export function removeDesktopIntegration(context) {
136
135
  return unavailable("operation_failed", "Tutti could not remove the user-level desktop entry.", context.adapter.entryPath);
137
136
  }
138
137
  }
139
- function stableGlobalPackage(options) {
140
- const npmExecutable = findDesktopExecutable("npm", options.env);
141
- if (npmExecutable === null) {
142
- return false;
143
- }
144
- const result = options.runner(npmExecutable, ["root", "--global"]);
145
- const globalRoot = result.stdout.trim();
146
- if (result.status !== 0 || !isAbsolute(globalRoot)) {
147
- return false;
148
- }
149
- const expectedRoot = join(globalRoot, ...PUBLISHED_CLI_PACKAGE_NAME.split("/"));
150
- if (!existsSync(expectedRoot)) {
151
- return false;
152
- }
153
- try {
154
- return realpathSync(expectedRoot) === realpathSync(options.packageRoot);
155
- }
156
- catch {
157
- return false;
158
- }
159
- }
160
138
  function createRealContext(options) {
161
139
  const cwd = resolve(options.cwd ?? process.cwd());
162
140
  const env = options.env ?? process.env;
163
141
  const platform = options.platform ?? process.platform;
164
142
  const runner = options.runner ?? runDesktopCommand;
165
143
  const packageIdentity = readCliPackageIdentity();
166
- if (packageIdentity.name !== PUBLISHED_CLI_PACKAGE_NAME ||
167
- (options.requireStableGlobal !== false &&
168
- !stableGlobalPackage({ packageRoot: packageIdentity.root, env, runner }))) {
169
- return unavailable("package_not_global", "Desktop integration requires a stable global @xfey/tutti installation.");
144
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
145
+ if (options.requireStableGlobal !== false) {
146
+ const packageInstallation = resolveGlobalPackageInstallation({
147
+ packageIdentity,
148
+ tuttiHome,
149
+ defaultTuttiHome: resolveTuttiHome(undefined, cwd),
150
+ env,
151
+ ...(options.packageRunner === undefined ? {} : { runner: options.packageRunner }),
152
+ });
153
+ if (packageInstallation.status === "unavailable") {
154
+ return packageInstallation.reasonCode === "custom_home"
155
+ ? unavailable("custom_tutti_home", "Desktop integration is available only for the default Tutti home.")
156
+ : unavailable("package_not_global", "Desktop integration requires a stable global @xfey/tutti installation.");
157
+ }
170
158
  }
171
- if (env.TUTTI_HOME !== undefined && env.TUTTI_HOME.trim() !== "") {
159
+ else if (tuttiHome !== resolveTuttiHome(undefined, cwd)) {
172
160
  return unavailable("custom_tutti_home", "Desktop integration is available only for the default Tutti home.");
173
161
  }
174
162
  if (platform !== "darwin" && platform !== "linux") {
@@ -188,7 +176,7 @@ function createRealContext(options) {
188
176
  ? createMacosDesktopAdapter({ homeDir, assetRoot, runner })
189
177
  : createLinuxDesktopAdapter({ homeDir, assetRoot, env, runner });
190
178
  return {
191
- tuttiHome: resolveTuttiHome(undefined, cwd),
179
+ tuttiHome,
192
180
  adapter,
193
181
  identity: {
194
182
  packageVersion: packageIdentity.version,
@@ -14,10 +14,12 @@ export type HostLocalControlOptions = {
14
14
  get: () => ProviderConfigProjection;
15
15
  configure: (input: HostLocalProviderConfigBody) => Promise<ProviderConfigProjection>;
16
16
  };
17
+ getPackageUpdateReadiness?: () => HostLocalPackageUpdateReadiness;
17
18
  requestIdleShutdown?: () => HostLocalIdleShutdownDisposition;
18
19
  requestShutdown: () => void;
19
20
  };
20
21
  export type HostLocalIdleShutdownDisposition = "accepted" | "busy" | "unsupported";
22
+ export type HostLocalPackageUpdateReadiness = "ready" | "busy" | "unsupported";
21
23
  export type HostLocalLaunchStatus = {
22
24
  project_id?: ProjectId;
23
25
  relay_connection?: {
@@ -55,6 +55,14 @@ const HostLocalIdleShutdownResponseSchema = {
55
55
  disposition: { type: "string", enum: ["accepted", "busy", "unsupported"] },
56
56
  },
57
57
  };
58
+ const HostLocalPackageUpdateReadinessResponseSchema = {
59
+ type: "object",
60
+ required: ["status"],
61
+ additionalProperties: false,
62
+ properties: {
63
+ status: { type: "string", enum: ["ready", "busy", "unsupported"] },
64
+ },
65
+ };
58
66
  const HostLocalProviderConfigBodySchema = {
59
67
  type: "object",
60
68
  required: ["api_key"],
@@ -252,6 +260,12 @@ export function registerHostLocalControlRoutes(app, options) {
252
260
  throw new HostLocalControlUnauthorizedError();
253
261
  }
254
262
  }
263
+ function packageUpdateReadiness() {
264
+ if (draining || activeMutations > 0) {
265
+ return "busy";
266
+ }
267
+ return options.getPackageUpdateReadiness?.() ?? "unsupported";
268
+ }
255
269
  app.get("/host-local/v1/launch-status", {
256
270
  schema: {
257
271
  response: {
@@ -262,6 +276,16 @@ export function registerHostLocalControlRoutes(app, options) {
262
276
  requireLocalToken(request.headers.authorization);
263
277
  return options.getLaunchStatus?.() ?? {};
264
278
  });
279
+ app.get("/host-local/v1/package-update-readiness", {
280
+ schema: {
281
+ response: {
282
+ 200: HostLocalPackageUpdateReadinessResponseSchema,
283
+ },
284
+ },
285
+ }, (request) => {
286
+ requireLocalToken(request.headers.authorization);
287
+ return { status: packageUpdateReadiness() };
288
+ });
265
289
  app.post("/host-local/v1/relay/reconnect", {
266
290
  schema: {
267
291
  response: {
@@ -37,7 +37,16 @@ export function createDesktopLocalConsoleInvocationContext(options) {
37
37
  }
38
38
  export function createLocalConsoleServiceEnvironment(options) {
39
39
  const environment = { TUTTI_HOME: options.tuttiHome };
40
- for (const key of ["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "TMPDIR", "USER"]) {
40
+ for (const key of [
41
+ "HOME",
42
+ "LANG",
43
+ "LC_ALL",
44
+ "LC_CTYPE",
45
+ "LOGNAME",
46
+ "PATH",
47
+ "TMPDIR",
48
+ "USER",
49
+ ]) {
41
50
  const value = options.env[key];
42
51
  if (value !== undefined && value !== "") {
43
52
  environment[key] = value;
@@ -0,0 +1,16 @@
1
+ export declare class LocalConsoleOperationConflictError extends Error {
2
+ readonly statusCode = 409;
3
+ readonly code = "package_update_in_progress";
4
+ constructor();
5
+ }
6
+ export type LocalConsolePackageUpdateOperationLease = {
7
+ release: () => void;
8
+ };
9
+ export type LocalConsoleOperationState = "idle" | "project_busy" | "package_update";
10
+ export declare class LocalConsoleOperationCoordinator {
11
+ #private;
12
+ state(): LocalConsoleOperationState;
13
+ runProjectOperation<T>(operation: () => Promise<T>): Promise<T>;
14
+ tryBeginPackageUpdate(): LocalConsolePackageUpdateOperationLease | null;
15
+ }
16
+ //# sourceMappingURL=operation-coordinator.d.ts.map
@@ -0,0 +1,50 @@
1
+ export class LocalConsoleOperationConflictError extends Error {
2
+ statusCode = 409;
3
+ code = "package_update_in_progress";
4
+ constructor() {
5
+ super("Tutti is restarting to install an update.");
6
+ this.name = "LocalConsoleOperationConflictError";
7
+ }
8
+ }
9
+ export class LocalConsoleOperationCoordinator {
10
+ #operationTail = Promise.resolve();
11
+ #pendingOperations = 0;
12
+ #packageUpdateOwner;
13
+ state() {
14
+ if (this.#packageUpdateOwner !== undefined) {
15
+ return "package_update";
16
+ }
17
+ return this.#pendingOperations > 0 ? "project_busy" : "idle";
18
+ }
19
+ runProjectOperation(operation) {
20
+ if (this.#packageUpdateOwner !== undefined) {
21
+ return Promise.reject(new LocalConsoleOperationConflictError());
22
+ }
23
+ this.#pendingOperations += 1;
24
+ const result = this.#operationTail.then(operation, operation);
25
+ this.#operationTail = result.then(() => undefined, () => undefined);
26
+ return result.finally(() => {
27
+ this.#pendingOperations = Math.max(0, this.#pendingOperations - 1);
28
+ });
29
+ }
30
+ tryBeginPackageUpdate() {
31
+ if (this.#packageUpdateOwner !== undefined || this.#pendingOperations > 0) {
32
+ return null;
33
+ }
34
+ const owner = Symbol("localConsolePackageUpdate");
35
+ this.#packageUpdateOwner = owner;
36
+ let released = false;
37
+ return {
38
+ release: () => {
39
+ if (released) {
40
+ return;
41
+ }
42
+ released = true;
43
+ if (this.#packageUpdateOwner === owner) {
44
+ this.#packageUpdateOwner = undefined;
45
+ }
46
+ },
47
+ };
48
+ }
49
+ }
50
+ //# sourceMappingURL=operation-coordinator.js.map
@@ -0,0 +1,25 @@
1
+ import { PackageUpdateLogger } from "./package-update-log.js";
2
+ import { type PackageUpdateRuntimeRecord } from "./package-update-record.js";
3
+ export type PackageUpdateCompletionResult = {
4
+ status: "completed";
5
+ restart_failure_count: number;
6
+ browser_opened: boolean;
7
+ };
8
+ export declare function runPackageUpdateCompletion(options: {
9
+ tuttiHome: string;
10
+ cliEntrypoint: string;
11
+ env: NodeJS.ProcessEnv;
12
+ currentVersion?: string;
13
+ logger?: Pick<PackageUpdateLogger, "append">;
14
+ restoreHosts?: (record: PackageUpdateRuntimeRecord) => Promise<{
15
+ restored_project_ids: string[];
16
+ failed_project_ids: string[];
17
+ }>;
18
+ ensureConsole?: () => Promise<{
19
+ url: string;
20
+ }>;
21
+ openBrowser?: (url: string) => boolean;
22
+ releaseLock?: () => boolean;
23
+ now?: () => Date;
24
+ }): Promise<PackageUpdateCompletionResult>;
25
+ //# sourceMappingURL=package-update-completion.d.ts.map
@@ -0,0 +1,91 @@
1
+ import { dirname } from "node:path";
2
+ import { readCliVersion } from "../cli/version.js";
3
+ import { ensureLocalConsole } from "./managed-console.js";
4
+ import { openLocalConsoleBrowser } from "./browser-open.js";
5
+ import { releasePackageUpdateLockForCurrentProcess } from "./package-update-lock.js";
6
+ import { PackageUpdateLogger } from "./package-update-log.js";
7
+ import { readPackageUpdateRuntimeRecord, updatePackageUpdateRuntimeRecord, } from "./package-update-record.js";
8
+ import { restorePackageUpdateHosts } from "./package-update-hosts.js";
9
+ export async function runPackageUpdateCompletion(options) {
10
+ const currentVersion = options.currentVersion ?? readCliVersion();
11
+ const read = readPackageUpdateRuntimeRecord({ tuttiHome: options.tuttiHome });
12
+ if (read.status !== "valid" ||
13
+ read.record.phase !== "restarting" ||
14
+ read.record.target_version !== currentVersion ||
15
+ read.record.updater_pid !== process.pid) {
16
+ throw new Error("Package update completion record is unavailable or does not match this CLI.");
17
+ }
18
+ const logger = options.logger ??
19
+ new PackageUpdateLogger({
20
+ tuttiHome: options.tuttiHome,
21
+ privatePaths: [options.tuttiHome],
22
+ ...(options.now === undefined ? {} : { now: options.now }),
23
+ });
24
+ const releaseLock = options.releaseLock ?? (() => releasePackageUpdateLockForCurrentProcess(options.tuttiHome));
25
+ const log = (entry) => {
26
+ try {
27
+ logger.append(entry);
28
+ }
29
+ catch {
30
+ // Completion correctness must not depend on the diagnostic log remaining writable.
31
+ }
32
+ };
33
+ try {
34
+ const restored = await (options.restoreHosts ??
35
+ ((record) => restorePackageUpdateHosts({
36
+ tuttiHome: options.tuttiHome,
37
+ projectIds: record.restore_project_ids,
38
+ env: options.env,
39
+ })))(read.record);
40
+ updatePackageUpdateRuntimeRecord({
41
+ tuttiHome: options.tuttiHome,
42
+ current: read.record,
43
+ phase: "restarting",
44
+ updaterPid: process.pid,
45
+ restartFailureCount: restored.failed_project_ids.length,
46
+ ...(options.now === undefined ? {} : { now: options.now }),
47
+ });
48
+ const consoleResult = await (options.ensureConsole ??
49
+ (() => ensureLocalConsole({
50
+ cwd: dirname(options.cliEntrypoint),
51
+ env: options.env,
52
+ source: "desktop",
53
+ })))();
54
+ if (!releaseLock()) {
55
+ throw new Error("Package update completion could not release its machine lock.");
56
+ }
57
+ const browserOpened = (options.openBrowser ?? openLocalConsoleBrowser)(consoleResult.url);
58
+ log({
59
+ level: restored.failed_project_ids.length === 0 ? "info" : "warn",
60
+ message: restored.failed_project_ids.length === 0
61
+ ? "Package update completed."
62
+ : "Package update completed with Host restore failures.",
63
+ phase: "restarting",
64
+ ...(restored.failed_project_ids.length === 0 ? {} : { reasonCode: "host_restore_failed" }),
65
+ });
66
+ return {
67
+ status: "completed",
68
+ restart_failure_count: restored.failed_project_ids.length,
69
+ browser_opened: browserOpened,
70
+ };
71
+ }
72
+ catch (error) {
73
+ updatePackageUpdateRuntimeRecord({
74
+ tuttiHome: options.tuttiHome,
75
+ current: read.record,
76
+ phase: "failed",
77
+ updaterPid: process.pid,
78
+ failureReasonCode: "completion_failed",
79
+ ...(options.now === undefined ? {} : { now: options.now }),
80
+ });
81
+ releaseLock();
82
+ log({
83
+ level: "error",
84
+ message: "Package update completion failed.",
85
+ phase: "failed",
86
+ reasonCode: "completion_failed",
87
+ });
88
+ throw error;
89
+ }
90
+ }
91
+ //# sourceMappingURL=package-update-completion.js.map
@@ -0,0 +1,17 @@
1
+ import type { ProjectId } from "@tutti/shared/ids";
2
+ export type PackageUpdateHostStopResult = "stopped" | "host_busy" | "host_identity_unverified" | "host_update_unsupported";
3
+ export declare function stopPackageUpdateHost(options: {
4
+ tuttiHome: string;
5
+ projectId: ProjectId;
6
+ fetchImpl?: typeof fetch;
7
+ }): Promise<PackageUpdateHostStopResult>;
8
+ export declare function restorePackageUpdateHosts(options: {
9
+ tuttiHome: string;
10
+ projectIds: readonly ProjectId[];
11
+ env: NodeJS.ProcessEnv;
12
+ fetchImpl?: typeof fetch;
13
+ }): Promise<{
14
+ restored_project_ids: ProjectId[];
15
+ failed_project_ids: ProjectId[];
16
+ }>;
17
+ //# sourceMappingURL=package-update-hosts.d.ts.map