@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.
Files changed (40) 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
  38. package/web/assets/index-Du5Txb6C.js +29 -0
  39. package/web/index.html +1 -1
  40. package/web/assets/index-DPU2uqZl.js +0 -29
@@ -1,17 +1,17 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
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 { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
7
- import { deleteMachineProjectLocalState, getHostLogFilePath, getMachineRuntimeEndpointPath, getProjectLocalStoreRoot, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
8
- import { readHostLocalLaunchStatus, readHostLocalProject, readHostLocalProviderConfig, requestHostLocalShutdown, rotateHostLocalInvite, } from "./local-control-client.js";
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 { readProjectIdentity } from "./project-identity.js";
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 readBindingFile(tuttiHome, projectId) {
42
- try {
43
- return readMachineProjectBinding(tuttiHome, projectId);
31
+ function runtimeStatus(inspection) {
32
+ if (inspection.binding.kind === "invalid" || inspection.endpoint.kind === "invalid") {
33
+ return "invalid";
44
34
  }
45
- catch {
46
- return null;
35
+ if (inspection.workspace.kind === "missing") {
36
+ return "workspace-missing";
47
37
  }
48
- }
49
- function workspaceBelongsToProject(workspaceRoot, projectId) {
50
- if (!existsSync(workspaceRoot)) {
51
- return false;
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
- try {
54
- const identity = readProjectIdentity(workspaceRoot);
55
- return identity.kind === "present" && identity.project_id === projectId;
44
+ if (inspection.host.kind === "stale") {
45
+ return "stale";
56
46
  }
57
- catch {
58
- return false;
47
+ if (inspection.host.kind === "stopped") {
48
+ return "stopped";
59
49
  }
60
- }
61
- async function cleanupOrphanedRuntimeProject(options) {
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
- deleteMachineProjectLocalState({
69
- tuttiHome: options.tuttiHome,
70
- projectId: options.projectId,
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
- for (const projectId of projectIdsFromTuttiHome(tuttiHome)) {
88
- const endpoint = readEndpointFile(tuttiHome, projectId);
89
- if (endpoint === null) {
90
- continue;
91
- }
92
- const binding = readBindingFile(tuttiHome, projectId);
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 [project, provider, launchStatus] = await Promise.allSettled([
120
- readHostLocalProject({ endpoint, ...fetchOption }),
121
- readHostLocalProviderConfig({ endpoint, ...fetchOption }),
122
- readHostLocalLaunchStatus({ endpoint, ...fetchOption }),
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: projectId,
126
- status: health.readiness?.relay === "connected" ? "online" : "offline",
127
- display_name: project.status === "fulfilled" ? project.value.display_name : displayName,
128
- workspace_root: workspaceRoot,
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.status === "fulfilled") {
132
- row.provider_status = provider.value.status;
94
+ if (provider !== undefined) {
95
+ row.provider_status = provider.status;
133
96
  }
134
- if (launchStatus.status === "fulfilled" && launchStatus.value.relay?.relay_project_ref !== undefined) {
135
- row.relay_project_ref = launchStatus.value.relay.relay_project_ref;
97
+ if (launchStatus?.relay?.relay_project_ref !== undefined) {
98
+ row.relay_project_ref = launchStatus.relay.relay_project_ref;
136
99
  }
137
- if (launchStatus.status === "fulfilled" && launchStatus.value.relay?.join_url !== undefined) {
138
- row.join_url = launchStatus.value.relay.join_url;
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.workspace_root);
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.workspace_root, 40);
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.workspace_root);
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
- if (!project.workspace_exists) {
321
- deleteMachineProjectLocalState({
322
- tuttiHome: project.tutti_home,
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
- deleteMachineProjectLocalState({
340
- tuttiHome: project.tutti_home,
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
- return `Stopped ${project.display_name}.`;
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) : "missing"}`);
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