@xfey/tutti 0.1.29 → 0.1.30

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/server-shell/cli/args.d.ts +9 -0
  3. package/dist/server-shell/cli/args.js +55 -5
  4. package/dist/server-shell/cli/cli.js +46 -7
  5. package/dist/server-shell/cli/errors.d.ts +1 -1
  6. package/dist/server-shell/cli/errors.js +6 -1
  7. package/dist/server-shell/cli/host-lifecycle.d.ts +1 -0
  8. package/dist/server-shell/cli/host-lifecycle.js +155 -41
  9. package/dist/server-shell/cli/host-relay-connection-manager.d.ts +50 -0
  10. package/dist/server-shell/cli/host-relay-connection-manager.js +447 -0
  11. package/dist/server-shell/cli/host-relay-status.d.ts +4 -0
  12. package/dist/server-shell/cli/host-relay-status.js +74 -15
  13. package/dist/server-shell/cli/host-runtime-endpoint.js +26 -0
  14. package/dist/server-shell/cli/host-server-runtime.d.ts +2 -2
  15. package/dist/server-shell/cli/host-server-runtime.js +19 -15
  16. package/dist/server-shell/cli/launch-command.d.ts +1 -0
  17. package/dist/server-shell/cli/launch-command.js +8 -10
  18. package/dist/server-shell/cli/launch.d.ts +1 -0
  19. package/dist/server-shell/cli/launch.js +7 -3
  20. package/dist/server-shell/cli/local-control-client.d.ts +5 -0
  21. package/dist/server-shell/cli/local-control-client.js +9 -0
  22. package/dist/server-shell/cli/machine-local.d.ts +8 -0
  23. package/dist/server-shell/cli/machine-local.js +29 -0
  24. package/dist/server-shell/cli/machine-project-inspector.d.ts +81 -0
  25. package/dist/server-shell/cli/machine-project-inspector.js +205 -0
  26. package/dist/server-shell/cli/project-resolver.js +22 -32
  27. package/dist/server-shell/cli/relay-registration.d.ts +5 -2
  28. package/dist/server-shell/cli/relay-registration.js +7 -3
  29. package/dist/server-shell/cli/runtime-commands.d.ts +30 -4
  30. package/dist/server-shell/cli/runtime-commands.js +230 -113
  31. package/dist/server-shell/http/routes/local-control.d.ts +10 -0
  32. package/dist/server-shell/http/routes/local-control.js +29 -0
  33. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +23 -0
  34. package/node_modules/@tutti/relay-client/dist/host-control.js +70 -3
  35. package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +6 -2
  36. package/node_modules/@tutti/relay-client/dist/tunnel.js +13 -2
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
  - 已具备 Fastify host shell、后台 host manager CLI 套件、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 接入。
28
28
  - Host server 支持在显式 `webStatic.root` 或 packaged `web/` 存在时服务 Web 静态产物;`/api/*`、SSE、health 和 host-local control 不被 SPA fallback 吞掉。
