@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.
- package/README.md +1 -1
- package/dist/server-shell/cli/args.d.ts +9 -0
- package/dist/server-shell/cli/args.js +55 -5
- package/dist/server-shell/cli/cli.js +46 -7
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/errors.js +6 -1
- package/dist/server-shell/cli/host-lifecycle.d.ts +1 -0
- package/dist/server-shell/cli/host-lifecycle.js +155 -41
- package/dist/server-shell/cli/host-relay-connection-manager.d.ts +50 -0
- package/dist/server-shell/cli/host-relay-connection-manager.js +447 -0
- package/dist/server-shell/cli/host-relay-status.d.ts +4 -0
- package/dist/server-shell/cli/host-relay-status.js +74 -15
- package/dist/server-shell/cli/host-runtime-endpoint.js +26 -0
- package/dist/server-shell/cli/host-server-runtime.d.ts +2 -2
- package/dist/server-shell/cli/host-server-runtime.js +19 -15
- package/dist/server-shell/cli/launch-command.d.ts +1 -0
- package/dist/server-shell/cli/launch-command.js +8 -10
- package/dist/server-shell/cli/launch.d.ts +1 -0
- package/dist/server-shell/cli/launch.js +7 -3
- package/dist/server-shell/cli/local-control-client.d.ts +5 -0
- package/dist/server-shell/cli/local-control-client.js +9 -0
- package/dist/server-shell/cli/machine-local.d.ts +8 -0
- package/dist/server-shell/cli/machine-local.js +29 -0
- package/dist/server-shell/cli/machine-project-inspector.d.ts +81 -0
- package/dist/server-shell/cli/machine-project-inspector.js +205 -0
- package/dist/server-shell/cli/project-resolver.js +22 -32
- package/dist/server-shell/cli/relay-registration.d.ts +5 -2
- package/dist/server-shell/cli/relay-registration.js +7 -3
- package/dist/server-shell/cli/runtime-commands.d.ts +30 -4
- package/dist/server-shell/cli/runtime-commands.js +230 -113
- package/dist/server-shell/http/routes/local-control.d.ts +10 -0
- package/dist/server-shell/http/routes/local-control.js +29 -0
- package/node_modules/@tutti/relay-client/dist/host-control.d.ts +23 -0
- package/node_modules/@tutti/relay-client/dist/host-control.js +70 -3
- package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +6 -2
- package/node_modules/@tutti/relay-client/dist/tunnel.js +13 -2
- 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 = [
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
joinToken: { mode: "rotate" },
|
|
170
|
-
fetch: fetchImpl,
|
|
251
|
+
fetchImpl,
|
|
252
|
+
probeRuntimeEndpoint,
|
|
253
|
+
shutdownWaitMs,
|
|
171
254
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
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
|
|
274
|
+
getLaunchStatus,
|
|
191
275
|
refreshRelayJoinUrl,
|
|
192
276
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
193
277
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|