@xfey/tutti 0.1.29 → 0.1.31
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/web/assets/index-Du5Txb6C.js +29 -0
- package/web/index.html +1 -1
- package/web/assets/index-DPU2uqZl.js +0 -29
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
2
|
import { emitKeypressEvents } from "node:readline";
|
|
4
|
-
import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
|
|
5
3
|
import { redactText } from "@tutti/shared/utils";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
4
|
+
import { archiveRelayProject as archiveRelayProjectAtRelay, RelayHostControlClientError, } from "@tutti/relay-client";
|
|
5
|
+
import { createRuntimeEndpointProbe, requestExistingHostShutdown, } from "./host-runtime-endpoint.js";
|
|
6
|
+
import { deleteMachineProjectLocalState, getHostLogFilePath, getMachineRuntimeEndpointPath, getProjectLocalStoreRoot, readMachineRuntimeEndpoint, readHostRegistrationSecret, } from "./machine-local.js";
|
|
7
|
+
import { inspectMachineProject, inspectMachineProjectInventory, inspectMachineProjects, } from "./machine-project-inspector.js";
|
|
8
|
+
import { readHostLocalLaunchStatus, readHostLocalProviderConfig, requestHostLocalShutdown, rotateHostLocalInvite, } from "./local-control-client.js";
|
|
9
9
|
import { formatCliErrorReason, LaunchError } from "./errors.js";
|
|
10
10
|
import { resolveManagedProjectContext } from "./project-resolver.js";
|
|
11
11
|
import { formatJoinLinkValidity, renderTerminalQr } from "./terminal-qr.js";
|
|
12
12
|
import { resolveTuttiHome } from "../../providers/openai/index.js";
|
|
13
13
|
import { readCliVersion } from "./version.js";
|
|
14
|
-
import {
|
|
14
|
+
import { resolveRelayUrl } from "./launch.js";
|
|
15
15
|
const PROJECT_ID_DISPLAY_MIN_LENGTH = 12;
|
|
16
16
|
function resolveProject(target, options = {}) {
|
|
17
17
|
return resolveManagedProjectContext({
|
|
@@ -20,16 +20,6 @@ function resolveProject(target, options = {}) {
|
|
|
20
20
|
...(options.env === undefined ? {} : { env: options.env }),
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
-
function projectIdsFromTuttiHome(tuttiHome) {
|
|
24
|
-
const projectsRoot = join(tuttiHome, "projects");
|
|
25
|
-
if (!existsSync(projectsRoot)) {
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
return readdirSync(projectsRoot, { withFileTypes: true })
|
|
29
|
-
.filter((entry) => entry.isDirectory() && isPrefixedId(entry.name, ID_PREFIXES.project))
|
|
30
|
-
.map((entry) => entry.name)
|
|
31
|
-
.sort();
|
|
32
|
-
}
|
|
33
23
|
function readEndpointFile(tuttiHome, projectId) {
|
|
34
24
|
try {
|
|
35
25
|
return readMachineRuntimeEndpoint(tuttiHome, projectId);
|
|
@@ -38,37 +28,32 @@ function readEndpointFile(tuttiHome, projectId) {
|
|
|
38
28
|
return null;
|
|
39
29
|
}
|
|
40
30
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
return
|
|
31
|
+
function runtimeStatus(inspection) {
|
|
32
|
+
if (inspection.binding.kind === "invalid" || inspection.endpoint.kind === "invalid") {
|
|
33
|
+
return "invalid";
|
|
44
34
|
}
|
|
45
|
-
|
|
46
|
-
return
|
|
35
|
+
if (inspection.workspace.kind === "missing") {
|
|
36
|
+
return "workspace-missing";
|
|
47
37
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
38
|
+
if (inspection.workspace.kind === "replaced" ||
|
|
39
|
+
inspection.workspace.kind === "not_a_tutti_project" ||
|
|
40
|
+
inspection.workspace.kind === "unreadable" ||
|
|
41
|
+
inspection.host.kind === "identity_mismatch") {
|
|
42
|
+
return "workspace-replaced";
|
|
52
43
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return identity.kind === "present" && identity.project_id === projectId;
|
|
44
|
+
if (inspection.host.kind === "stale") {
|
|
45
|
+
return "stale";
|
|
56
46
|
}
|
|
57
|
-
|
|
58
|
-
return
|
|
47
|
+
if (inspection.host.kind === "stopped") {
|
|
48
|
+
return "stopped";
|
|
59
49
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (options.hostAlive) {
|
|
63
|
-
await requestHostLocalShutdown({
|
|
64
|
-
endpoint: options.endpoint,
|
|
65
|
-
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
66
|
-
}).catch(() => undefined);
|
|
50
|
+
if (inspection.relay === "connected") {
|
|
51
|
+
return "online";
|
|
67
52
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
53
|
+
if (inspection.relay === "connecting" || inspection.relay === "reconnecting") {
|
|
54
|
+
return "reconnecting";
|
|
55
|
+
}
|
|
56
|
+
return "relay-unavailable";
|
|
72
57
|
}
|
|
73
58
|
export function formatProjectIdForDisplay(projectId, projectIds) {
|
|
74
59
|
for (let length = Math.min(PROJECT_ID_DISPLAY_MIN_LENGTH, projectId.length); length <= projectId.length; length += 1) {
|
|
@@ -82,60 +67,50 @@ export function formatProjectIdForDisplay(projectId, projectIds) {
|
|
|
82
67
|
export async function listRuntimeProjects(options = {}) {
|
|
83
68
|
const cwd = options.cwd ?? process.cwd();
|
|
84
69
|
const tuttiHome = resolveTuttiHome(options.env?.TUTTI_HOME, cwd);
|
|
85
|
-
const probe = createRuntimeEndpointProbe(options.fetchImpl ?? fetch);
|
|
86
70
|
const rows = [];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const workspaceRoot = binding?.workspace_root ?? endpoint.workspace_root;
|
|
94
|
-
const displayName = binding?.workspace_root === undefined
|
|
95
|
-
? projectId
|
|
96
|
-
: binding.workspace_root.split(/[\\/]/u).pop() ?? projectId;
|
|
97
|
-
const health = await probe(endpoint);
|
|
98
|
-
if (!workspaceBelongsToProject(workspaceRoot, projectId)) {
|
|
99
|
-
await cleanupOrphanedRuntimeProject({
|
|
100
|
-
tuttiHome,
|
|
101
|
-
projectId,
|
|
102
|
-
endpoint,
|
|
103
|
-
hostAlive: health.kind === "alive",
|
|
104
|
-
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
105
|
-
});
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (health.kind === "stale") {
|
|
109
|
-
rows.push({
|
|
110
|
-
project_id: projectId,
|
|
111
|
-
status: "stale",
|
|
112
|
-
display_name: displayName,
|
|
113
|
-
workspace_root: workspaceRoot,
|
|
114
|
-
endpoint: endpoint.base_url,
|
|
115
|
-
});
|
|
71
|
+
const inspections = await inspectMachineProjects({
|
|
72
|
+
tuttiHome,
|
|
73
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
74
|
+
});
|
|
75
|
+
for (const inspection of inspections) {
|
|
76
|
+
if (options.all !== true && inspection.endpoint.kind === "missing") {
|
|
116
77
|
continue;
|
|
117
78
|
}
|
|
79
|
+
const endpoint = inspection.endpoint.kind === "valid" ? inspection.endpoint.record : undefined;
|
|
118
80
|
const fetchOption = options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl };
|
|
119
|
-
const [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
81
|
+
const [provider, launchStatus] = endpoint === undefined || inspection.host.kind !== "running"
|
|
82
|
+
? [undefined, undefined]
|
|
83
|
+
: await Promise.all([
|
|
84
|
+
readHostLocalProviderConfig({ endpoint, ...fetchOption }).catch(() => undefined),
|
|
85
|
+
readHostLocalLaunchStatus({ endpoint, ...fetchOption }).catch(() => undefined),
|
|
86
|
+
]);
|
|
124
87
|
const row = {
|
|
125
|
-
project_id:
|
|
126
|
-
status:
|
|
127
|
-
display_name:
|
|
128
|
-
workspace_root:
|
|
129
|
-
endpoint: endpoint.base_url,
|
|
88
|
+
project_id: inspection.project_id,
|
|
89
|
+
status: runtimeStatus(inspection),
|
|
90
|
+
display_name: inspection.display_name,
|
|
91
|
+
workspace_root: inspection.workspace_root ?? "-",
|
|
92
|
+
...(endpoint === undefined ? {} : { endpoint: endpoint.base_url }),
|
|
130
93
|
};
|
|
131
|
-
if (provider
|
|
132
|
-
row.provider_status = provider.
|
|
94
|
+
if (provider !== undefined) {
|
|
95
|
+
row.provider_status = provider.status;
|
|
133
96
|
}
|
|
134
|
-
if (launchStatus
|
|
135
|
-
row.relay_project_ref = launchStatus.
|
|
97
|
+
if (launchStatus?.relay?.relay_project_ref !== undefined) {
|
|
98
|
+
row.relay_project_ref = launchStatus.relay.relay_project_ref;
|
|
136
99
|
}
|
|
137
|
-
if (launchStatus
|
|
138
|
-
row.join_url = launchStatus.
|
|
100
|
+
if (launchStatus?.relay?.join_url !== undefined) {
|
|
101
|
+
row.join_url = launchStatus.relay.join_url;
|
|
102
|
+
}
|
|
103
|
+
if (launchStatus?.relay_connection !== undefined) {
|
|
104
|
+
row.relay_connection_status = launchStatus.relay_connection.status;
|
|
105
|
+
if (launchStatus.relay_connection.last_connected_at !== undefined) {
|
|
106
|
+
row.last_connected_at = launchStatus.relay_connection.last_connected_at;
|
|
107
|
+
}
|
|
108
|
+
if (launchStatus.relay_connection.next_retry_at !== undefined) {
|
|
109
|
+
row.next_retry_at = launchStatus.relay_connection.next_retry_at;
|
|
110
|
+
}
|
|
111
|
+
if (launchStatus.relay_connection.reason_code !== undefined) {
|
|
112
|
+
row.relay_reason_code = launchStatus.relay_connection.reason_code;
|
|
113
|
+
}
|
|
139
114
|
}
|
|
140
115
|
rows.push(row);
|
|
141
116
|
}
|
|
@@ -145,19 +120,29 @@ function formatTable(rows) {
|
|
|
145
120
|
if (rows.length === 0) {
|
|
146
121
|
return "No Tutti host processes found.";
|
|
147
122
|
}
|
|
148
|
-
const header = ["STATUS", "ID", "PROJECT", "PROVIDER", "WORKSPACE"];
|
|
123
|
+
const header = ["STATUS", "ID", "PROJECT", "PROVIDER", "RELAY", "WORKSPACE"];
|
|
149
124
|
const projectIds = rows.map((row) => row.project_id);
|
|
150
125
|
const data = rows.map((row) => [
|
|
151
126
|
row.status,
|
|
152
127
|
formatProjectIdForDisplay(row.project_id, projectIds),
|
|
153
128
|
row.display_name,
|
|
154
129
|
row.provider_status ?? "-",
|
|
130
|
+
formatRelayRuntimeDetail(row),
|
|
155
131
|
redactText(row.workspace_root),
|
|
156
132
|
]);
|
|
157
133
|
const widths = header.map((label, index) => Math.max(label.length, ...data.map((row) => row[index]?.length ?? 0)));
|
|
158
134
|
const line = (columns) => columns.map((column, index) => column.padEnd(widths[index] ?? column.length)).join(" ");
|
|
159
135
|
return [line(header), line(widths.map((width) => "-".repeat(width))), ...data.map(line)].join("\n");
|
|
160
136
|
}
|
|
137
|
+
function formatRelayRuntimeDetail(row) {
|
|
138
|
+
const details = [
|
|
139
|
+
row.relay_connection_status,
|
|
140
|
+
...(row.last_connected_at === undefined ? [] : [`last=${row.last_connected_at}`]),
|
|
141
|
+
...(row.next_retry_at === undefined ? [] : [`retry=${row.next_retry_at}`]),
|
|
142
|
+
...(row.relay_reason_code === undefined ? [] : [`reason=${row.relay_reason_code}`]),
|
|
143
|
+
].filter((value) => value !== undefined && value !== "");
|
|
144
|
+
return details.length === 0 ? "-" : redactText(details.join(" "));
|
|
145
|
+
}
|
|
161
146
|
const CLEAR = "\u001B[2J\u001B[H";
|
|
162
147
|
const HIDE_CURSOR = "\u001B[?25l";
|
|
163
148
|
const SHOW_CURSOR = "\u001B[?25h";
|
|
@@ -186,7 +171,7 @@ function renderManager(options) {
|
|
|
186
171
|
"",
|
|
187
172
|
];
|
|
188
173
|
if (options.rows.length > 0) {
|
|
189
|
-
const header = ["", "STATUS", "ID", "PROJECT", "PROVIDER", "WORKSPACE"];
|
|
174
|
+
const header = ["", "STATUS", "ID", "PROJECT", "PROVIDER", "RELAY", "WORKSPACE"];
|
|
190
175
|
const projectIds = options.rows.map((row) => row.project_id);
|
|
191
176
|
const data = options.rows.map((row, index) => [
|
|
192
177
|
index === options.selectedIndex ? ">" : " ",
|
|
@@ -194,6 +179,7 @@ function renderManager(options) {
|
|
|
194
179
|
formatProjectIdForDisplay(row.project_id, projectIds),
|
|
195
180
|
row.display_name,
|
|
196
181
|
row.provider_status ?? "-",
|
|
182
|
+
formatRelayRuntimeDetail(row),
|
|
197
183
|
redactText(row.workspace_root),
|
|
198
184
|
]);
|
|
199
185
|
const widths = header.map((label, index) => Math.max(label.length, ...data.map((row) => row[index]?.length ?? 0)));
|
|
@@ -208,19 +194,20 @@ function renderManager(options) {
|
|
|
208
194
|
}
|
|
209
195
|
return lines.join("\n");
|
|
210
196
|
}
|
|
211
|
-
export async function runPsCommand() {
|
|
212
|
-
return formatTable(await listRuntimeProjects());
|
|
197
|
+
export async function runPsCommand(options = {}) {
|
|
198
|
+
return formatTable(await listRuntimeProjects(options));
|
|
213
199
|
}
|
|
214
200
|
export async function runPsManageCommand(options = {}) {
|
|
215
201
|
const stdin = options.stdin ?? process.stdin;
|
|
216
202
|
const stdout = options.stdout ?? process.stdout;
|
|
203
|
+
const listOptions = options.all === undefined ? {} : { all: options.all };
|
|
217
204
|
if (!stdin.isTTY || !stdout.isTTY) {
|
|
218
|
-
stdout.write(`${await runPsCommand()}\n`);
|
|
205
|
+
stdout.write(`${await runPsCommand(listOptions)}\n`);
|
|
219
206
|
return;
|
|
220
207
|
}
|
|
221
208
|
emitKeypressEvents(stdin);
|
|
222
209
|
stdin.setRawMode(true);
|
|
223
|
-
let rows = await listRuntimeProjects();
|
|
210
|
+
let rows = await listRuntimeProjects(listOptions);
|
|
224
211
|
let selectedIndex = 0;
|
|
225
212
|
let message;
|
|
226
213
|
let detail;
|
|
@@ -257,7 +244,7 @@ export async function runPsManageCommand(options = {}) {
|
|
|
257
244
|
continue;
|
|
258
245
|
}
|
|
259
246
|
if (input.character === "r") {
|
|
260
|
-
rows = await listRuntimeProjects();
|
|
247
|
+
rows = await listRuntimeProjects(listOptions);
|
|
261
248
|
selectedIndex = Math.min(selectedIndex, Math.max(rows.length - 1, 0));
|
|
262
249
|
message = "Refreshed.";
|
|
263
250
|
detail = undefined;
|
|
@@ -273,7 +260,7 @@ export async function runPsManageCommand(options = {}) {
|
|
|
273
260
|
}
|
|
274
261
|
if (input.key.name === "return" || input.character === "i") {
|
|
275
262
|
try {
|
|
276
|
-
detail = await runInviteCommand(project.
|
|
263
|
+
detail = await runInviteCommand(project.project_id);
|
|
277
264
|
message = `Invite refreshed for ${project.display_name}.`;
|
|
278
265
|
}
|
|
279
266
|
catch (error) {
|
|
@@ -284,15 +271,15 @@ export async function runPsManageCommand(options = {}) {
|
|
|
284
271
|
continue;
|
|
285
272
|
}
|
|
286
273
|
if (input.character === "l") {
|
|
287
|
-
detail = runLogsCommand(project.
|
|
274
|
+
detail = runLogsCommand(project.project_id, 40);
|
|
288
275
|
message = `Showing last 40 host log lines for ${project.display_name}.`;
|
|
289
276
|
render();
|
|
290
277
|
continue;
|
|
291
278
|
}
|
|
292
279
|
if (input.character === "s") {
|
|
293
280
|
try {
|
|
294
|
-
message = await runStopCommand(project.
|
|
295
|
-
rows = await listRuntimeProjects();
|
|
281
|
+
message = await runStopCommand(project.project_id);
|
|
282
|
+
rows = await listRuntimeProjects(listOptions);
|
|
296
283
|
selectedIndex = Math.min(selectedIndex, Math.max(rows.length - 1, 0));
|
|
297
284
|
detail = undefined;
|
|
298
285
|
}
|
|
@@ -317,14 +304,9 @@ export async function runStopCommand(target, options = {}) {
|
|
|
317
304
|
const project = resolveProject(target, options);
|
|
318
305
|
const endpoint = project.runtime_endpoint;
|
|
319
306
|
if (endpoint === null) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
projectId: project.project_id,
|
|
324
|
-
});
|
|
325
|
-
return `Deleted ${project.display_name}; the workspace no longer exists.`;
|
|
326
|
-
}
|
|
327
|
-
return "Tutti host is not running for this project.";
|
|
307
|
+
return project.workspace_exists
|
|
308
|
+
? "Tutti host is not running for this project."
|
|
309
|
+
: `Tutti host is not running for ${project.display_name}; machine-local state was retained. Use \`tutti forget ${project.project_id}\` to delete it.`;
|
|
328
310
|
}
|
|
329
311
|
try {
|
|
330
312
|
await requestHostLocalShutdown({
|
|
@@ -336,13 +318,121 @@ export async function runStopCommand(target, options = {}) {
|
|
|
336
318
|
throw new LaunchError("existing_server_unhealthy", `Could not stop the host: ${formatCliErrorReason(error)}`, "Run `tutti ps` to inspect the runtime state, or stop the host process manually.");
|
|
337
319
|
}
|
|
338
320
|
if (!project.workspace_exists) {
|
|
339
|
-
|
|
340
|
-
|
|
321
|
+
return `Stopped ${project.display_name}; machine-local state was retained. Use \`tutti forget ${project.project_id}\` to delete it.`;
|
|
322
|
+
}
|
|
323
|
+
return `Stopped ${project.display_name}.`;
|
|
324
|
+
}
|
|
325
|
+
async function stopVerifiedHostBeforeDestructiveLifecycle(options) {
|
|
326
|
+
const inspection = await inspectMachineProject(options.projectId, {
|
|
327
|
+
tuttiHome: options.tuttiHome,
|
|
328
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
329
|
+
});
|
|
330
|
+
if (inspection.host.kind === "stale" &&
|
|
331
|
+
inspection.host.reason_code === "local_control_unavailable") {
|
|
332
|
+
throw new LaunchError("existing_server_unhealthy", "The host is reachable but its identity and shutdown control could not be verified", `Stop the host manually, then retry ${options.action} with the full project ID.`);
|
|
333
|
+
}
|
|
334
|
+
if (inspection.host.kind === "identity_mismatch") {
|
|
335
|
+
throw new LaunchError("existing_server_unhealthy", "The runtime endpoint belongs to a different Tutti project host", `Stop the host manually or remove the stale endpoint through launch recovery, then retry ${options.action} with the full project ID.`);
|
|
336
|
+
}
|
|
337
|
+
if (inspection.endpoint.kind !== "valid" || inspection.host.kind !== "running") {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (!inspection.host.identity_verified) {
|
|
341
|
+
throw new LaunchError("existing_server_unhealthy", `The running host identity could not be verified before ${options.action}`, `Stop the host manually, then retry ${options.action} with the full project ID.`);
|
|
342
|
+
}
|
|
343
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
344
|
+
try {
|
|
345
|
+
await requestExistingHostShutdown({
|
|
346
|
+
endpoint: inspection.endpoint.record,
|
|
347
|
+
fetchImpl,
|
|
348
|
+
probeRuntimeEndpoint: createRuntimeEndpointProbe(fetchImpl),
|
|
349
|
+
shutdownWaitMs: options.shutdownWaitMs ?? 2_000,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
throw new LaunchError("existing_server_unhealthy", `Could not safely stop the host before ${options.action}: ${formatCliErrorReason(error)}`, `The project state was retained. Stop the host manually, then retry ${options.action} with the full project ID.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
export async function runArchiveCommand(target, options = {}) {
|
|
357
|
+
const project = resolveProject(target, options);
|
|
358
|
+
const relayProjectRef = project.binding?.relay_project_ref;
|
|
359
|
+
if (relayProjectRef === undefined) {
|
|
360
|
+
throw new LaunchError("relay_registration_failed", "This machine-local project has no Relay project binding to archive", "Run `tutti launch` to establish a Relay binding, or use `tutti forget` for local-only cleanup.");
|
|
361
|
+
}
|
|
362
|
+
const interactive = options.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true);
|
|
363
|
+
if (options.yes !== true) {
|
|
364
|
+
if (!interactive) {
|
|
365
|
+
throw new LaunchError("archive_confirmation_required", "Archive requires explicit confirmation in a non-interactive terminal", `Re-run \`tutti archive ${project.project_id} --yes\` after verifying the project ID and global impact.`);
|
|
366
|
+
}
|
|
367
|
+
const accepted = (await options.confirm?.([
|
|
368
|
+
`Archive ${project.display_name} (${project.project_id}) on Relay?`,
|
|
369
|
+
"",
|
|
370
|
+
"This hides the project for all members and revokes its join link and client sessions.",
|
|
371
|
+
"Memberships and machine-local project state are retained.",
|
|
372
|
+
].join("\n"))) ?? false;
|
|
373
|
+
if (!accepted) {
|
|
374
|
+
throw new LaunchError("archive_rejected", "Relay project archive was not confirmed", "No Relay or machine-local project state was changed.");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const hostSecret = readHostRegistrationSecret(project.tutti_home, project.project_id);
|
|
378
|
+
await stopVerifiedHostBeforeDestructiveLifecycle({
|
|
379
|
+
projectId: project.project_id,
|
|
380
|
+
tuttiHome: project.tutti_home,
|
|
381
|
+
action: "archive",
|
|
382
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
383
|
+
...(options.shutdownWaitMs === undefined ? {} : { shutdownWaitMs: options.shutdownWaitMs }),
|
|
384
|
+
});
|
|
385
|
+
let response;
|
|
386
|
+
try {
|
|
387
|
+
response = await archiveRelayProjectAtRelay({
|
|
388
|
+
relayUrl: resolveRelayUrl(options.env),
|
|
341
389
|
projectId: project.project_id,
|
|
390
|
+
relayProjectRef,
|
|
391
|
+
hostRegistrationSecret: hostSecret.secret,
|
|
392
|
+
...(options.fetchImpl === undefined ? {} : { fetch: options.fetchImpl }),
|
|
393
|
+
...(options.now === undefined ? {} : { issuedAt: options.now() }),
|
|
342
394
|
});
|
|
343
|
-
return `Stopped and deleted ${project.display_name}; the workspace no longer exists.`;
|
|
344
395
|
}
|
|
345
|
-
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (error instanceof RelayHostControlClientError && error.code === "relay_unavailable") {
|
|
398
|
+
throw new LaunchError("relay_unavailable", "Relay is unavailable, so the project was not archived", `Machine-local state was retained. Retry \`tutti archive ${project.project_id} --yes\` after Relay recovers.`);
|
|
399
|
+
}
|
|
400
|
+
throw new LaunchError("relay_registration_failed", `Relay rejected project archive${error instanceof RelayHostControlClientError && error.relayErrorCode !== undefined ? ` (${error.relayErrorCode})` : ""}`, "Machine-local state was retained. Verify the Relay binding and host credential, then retry archive.");
|
|
401
|
+
}
|
|
402
|
+
if (response.disposition.kind === "interrupted") {
|
|
403
|
+
throw new LaunchError("relay_registration_failed", "Relay project archive was interrupted before completion", `Machine-local state was retained. Retry \`tutti archive ${project.project_id} --yes\` with a new command.`);
|
|
404
|
+
}
|
|
405
|
+
return `${response.disposition.kind === "already_archived" ? "Already archived" : "Archived"} ${project.display_name} (${project.project_id}) on Relay. Memberships and machine-local state were retained. Use \`tutti forget ${project.project_id} --yes\` only if local cleanup is also intended.`;
|
|
406
|
+
}
|
|
407
|
+
export async function runForgetCommand(target, options = {}) {
|
|
408
|
+
const project = resolveProject(target, options);
|
|
409
|
+
const interactive = options.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true);
|
|
410
|
+
if (options.yes !== true) {
|
|
411
|
+
if (!interactive) {
|
|
412
|
+
throw new LaunchError("forget_confirmation_required", "Forget requires explicit confirmation in a non-interactive terminal", `Re-run \`tutti forget ${project.project_id} --yes\` after verifying the project ID.`);
|
|
413
|
+
}
|
|
414
|
+
const accepted = (await options.confirm?.([
|
|
415
|
+
`Forget ${project.display_name} (${project.project_id}) on this machine?`,
|
|
416
|
+
"",
|
|
417
|
+
"This deletes its local SQLite store, provider config/auth, skills, cache, binding, endpoint, and host credential.",
|
|
418
|
+
"The Relay project and other members are not archived or removed.",
|
|
419
|
+
].join("\n"))) ?? false;
|
|
420
|
+
if (!accepted) {
|
|
421
|
+
throw new LaunchError("forget_rejected", "Machine-local project deletion was not confirmed", "No project state was deleted.");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
await stopVerifiedHostBeforeDestructiveLifecycle({
|
|
425
|
+
projectId: project.project_id,
|
|
426
|
+
tuttiHome: project.tutti_home,
|
|
427
|
+
action: "forget",
|
|
428
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
429
|
+
...(options.shutdownWaitMs === undefined ? {} : { shutdownWaitMs: options.shutdownWaitMs }),
|
|
430
|
+
});
|
|
431
|
+
deleteMachineProjectLocalState({
|
|
432
|
+
tuttiHome: project.tutti_home,
|
|
433
|
+
projectId: project.project_id,
|
|
434
|
+
});
|
|
435
|
+
return `Forgot ${project.display_name} (${project.project_id}) on this machine. The Relay project and memberships were not archived.`;
|
|
346
436
|
}
|
|
347
437
|
export async function runInviteCommand(target) {
|
|
348
438
|
const project = resolveProject(target);
|
|
@@ -398,17 +488,44 @@ export function runLogsCommand(target, tailLines = 120) {
|
|
|
398
488
|
const lines = readFileSync(logPath, "utf8").split(/\r?\n/u).filter(Boolean).slice(-tailLines);
|
|
399
489
|
return lines.length === 0 ? "Host log is empty." : lines.join("\n");
|
|
400
490
|
}
|
|
401
|
-
export function runDoctorCommand(target, options = {}) {
|
|
491
|
+
export async function runDoctorCommand(target, options = {}) {
|
|
402
492
|
const checks = [];
|
|
403
493
|
checks.push(`Node: ${process.version}`);
|
|
404
494
|
checks.push(`Tutti: ${readCliVersion()}`);
|
|
405
495
|
try {
|
|
406
496
|
const project = resolveProject(target, options);
|
|
497
|
+
const inventory = inspectMachineProjectInventory(project.project_id, project.tutti_home);
|
|
407
498
|
checks.push(`Project: ${project.display_name}`);
|
|
408
499
|
checks.push(`Project ID: ${project.project_id}`);
|
|
409
|
-
checks.push(`Workspace: ${project.workspace_exists ? redactText(project.workspace_root) : "
|
|
500
|
+
checks.push(`Workspace: ${inventory.workspace.kind}${project.workspace_exists ? ` (${redactText(project.workspace_root)})` : ""}`);
|
|
410
501
|
checks.push(`Provider: ${project.provider_config.status}`);
|
|
411
502
|
checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
|
|
503
|
+
if (project.runtime_endpoint !== null) {
|
|
504
|
+
try {
|
|
505
|
+
const launchStatus = await readHostLocalLaunchStatus({
|
|
506
|
+
endpoint: project.runtime_endpoint,
|
|
507
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
508
|
+
});
|
|
509
|
+
if (launchStatus.project_id !== project.project_id) {
|
|
510
|
+
checks.push("Relay: unknown (host identity unverified)");
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
checks.push(`Relay: ${launchStatus.relay_connection?.status ?? "unknown"}`);
|
|
514
|
+
if (launchStatus.relay_connection?.last_connected_at !== undefined) {
|
|
515
|
+
checks.push(`Relay last connected: ${launchStatus.relay_connection.last_connected_at}`);
|
|
516
|
+
}
|
|
517
|
+
if (launchStatus.relay_connection?.next_retry_at !== undefined) {
|
|
518
|
+
checks.push(`Relay next retry: ${launchStatus.relay_connection.next_retry_at}`);
|
|
519
|
+
}
|
|
520
|
+
if (launchStatus.relay_connection?.reason_code !== undefined) {
|
|
521
|
+
checks.push(`Relay reason: ${redactText(launchStatus.relay_connection.reason_code)}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
checks.push("Relay: unknown (host-local status unavailable)");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
412
529
|
}
|
|
413
530
|
catch (error) {
|
|
414
531
|
checks.push("Project: unavailable");
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { FastifyInstance } from "fastify";
|
|
2
2
|
import type { ProviderConfigProjection } from "@tutti/shared/schemas/api";
|
|
3
|
+
import type { ProjectId } from "@tutti/shared/ids";
|
|
3
4
|
export type HostLocalControlOptions = {
|
|
4
5
|
token: string;
|
|
5
6
|
getLaunchStatus?: () => HostLocalLaunchStatus;
|
|
6
7
|
refreshInvite?: () => Promise<HostLocalLaunchStatus>;
|
|
8
|
+
reconnectRelay?: () => Promise<HostLocalLaunchStatus>;
|
|
7
9
|
project?: {
|
|
8
10
|
get: () => HostLocalProjectProjection;
|
|
9
11
|
update: (input: HostLocalProjectBody) => HostLocalProjectProjection;
|
|
@@ -15,6 +17,14 @@ export type HostLocalControlOptions = {
|
|
|
15
17
|
requestShutdown: () => void;
|
|
16
18
|
};
|
|
17
19
|
export type HostLocalLaunchStatus = {
|
|
20
|
+
project_id?: ProjectId;
|
|
21
|
+
relay_connection?: {
|
|
22
|
+
status: "connecting" | "connected" | "reconnecting" | "unavailable" | "stopped";
|
|
23
|
+
attempt?: number;
|
|
24
|
+
next_retry_at?: string;
|
|
25
|
+
last_connected_at?: string;
|
|
26
|
+
reason_code?: string;
|
|
27
|
+
};
|
|
18
28
|
relay?: {
|
|
19
29
|
relay_project_ref: string;
|
|
20
30
|
host_connection_ref: string;
|
|
@@ -115,6 +115,22 @@ const HostLocalLaunchStatusResponseSchema = {
|
|
|
115
115
|
type: "object",
|
|
116
116
|
additionalProperties: false,
|
|
117
117
|
properties: {
|
|
118
|
+
project_id: { type: "string" },
|
|
119
|
+
relay_connection: {
|
|
120
|
+
type: "object",
|
|
121
|
+
required: ["status"],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
properties: {
|
|
124
|
+
status: {
|
|
125
|
+
type: "string",
|
|
126
|
+
enum: ["connecting", "connected", "reconnecting", "unavailable", "stopped"],
|
|
127
|
+
},
|
|
128
|
+
attempt: { type: "integer", minimum: 0 },
|
|
129
|
+
next_retry_at: { type: "string" },
|
|
130
|
+
last_connected_at: { type: "string" },
|
|
131
|
+
reason_code: { type: "string" },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
118
134
|
relay: {
|
|
119
135
|
type: "object",
|
|
120
136
|
required: [
|
|
@@ -190,6 +206,19 @@ export function registerHostLocalControlRoutes(app, options) {
|
|
|
190
206
|
requireLocalToken(request.headers.authorization);
|
|
191
207
|
return options.getLaunchStatus?.() ?? {};
|
|
192
208
|
});
|
|
209
|
+
app.post("/host-local/v1/relay/reconnect", {
|
|
210
|
+
schema: {
|
|
211
|
+
response: {
|
|
212
|
+
200: HostLocalLaunchStatusResponseSchema,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}, async (request) => {
|
|
216
|
+
requireLocalToken(request.headers.authorization);
|
|
217
|
+
if (options.reconnectRelay === undefined) {
|
|
218
|
+
return options.getLaunchStatus?.() ?? {};
|
|
219
|
+
}
|
|
220
|
+
return await options.reconnectRelay();
|
|
221
|
+
});
|
|
193
222
|
app.post("/host-local/v1/invite/rotate", {
|
|
194
223
|
schema: {
|
|
195
224
|
response: {
|
|
@@ -81,6 +81,17 @@ export type RefreshRelayJoinTokenPayload = {
|
|
|
81
81
|
export type RefreshRelayJoinTokenResponse = {
|
|
82
82
|
join_token: JoinTokenProjection;
|
|
83
83
|
};
|
|
84
|
+
export type ArchiveRelayProjectResponse = {
|
|
85
|
+
command_id: IdempotencyKey;
|
|
86
|
+
disposition: {
|
|
87
|
+
kind: "archived";
|
|
88
|
+
} | {
|
|
89
|
+
kind: "already_archived";
|
|
90
|
+
} | {
|
|
91
|
+
kind: "interrupted";
|
|
92
|
+
retry_with_new_command_id: true;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
84
95
|
export type RelayUploadProjection = {
|
|
85
96
|
upload_id: RelayUploadRef;
|
|
86
97
|
relay_project_ref: RelayProjectRef;
|
|
@@ -122,6 +133,7 @@ export type RegisterRelayHostConnectionOptions = {
|
|
|
122
133
|
capabilities?: Partial<RegisterRelayHostConnectionPayload["host"]["capabilities"]>;
|
|
123
134
|
fetch?: RelayHostControlFetch;
|
|
124
135
|
timeoutMs?: number;
|
|
136
|
+
signal?: AbortSignal;
|
|
125
137
|
};
|
|
126
138
|
export type RefreshRelayJoinTokenOptions = {
|
|
127
139
|
relayUrl: string;
|
|
@@ -136,6 +148,16 @@ export type RefreshRelayJoinTokenOptions = {
|
|
|
136
148
|
fetch?: RelayHostControlFetch;
|
|
137
149
|
timeoutMs?: number;
|
|
138
150
|
};
|
|
151
|
+
export type ArchiveRelayProjectOptions = {
|
|
152
|
+
relayUrl: string;
|
|
153
|
+
projectId: ProjectId;
|
|
154
|
+
relayProjectRef: RelayProjectRef;
|
|
155
|
+
hostRegistrationSecret: string;
|
|
156
|
+
commandId?: IdempotencyKey;
|
|
157
|
+
issuedAt?: Date;
|
|
158
|
+
fetch?: RelayHostControlFetch;
|
|
159
|
+
timeoutMs?: number;
|
|
160
|
+
};
|
|
139
161
|
export type ResolveRelayUploadOptions = {
|
|
140
162
|
relayUrl: string;
|
|
141
163
|
projectId: ProjectId;
|
|
@@ -159,6 +181,7 @@ export declare class RelayHostControlClientError extends Error {
|
|
|
159
181
|
}
|
|
160
182
|
export declare function registerRelayHostConnection(options: RegisterRelayHostConnectionOptions): Promise<RegisterRelayHostConnectionResponse>;
|
|
161
183
|
export declare function refreshRelayJoinToken(options: RefreshRelayJoinTokenOptions): Promise<RefreshRelayJoinTokenResponse>;
|
|
184
|
+
export declare function archiveRelayProject(options: ArchiveRelayProjectOptions): Promise<ArchiveRelayProjectResponse>;
|
|
162
185
|
export declare function resolveRelayUpload(options: ResolveRelayUploadOptions): Promise<ResolveRelayUploadResponse>;
|
|
163
186
|
export declare function completeRelayUpload(options: CompleteRelayUploadOptions): Promise<CompleteRelayUploadResponse>;
|
|
164
187
|
//# sourceMappingURL=host-control.d.ts.map
|