29
29
  - 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` 与测试产物。
30
- - Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti launch --help` / `tutti --version`、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
30
+ - Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti launch --help` / `tutti archive --help` / `tutti --version`、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
31
31
  - `prompts/` 是所有模型-facing prompt、指令模板和 Codex 执行模板的统一落点;长 prompt 不内联在 TypeScript 中。
32
32
  - Host Project API 只信任 Relay tunnel metadata 注入的 `relay_session_context`;direct loopback 缺少可信 context 时不作为浏览器工作区入口。
33
33
  - 详细模块落点见 `src/README.md`;logger redaction、command idempotency、schema、provider 与 Codex 边界以 `docs/system/` 对应 canonical 文档为准。
@@ -15,12 +15,21 @@ export type CliCommand = {
15
15
  } | {
16
16
  kind: "ps";
17
17
  manage: boolean;
18
+ all: boolean;
18
19
  } | {
19
20
  kind: "stop";
20
21
  target?: string;
21
22
  } | {
22
23
  kind: "invite";
23
24
  target?: string;
25
+ } | {
26
+ kind: "archive";
27
+ target: string;
28
+ yes: boolean;
29
+ } | {
30
+ kind: "forget";
31
+ target: string;
32
+ yes: boolean;
24
33
  } | {
25
34
  kind: "logs";
26
35
  target?: string;
@@ -49,10 +49,7 @@ function readOptionalTarget(args) {
49
49
  export function parseCliArgs(argv, cwd = process.cwd()) {
50
50
  const args = [...argv];
51
51
  const command = args.shift();
52
- if (command === undefined ||
53
- command === "--help" ||
54
- command === "-h" ||
55
- command === "help") {
52
+ if (command === undefined || command === "--help" || command === "-h" || command === "help") {
56
53
  return { kind: "help", ...(args[0] === undefined ? {} : { topic: args[0] }) };
57
54
  }
58
55
  if (command === "--version" || command === "-v" || command === "version") {
@@ -115,17 +112,22 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
115
112
  }
116
113
  if (command === "ps") {
117
114
  let manage = false;
115
+ let all = false;
118
116
  for (const arg of args) {
119
117
  if (arg === "--manage") {
120
118
  manage = true;
121
119
  continue;
122
120
  }
121
+ if (arg === "--all") {
122
+ all = true;
123
+ continue;
124
+ }
123
125
  if (isHelpArg(arg)) {
124
126
  return { kind: "help", topic: "ps" };
125
127
  }
126
128
  throw new LaunchError("git_bootstrap_failed", `Unsupported option: ${arg}`, "Use `tutti ps --help` to see supported ps options.");
127
129
  }
128
- return { kind: "ps", manage };
130
+ return { kind: "ps", manage, all };
129
131
  }
130
132
  if (command === "stop") {
131
133
  if (hasHelpArg(args)) {
@@ -141,6 +143,54 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
141
143
  const target = readOptionalTarget(args);
142
144
  return { kind: "invite", ...(target === undefined ? {} : { target }) };
143
145
  }
146
+ if (command === "archive") {
147
+ if (hasHelpArg(args)) {
148
+ return { kind: "help", topic: "archive" };
149
+ }
150
+ let target;
151
+ let yes = false;
152
+ for (const arg of args) {
153
+ if (arg === "--yes") {
154
+ yes = true;
155
+ continue;
156
+ }
157
+ if (arg.startsWith("--")) {
158
+ throw new LaunchError("git_bootstrap_failed", `Unsupported option: ${arg}`, "Run `tutti archive --help` to see supported archive options.");
159
+ }
160
+ if (target !== undefined) {
161
+ throw new LaunchError("git_bootstrap_failed", "Only one archive target is supported", "Run `tutti archive <target> [--yes]`.");
162
+ }
163
+ target = arg;
164
+ }
165
+ if (target === undefined) {
166
+ throw new LaunchError("project_target_not_found", "Archive requires a project target", "Run `tutti archive <project-id> [--yes]`.");
167
+ }
168
+ return { kind: "archive", target, yes };
169
+ }
170
+ if (command === "forget") {
171
+ if (hasHelpArg(args)) {
172
+ return { kind: "help", topic: "forget" };
173
+ }
174
+ let target;
175
+ let yes = false;
176
+ for (const arg of args) {
177
+ if (arg === "--yes") {
178
+ yes = true;
179
+ continue;
180
+ }
181
+ if (arg.startsWith("--")) {
182
+ throw new LaunchError("git_bootstrap_failed", `Unsupported option: ${arg}`, "Run `tutti forget --help` to see supported forget options.");
183
+ }
184
+ if (target !== undefined) {
185
+ throw new LaunchError("git_bootstrap_failed", "Only one forget target is supported", "Run `tutti forget <target> [--yes]`.");
186
+ }
187
+ target = arg;
188
+ }
189
+ if (target === undefined) {
190
+ throw new LaunchError("project_target_not_found", "Forget requires a project target", "Run `tutti forget <project-id> [--yes]`.");
191
+ }
192
+ return { kind: "forget", target, yes };
193
+ }
144
194
  if (command === "logs") {
145
195
  if (hasHelpArg(args)) {
146
196
  return { kind: "help", topic: "logs" };
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { formatCliError } from "./errors.js";
3
3
  import { parseCliArgs } from "./args.js";
4
- import { runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
4
+ import { confirmTextFromTty, runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
5
5
  import { resolveExistingProjectContext } from "./project-resolver.js";
6
6
  import { runProviderSetupTui } from "./provider-tui.js";
7
- import { runDoctorCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, } from "./runtime-commands.js";
7
+ import { runArchiveCommand, runDoctorCommand, runForgetCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, } from "./runtime-commands.js";
8
8
  import { readCliVersion } from "./version.js";
9
9
  const HELP_TEXT = `Usage:
10
10
  tutti launch [target] [--yes] [--foreground]
11
- tutti ps [--manage]
11
+ tutti ps [--manage] [--all]
12
12
  tutti invite [target]
13
13
  tutti stop [target]
14
+ tutti archive <target> [--yes]
15
+ tutti forget <target> [--yes]
14
16
  tutti logs [target] [--tail 120]
15
17
  tutti provider setup [target]
16
18
  tutti provider status [target]
@@ -42,10 +44,11 @@ Commands:
42
44
  status Show the redacted provider status for a project.
43
45
  `;
44
46
  const PS_HELP_TEXT = `Usage:
45
- tutti ps [--manage]
47
+ tutti ps [--manage] [--all]
46
48
 
47
49
  Options:
48
50
  --manage Open the keyboard project manager.
51
+ --all Include stopped and binding-only machine projects.
49
52
  `;
50
53
  const INVITE_HELP_TEXT = `Usage:
51
54
  tutti invite [target]
@@ -57,6 +60,24 @@ const STOP_HELP_TEXT = `Usage:
57
60
 
58
61
  Stops the background Tutti host for a project.
59
62
  `;
63
+ const ARCHIVE_HELP_TEXT = `Usage:
64
+ tutti archive <target> [--yes]
65
+
66
+ Archives the Relay project for all members after safely stopping its host.
67
+ Memberships and machine-local project state are retained.
68
+
69
+ Options:
70
+ --yes Confirm global Relay archive in a non-interactive terminal.
71
+ `;
72
+ const FORGET_HELP_TEXT = `Usage:
73
+ tutti forget <target> [--yes]
74
+
75
+ Deletes this project's machine-local state after safely stopping its host.
76
+ It does not archive the Relay project or change other members.
77
+
78
+ Options:
79
+ --yes Confirm destructive local deletion in a non-interactive terminal.
80
+ `;
60
81
  const LOGS_HELP_TEXT = `Usage:
61
82
  tutti logs [target] [--tail 120]
62
83
 
@@ -83,6 +104,12 @@ function helpForTopic(topic) {
83
104
  if (topic === "stop") {
84
105
  return STOP_HELP_TEXT;
85
106
  }
107
+ if (topic === "archive") {
108
+ return ARCHIVE_HELP_TEXT;
109
+ }
110
+ if (topic === "forget") {
111
+ return FORGET_HELP_TEXT;
112
+ }
86
113
  if (topic === "logs") {
87
114
  return LOGS_HELP_TEXT;
88
115
  }
@@ -147,15 +174,27 @@ try {
147
174
  break;
148
175
  case "ps":
149
176
  if (command.manage) {
150
- await runPsManageCommand();
177
+ await runPsManageCommand({ all: command.all });
151
178
  }
152
179
  else {
153
- process.stdout.write(`${await runPsCommand()}\n`);
180
+ process.stdout.write(`${await runPsCommand({ all: command.all })}\n`);
154
181
  }
155
182
  break;
156
183
  case "stop":
157
184
  process.stdout.write(`${await runStopCommand(command.target)}\n`);
158
185
  break;
186
+ case "archive":
187
+ process.stdout.write(`${await runArchiveCommand(command.target, {
188
+ yes: command.yes,
189
+ confirm: confirmTextFromTty,
190
+ })}\n`);
191
+ break;
192
+ case "forget":
193
+ process.stdout.write(`${await runForgetCommand(command.target, {
194
+ yes: command.yes,
195
+ confirm: confirmTextFromTty,
196
+ })}\n`);
197
+ break;
159
198
  case "invite":
160
199
  process.stdout.write(`${await runInviteCommand(command.target)}\n`);
161
200
  break;
@@ -169,7 +208,7 @@ try {
169
208
  process.stdout.write(`${runProviderStatusCommand(command.target)}\n`);
170
209
  break;
171
210
  case "doctor":
172
- process.stdout.write(`${runDoctorCommand(command.target)}\n`);
211
+ process.stdout.write(`${await runDoctorCommand(command.target)}\n`);
173
212
  break;
174
213
  }
175
214
  }
@@ -1,4 +1,4 @@
1
- export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "project_target_ambiguous" | "project_target_not_found" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable";
1
+ export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable";
2
2
  export declare class LaunchError extends Error {
3
3
  readonly code: LaunchErrorCode;
4
4
  readonly next: string;
@@ -44,7 +44,12 @@ export function formatCliError(error) {
44
44
  if (error.code === "provider_setup_cancelled") {
45
45
  return ["Tutti command cancelled", "", `Reason: ${redactText(error.message)}`].join("\n");
46
46
  }
47
- const lines = ["Tutti command failed", "", `Code: ${error.code}`, `Reason: ${formatBoundedText(error.message)}`];
47
+ const lines = [
48
+ "Tutti command failed",
49
+ "",
50
+ `Code: ${error.code}`,
51
+ `Reason: ${formatBoundedText(error.message)}`,
52
+ ];
48
53
  const detailLines = Object.entries(error.details)
49
54
  .filter(([, value]) => value !== undefined)
50
55
  .map(([key, value]) => `Detail ${key}: ${formatBoundedDetail(value)}`);
@@ -37,6 +37,7 @@ export type StartLaunchProjectOptions = LaunchPreparationOptions & {
37
37
  connectRelayHostTunnel?: RelayHostTunnelConnector;
38
38
  probeRuntimeEndpoint?: HostRuntimeEndpointProbe;
39
39
  registerRelayHostConnection?: RelayHostConnectionRegistrar;
40
+ reconnectWaitMs?: number;
40
41
  shutdownWaitMs?: number;
41
42
  };
42
43
  export type FormatLaunchLifecycleResultOptions = {
@@ -4,15 +4,19 @@ import { createHostServer } from "../http/create-server.js";
4
4
  import { createHostProjectApiTunnelRequestHandler, createHostProjectApiTunnelStreamRequestHandler, } from "../http/host-tunnel.js";
5
5
  import { LaunchError } from "./errors.js";
6
6
  import { TUTTI_MAINLINE_BRANCH } from "./git-bootstrap.js";
7
- import { relayStateFromLaunchStatus, relayStatusFromLocalStatus } from "./host-relay-status.js";
7
+ import { hostLocalLaunchStatusFromConnectionManagerState, relayStateFromConnectionManagerState, relayStatusFromLocalStatus, } from "./host-relay-status.js";
8
+ import { createHostRelayConnectionManager, } from "./host-relay-connection-manager.js";
8
9
  import { createRuntimeEndpointProbe, readExistingHostLaunchStatus, requestExistingHostShutdown, } from "./host-runtime-endpoint.js";
10
+ import { reconnectHostLocalRelay } from "./local-control-client.js";
9
11
  import { HOST_SERVER_VERSION, startForegroundHostServer, } from "./host-server-runtime.js";
10
12
  import { deleteMachineRuntimeEndpoint, readHostRegistrationSecret, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
13
+ import { inspectMachineProject, inspectMachineProjectInventories, normalizeMachineWorkspacePath, } from "./machine-project-inspector.js";
11
14
  import { prepareLaunchProject, resolveLaunchLocalContext, } from "./launch.js";
12
15
  import { registerHostWithRelay, relayErrorToLaunchError, } from "./relay-registration.js";
13
16
  import { formatJoinLinkValidity, formatTerminalLink } from "./terminal-qr.js";
14
17
  export { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
15
18
  const DEFAULT_SHUTDOWN_WAIT_MS = 2_000;
19
+ const DEFAULT_RECONNECT_WAIT_MS = 8_000;
16
20
  function urlLine(label, value, options) {
17
21
  return `${label}: ${formatTerminalLink(value, options)}`;
18
22
  }
@@ -72,21 +76,106 @@ function createSummary(options) {
72
76
  }
73
77
  return summary;
74
78
  }
79
+ async function reconcileReplacedWorkspaceHosts(options) {
80
+ const normalizedWorkspaceRoot = normalizeMachineWorkspacePath(options.workspaceRoot);
81
+ const replacedProjects = inspectMachineProjectInventories(options.tuttiHome).filter((inspection) => inspection.project_id !== options.projectId &&
82
+ inspection.normalized_workspace_root === normalizedWorkspaceRoot &&
83
+ inspection.workspace.kind === "replaced" &&
84
+ inspection.workspace.actual_project_id === options.projectId &&
85
+ inspection.endpoint.kind === "valid" &&
86
+ normalizeMachineWorkspacePath(inspection.endpoint.record.workspace_root) ===
87
+ normalizedWorkspaceRoot);
88
+ for (const replacedProject of replacedProjects) {
89
+ const inspection = await inspectMachineProject(replacedProject.project_id, {
90
+ tuttiHome: options.tuttiHome,
91
+ fetchImpl: options.fetchImpl,
92
+ probeRuntimeEndpoint: options.probeRuntimeEndpoint,
93
+ });
94
+ if (inspection.endpoint.kind !== "valid") {
95
+ continue;
96
+ }
97
+ if ((inspection.host.kind === "running" && !inspection.host.identity_verified) ||
98
+ (inspection.host.kind === "stale" &&
99
+ inspection.host.reason_code === "local_control_unavailable")) {
100
+ throw new LaunchError("takeover_failed", "The replaced project host identity could not be verified before shutdown", "Stop the old host manually, then run `tutti launch` again.");
101
+ }
102
+ const shouldRequestShutdown = inspection.host.kind === "running";
103
+ if (shouldRequestShutdown) {
104
+ await requestExistingHostShutdown({
105
+ endpoint: inspection.endpoint.record,
106
+ fetchImpl: options.fetchImpl,
107
+ probeRuntimeEndpoint: options.probeRuntimeEndpoint,
108
+ shutdownWaitMs: options.shutdownWaitMs,
109
+ });
110
+ }
111
+ if (shouldRequestShutdown ||
112
+ inspection.host.kind === "stale" ||
113
+ inspection.host.kind === "identity_mismatch") {
114
+ deleteMachineRuntimeEndpoint({
115
+ tuttiHome: options.tuttiHome,
116
+ projectId: inspection.project_id,
117
+ expectedToken: inspection.endpoint.record.token,
118
+ });
119
+ }
120
+ }
121
+ }
75
122
  export async function startLaunchProject(options) {
76
123
  const context = resolveLaunchLocalContext(options);
77
124
  const fetchImpl = options.fetch ?? fetch;
78
125
  const probeRuntimeEndpoint = options.probeRuntimeEndpoint ?? createRuntimeEndpointProbe(fetchImpl);
79
126
  const shutdownWaitMs = options.shutdownWaitMs ?? DEFAULT_SHUTDOWN_WAIT_MS;
127
+ const reconnectWaitMs = options.reconnectWaitMs ?? DEFAULT_RECONNECT_WAIT_MS;
80
128
  let startupStatus = context.existing_identity.kind === "missing" ? "created" : "recovered";
81
129
  let allowWorkspaceRootTakeover = false;
82
130
  if (context.existing_identity.kind === "present") {
83
131
  const projectId = context.existing_identity.project_id;
132
+ await reconcileReplacedWorkspaceHosts({
133
+ tuttiHome: context.tutti_home,
134
+ workspaceRoot: context.workspace_root,
135
+ projectId,
136
+ fetchImpl,
137
+ probeRuntimeEndpoint,
138
+ shutdownWaitMs,
139
+ });
84
140
  const endpoint = readMachineRuntimeEndpoint(context.tutti_home, projectId);
85
141
  if (endpoint !== null) {
86
142
  const sameWorkspace = resolve(endpoint.workspace_root) === context.workspace_root;
87
143
  const probe = await probeRuntimeEndpoint(endpoint);
88
144
  if (probe.kind === "alive" && sameWorkspace) {
89
- const launchStatus = await readExistingHostLaunchStatus({ endpoint, fetchImpl });
145
+ let launchStatus = await readExistingHostLaunchStatus({ endpoint, fetchImpl });
146
+ if (launchStatus.project_id !== projectId) {
147
+ throw new LaunchError("existing_server_unhealthy", "The existing Tutti host identity could not be verified for this project", "Stop the existing host manually, then run `tutti launch` again.");
148
+ }
149
+ const relayConnected = launchStatus.relay_connection?.status === "connected" ||
150
+ (launchStatus.relay_connection === undefined && probe.readiness?.relay === "connected");
151
+ if (!relayConnected) {
152
+ try {
153
+ launchStatus = await reconnectHostLocalRelay({
154
+ endpoint,
155
+ fetchImpl,
156
+ timeoutMs: reconnectWaitMs,
157
+ });
158
+ }
159
+ catch {
160
+ throw new LaunchError("relay_unavailable", "The existing Tutti host could not complete a bounded Relay reconnect", "The host is still running. Check Relay connectivity, then run `tutti launch` again.");
161
+ }
162
+ if (launchStatus.project_id !== projectId) {
163
+ throw new LaunchError("existing_server_unhealthy", "The reconnected Tutti host identity does not match this project", "The existing host was preserved. Stop it manually, then run `tutti launch` again.");
164
+ }
165
+ const reconnectedProbe = await probeRuntimeEndpoint(endpoint);
166
+ const reconnected = launchStatus.relay_connection?.status === "connected" ||
167
+ (launchStatus.relay_connection === undefined &&
168
+ reconnectedProbe.kind === "alive" &&
169
+ reconnectedProbe.readiness?.relay === "connected");
170
+ if (!reconnected) {
171
+ throw new LaunchError("relay_unavailable", "The existing Tutti host is running but Relay reconnection is not complete", "The host was preserved. Check `tutti ps` or `tutti doctor`, then retry launch.", {
172
+ relay_status: launchStatus.relay_connection?.status ?? "not_connected",
173
+ ...(launchStatus.relay_connection?.reason_code === undefined
174
+ ? {}
175
+ : { reason_code: launchStatus.relay_connection.reason_code }),
176
+ });
177
+ }
178
+ }
90
179
  const relay = relayStatusFromLocalStatus(launchStatus);
91
180
  return {
92
181
  kind: "connected",
@@ -154,58 +243,55 @@ export async function startLaunchProject(options) {
154
243
  ...options,
155
244
  allowWorkspaceRootTakeover,
156
245
  });
157
- const launchStatus = {};
158
- const refreshRelayJoinUrl = async () => {
159
- if (launchStatus.relay === undefined) {
160
- return relayStateFromLaunchStatus(preparation.relay_url, launchStatus);
161
- }
162
- const hostSecret = readHostRegistrationSecret(preparation.tutti_home, preparation.project_id);
163
- const response = await refreshRelayJoinTokenWithRelay({
164
- relayUrl: preparation.relay_url,
246
+ if (context.existing_identity.kind === "missing") {
247
+ await reconcileReplacedWorkspaceHosts({
248
+ tuttiHome: preparation.tutti_home,
249
+ workspaceRoot: preparation.workspace_root,
165
250
  projectId: preparation.project_id,
166
- relayProjectRef: launchStatus.relay.relay_project_ref,
167
- hostConnectionRef: launchStatus.relay.host_connection_ref,
168
- hostRegistrationSecret: hostSecret.secret,
169
- joinToken: { mode: "rotate" },
170
- fetch: fetchImpl,
251
+ fetchImpl,
252
+ probeRuntimeEndpoint,
253
+ shutdownWaitMs,
171
254
  });
172
- const relayWithoutJoinUrl = { ...launchStatus.relay };
173
- delete relayWithoutJoinUrl.join_url;
174
- launchStatus.relay = {
175
- ...relayWithoutJoinUrl,
176
- ...(response.join_token.join_url === undefined
177
- ? {}
178
- : { join_url: response.join_token.join_url }),
179
- join_url_visibility: response.join_token.visibility,
180
- join_token_expires_at: response.join_token.expires_at,
181
- join_token_reusable: response.join_token.reusable,
182
- };
183
- return relayStateFromLaunchStatus(preparation.relay_url, launchStatus);
255
+ }
256
+ const relayRuntime = {};
257
+ const readConnectionState = () => relayRuntime.connectionManager?.getState() ?? {
258
+ status: "unavailable",
259
+ reason_code: "not_started",
260
+ };
261
+ const getLaunchStatus = () => hostLocalLaunchStatusFromConnectionManagerState(preparation.project_id, readConnectionState());
262
+ const refreshRelayJoinUrl = async () => {
263
+ if (relayRuntime.connectionManager === undefined) {
264
+ return relayStateFromConnectionManagerState(preparation.relay_url, readConnectionState());
265
+ }
266
+ await relayRuntime.connectionManager.refreshJoinUrl();
267
+ return relayStateFromConnectionManagerState(preparation.relay_url, relayRuntime.connectionManager.getState());
184
268
  };
185
269
  const host = await startForegroundHostServer({
186
270
  preparation,
187
271
  endpointHost: context.local_diagnostic_endpoint.host,
188
272
  endpointPort: context.local_diagnostic_endpoint.port,
189
273
  createServer: options.createServer ?? createHostServer,
190
- getLaunchStatus: () => launchStatus,
274
+ getLaunchStatus,
191
275
  refreshRelayJoinUrl,
192
276
  ...(options.now === undefined ? {} : { now: options.now }),
193
277
  });
194
- let relay;
195
- try {
196
- const registered = await registerHostWithRelay({
278
+ const connectionManager = createHostRelayConnectionManager({
279
+ tuttiHome: preparation.tutti_home,
280
+ projectId: preparation.project_id,
281
+ workspaceRoot: preparation.workspace_root,
282
+ registerConnection: async ({ joinTokenMode, signal }) => await registerHostWithRelay({
197
283
  preparation,
198
284
  fetchImpl,
199
285
  registerRelayHostConnection: options.registerRelayHostConnection ?? registerRelayHostConnectionWithRelay,
200
286
  serverVersion: HOST_SERVER_VERSION,
287
+ joinTokenMode,
288
+ signal,
201
289
  ...(options.now === undefined ? {} : { now: options.now }),
202
- });
203
- relay = registered.summary;
204
- launchStatus.relay = relay;
205
- const tunnel = (options.connectRelayHostTunnel ?? connectRelayHostTunnel)({
206
- tunnelUrl: relay.tunnel_url,
207
- relayProjectRef: relay.relay_project_ref,
208
- hostConnectionRef: relay.host_connection_ref,
290
+ }),
291
+ connectTunnel: (registered) => (options.connectRelayHostTunnel ?? connectRelayHostTunnel)({
292
+ tunnelUrl: registered.summary.tunnel_url,
293
+ relayProjectRef: registered.summary.relay_project_ref,
294
+ hostConnectionRef: registered.summary.host_connection_ref,
209
295
  hostConnectionToken: registered.hostConnectionToken,
210
296
  handleRequest: createHostProjectApiTunnelRequestHandler({
211
297
  app: host.app,
@@ -215,9 +301,37 @@ export async function startLaunchProject(options) {
215
301
  baseUrl: host.runtime_endpoint.base_url,
216
302
  trustedMetadataStore: host.trusted_relay_metadata_store,
217
303
  }),
218
- });
219
- await tunnel.connected;
220
- host.attachRelayTunnel(tunnel);
304
+ }),
305
+ refreshJoinUrl: async (current) => {
306
+ const hostSecret = readHostRegistrationSecret(preparation.tutti_home, preparation.project_id);
307
+ const response = await refreshRelayJoinTokenWithRelay({
308
+ relayUrl: preparation.relay_url,
309
+ projectId: preparation.project_id,
310
+ relayProjectRef: current.relay_project_ref,
311
+ hostConnectionRef: current.host_connection_ref,
312
+ hostRegistrationSecret: hostSecret.secret,
313
+ joinToken: { mode: "rotate" },
314
+ fetch: fetchImpl,
315
+ });
316
+ const withoutJoinUrl = { ...current };
317
+ delete withoutJoinUrl.join_url;
318
+ return {
319
+ ...withoutJoinUrl,
320
+ ...(response.join_token.join_url === undefined
321
+ ? {}
322
+ : { join_url: response.join_token.join_url }),
323
+ join_url_visibility: response.join_token.visibility,
324
+ join_token_expires_at: response.join_token.expires_at,
325
+ join_token_reusable: response.join_token.reusable,
326
+ };
327
+ },
328
+ ...(options.now === undefined ? {} : { now: options.now }),
329
+ });
330
+ relayRuntime.connectionManager = connectionManager;
331
+ host.attachRelayConnectionManager(connectionManager);
332
+ let relay;
333
+ try {
334
+ relay = await connectionManager.start();
221
335
  }
222
336
  catch (error) {
223
337
  const launchError = error instanceof LaunchError || error instanceof RelayHostControlClientError
@@ -0,0 +1,50 @@
1
+ import { type RelayHostTunnelHandle } from "@tutti/relay-client";
2
+ import type { ProjectId } from "@tutti/shared/ids";
3
+ import type { HostLaunchRelaySummary, RegisteredRelayHostConnection } from "./relay-registration.js";
4
+ export type HostRelayConnectionManagerState = {
5
+ status: "unavailable";
6
+ reason_code: "not_started" | "relay_unavailable" | "workspace_missing" | "workspace_replaced" | "workspace_unreadable" | "fatal_error";
7
+ error_code?: string;
8
+ next_retry_at?: string;
9
+ last_connection?: HostLaunchRelaySummary;
10
+ } | {
11
+ status: "connecting";
12
+ attempt: 0;
13
+ } | {
14
+ status: "connected";
15
+ connection: HostLaunchRelaySummary;
16
+ } | {
17
+ status: "reconnecting";
18
+ attempt: number;
19
+ next_retry_at?: string;
20
+ last_connected_at?: string;
21
+ last_connection?: HostLaunchRelaySummary;
22
+ } | {
23
+ status: "stopped";
24
+ };
25
+ export type HostRelayConnectionManager = {
26
+ start: () => Promise<HostLaunchRelaySummary>;
27
+ reconnectNow: () => Promise<HostRelayConnectionManagerState>;
28
+ refreshJoinUrl: () => Promise<HostLaunchRelaySummary>;
29
+ getState: () => HostRelayConnectionManagerState;
30
+ stop: () => Promise<void>;
31
+ };
32
+ export type CreateHostRelayConnectionManagerOptions = {
33
+ tuttiHome: string;
34
+ projectId: ProjectId;
35
+ workspaceRoot: string;
36
+ registerConnection: (options: {
37
+ joinTokenMode: "reuse_active" | "rotate";
38
+ signal: AbortSignal;
39
+ }) => Promise<RegisteredRelayHostConnection>;
40
+ connectTunnel: (registered: RegisteredRelayHostConnection) => RelayHostTunnelHandle;
41
+ refreshJoinUrl?: (connection: HostLaunchRelaySummary) => Promise<HostLaunchRelaySummary>;
42
+ onStateChange?: (state: HostRelayConnectionManagerState) => void;
43
+ retryDelaysMs?: readonly number[];
44
+ jitterRatio?: number;
45
+ workspaceCheckIntervalMs?: number;
46
+ now?: () => Date;
47
+ random?: () => number;
48
+ };
49
+ export declare function createHostRelayConnectionManager(options: CreateHostRelayConnectionManagerOptions): HostRelayConnectionManager;
50
+ //# sourceMappingURL=host-relay-connection-manager.d.ts.map