@xfey/tutti 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/collaboration-state/index.d.ts +1 -1
  3. package/dist/collaboration-state/index.js +1 -1
  4. package/dist/collaboration-state/messages.d.ts +1 -0
  5. package/dist/collaboration-state/messages.js +12 -0
  6. package/dist/control-plane/index.d.ts +0 -1
  7. package/dist/control-plane/index.js +16 -49
  8. package/dist/control-plane/task-compile-output.js +2 -54
  9. package/dist/control-plane/worklist-terminal-feedback.d.ts +9 -1
  10. package/dist/control-plane/worklist-terminal-feedback.js +99 -1
  11. package/dist/server-shell/cli/args.d.ts +9 -0
  12. package/dist/server-shell/cli/args.js +55 -5
  13. package/dist/server-shell/cli/cli.js +46 -7
  14. package/dist/server-shell/cli/errors.d.ts +1 -1
  15. package/dist/server-shell/cli/errors.js +6 -1
  16. package/dist/server-shell/cli/host-lifecycle.d.ts +1 -0
  17. package/dist/server-shell/cli/host-lifecycle.js +155 -41
  18. package/dist/server-shell/cli/host-relay-connection-manager.d.ts +50 -0
  19. package/dist/server-shell/cli/host-relay-connection-manager.js +447 -0
  20. package/dist/server-shell/cli/host-relay-status.d.ts +4 -0
  21. package/dist/server-shell/cli/host-relay-status.js +74 -15
  22. package/dist/server-shell/cli/host-runtime-endpoint.js +26 -0
  23. package/dist/server-shell/cli/host-server-runtime.d.ts +2 -2
  24. package/dist/server-shell/cli/host-server-runtime.js +19 -15
  25. package/dist/server-shell/cli/launch-command.d.ts +1 -0
  26. package/dist/server-shell/cli/launch-command.js +8 -10
  27. package/dist/server-shell/cli/launch.d.ts +1 -0
  28. package/dist/server-shell/cli/launch.js +7 -3
  29. package/dist/server-shell/cli/local-control-client.d.ts +5 -0
  30. package/dist/server-shell/cli/local-control-client.js +9 -0
  31. package/dist/server-shell/cli/machine-local.d.ts +8 -0
  32. package/dist/server-shell/cli/machine-local.js +29 -0
  33. package/dist/server-shell/cli/machine-project-inspector.d.ts +81 -0
  34. package/dist/server-shell/cli/machine-project-inspector.js +205 -0
  35. package/dist/server-shell/cli/project-resolver.js +22 -32
  36. package/dist/server-shell/cli/relay-registration.d.ts +5 -2
  37. package/dist/server-shell/cli/relay-registration.js +7 -3
  38. package/dist/server-shell/cli/runtime-commands.d.ts +30 -4
  39. package/dist/server-shell/cli/runtime-commands.js +230 -113
  40. package/dist/server-shell/http/routes/local-control.d.ts +10 -0
  41. package/dist/server-shell/http/routes/local-control.js +29 -0
  42. package/migrations/README.md +1 -1
  43. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +23 -0
  44. package/node_modules/@tutti/relay-client/dist/host-control.js +70 -3
  45. package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +6 -2
  46. package/node_modules/@tutti/relay-client/dist/tunnel.js +13 -2
  47. package/node_modules/@tutti/shared/dist/schemas/api/clarifications.d.ts +21 -0
  48. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
  49. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  50. package/node_modules/@tutti/shared/dist/schemas/api/messages.d.ts +105 -0
  51. package/node_modules/@tutti/shared/dist/schemas/api/messages.js +16 -0
  52. package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +2 -1
  53. package/node_modules/@tutti/shared/dist/schemas/api/viewer-reference.d.ts +21 -0
  54. package/package.json +1 -1
  55. package/prompts/prompt-flow-map.md +1 -1
  56. package/prompts/runs/README.md +1 -1
  57. package/prompts/runs/task-continuation.md +1 -1
  58. package/prompts/runs/task-retry.md +1 -1
  59. package/prompts/runs/task-run.md +1 -1
  60. package/web/assets/index-BHRlhFwi.css +1 -0
  61. package/web/assets/index-DPU2uqZl.js +29 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CeGTRkQn.css +0 -1
  64. package/web/assets/index-DGnDFv9O.js +0 -29
@@ -4,15 +4,19 @@ import { createHostServer } from "../http/create-server.js";
4
4
  import { createHostProjectApiTunnelRequestHandler, createHostProjectApiTunnelStreamRequestHandler, } from "../http/host-tunnel.js";
5
5
  import { LaunchError } from "./errors.js";
6
6
  import { TUTTI_MAINLINE_BRANCH } from "./git-bootstrap.js";
7
- import { relayStateFromLaunchStatus, relayStatusFromLocalStatus } from "./host-relay-status.js";
7
+ import { hostLocalLaunchStatusFromConnectionManagerState, relayStateFromConnectionManagerState, relayStatusFromLocalStatus, } from "./host-relay-status.js";
8
+ import { createHostRelayConnectionManager, } from "./host-relay-connection-manager.js";
8
9
  import { createRuntimeEndpointProbe, readExistingHostLaunchStatus, requestExistingHostShutdown, } from "./host-runtime-endpoint.js";
10
+ import { reconnectHostLocalRelay } from "./local-control-client.js";
9
11
  import { HOST_SERVER_VERSION, startForegroundHostServer, } from "./host-server-runtime.js";
10
12
  import { deleteMachineRuntimeEndpoint, readHostRegistrationSecret, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
13
+ import { inspectMachineProject, inspectMachineProjectInventories, normalizeMachineWorkspacePath, } from "./machine-project-inspector.js";
11
14
  import { prepareLaunchProject, resolveLaunchLocalContext, } from "./launch.js";
12
15
  import { registerHostWithRelay, relayErrorToLaunchError, } from "./relay-registration.js";
13
16
  import { formatJoinLinkValidity, formatTerminalLink } from "./terminal-qr.js";
14
17
  export { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
15
18
  const DEFAULT_SHUTDOWN_WAIT_MS = 2_000;
19
+ const DEFAULT_RECONNECT_WAIT_MS = 8_000;
16
20
  function urlLine(label, value, options) {
17
21
  return `${label}: ${formatTerminalLink(value, options)}`;
18
22
  }
@@ -72,21 +76,106 @@ function createSummary(options) {
72
76
  }
73
77
  return summary;
74
78
  }
79
+ async function reconcileReplacedWorkspaceHosts(options) {
80
+ const normalizedWorkspaceRoot = normalizeMachineWorkspacePath(options.workspaceRoot);
81
+ const replacedProjects = inspectMachineProjectInventories(options.tuttiHome).filter((inspection) => inspection.project_id !== options.projectId &&
82
+ inspection.normalized_workspace_root === normalizedWorkspaceRoot &&
83
+ inspection.workspace.kind === "replaced" &&
84
+ inspection.workspace.actual_project_id === options.projectId &&
85
+ inspection.endpoint.kind === "valid" &&
86
+ normalizeMachineWorkspacePath(inspection.endpoint.record.workspace_root) ===
87
+ normalizedWorkspaceRoot);
88
+ for (const replacedProject of replacedProjects) {
89
+ const inspection = await inspectMachineProject(replacedProject.project_id, {
90
+ tuttiHome: options.tuttiHome,
91
+ fetchImpl: options.fetchImpl,
92
+ probeRuntimeEndpoint: options.probeRuntimeEndpoint,
93
+ });
94
+ if (inspection.endpoint.kind !== "valid") {
95
+ continue;
96
+ }
97
+ if ((inspection.host.kind === "running" && !inspection.host.identity_verified) ||
98
+ (inspection.host.kind === "stale" &&
99
+ inspection.host.reason_code === "local_control_unavailable")) {
100
+ throw new LaunchError("takeover_failed", "The replaced project host identity could not be verified before shutdown", "Stop the old host manually, then run `tutti launch` again.");
101
+ }
102
+ const shouldRequestShutdown = inspection.host.kind === "running";
103
+ if (shouldRequestShutdown) {
104
+ await requestExistingHostShutdown({
105
+ endpoint: inspection.endpoint.record,
106
+ fetchImpl: options.fetchImpl,
107
+ probeRuntimeEndpoint: options.probeRuntimeEndpoint,
108
+ shutdownWaitMs: options.shutdownWaitMs,
109
+ });
110
+ }
111
+ if (shouldRequestShutdown ||
112
+ inspection.host.kind === "stale" ||
113
+ inspection.host.kind === "identity_mismatch") {
114
+ deleteMachineRuntimeEndpoint({
115
+ tuttiHome: options.tuttiHome,
116
+ projectId: inspection.project_id,
117
+ expectedToken: inspection.endpoint.record.token,
118
+ });
119
+ }
120
+ }
121
+ }
75
122
  export async function startLaunchProject(options) {
76
123
  const context = resolveLaunchLocalContext(options);
77
124
  const fetchImpl = options.fetch ?? fetch;
78
125
  const probeRuntimeEndpoint = options.probeRuntimeEndpoint ?? createRuntimeEndpointProbe(fetchImpl);
79
126
  const shutdownWaitMs = options.shutdownWaitMs ?? DEFAULT_SHUTDOWN_WAIT_MS;
127
+ const reconnectWaitMs = options.reconnectWaitMs ?? DEFAULT_RECONNECT_WAIT_MS;
80
128
  let startupStatus = context.existing_identity.kind === "missing" ? "created" : "recovered";
81
129
  let allowWorkspaceRootTakeover = false;
82
130
  if (context.existing_identity.kind === "present") {
83
131
  const projectId = context.existing_identity.project_id;
132
+ await reconcileReplacedWorkspaceHosts({
133
+ tuttiHome: context.tutti_home,
134
+ workspaceRoot: context.workspace_root,
135
+ projectId,
136
+ fetchImpl,
137
+ probeRuntimeEndpoint,
138
+ shutdownWaitMs,
139
+ });
84
140
  const endpoint = readMachineRuntimeEndpoint(context.tutti_home, projectId);
85
141
  if (endpoint !== null) {
86
142
  const sameWorkspace = resolve(endpoint.workspace_root) === context.workspace_root;
87
143
  const probe = await probeRuntimeEndpoint(endpoint);
88
144
  if (probe.kind === "alive" && sameWorkspace) {
89
- const launchStatus = await readExistingHostLaunchStatus({ endpoint, fetchImpl });
145
+ let launchStatus = await readExistingHostLaunchStatus({ endpoint, fetchImpl });
146
+ if (launchStatus.project_id !== projectId) {
147
+ throw new LaunchError("existing_server_unhealthy", "The existing Tutti host identity could not be verified for this project", "Stop the existing host manually, then run `tutti launch` again.");
148
+ }
149
+ const relayConnected = launchStatus.relay_connection?.status === "connected" ||
150
+ (launchStatus.relay_connection === undefined && probe.readiness?.relay === "connected");
151
+ if (!relayConnected) {
152
+ try {
153
+ launchStatus = await reconnectHostLocalRelay({
154
+ endpoint,
155
+ fetchImpl,
156
+ timeoutMs: reconnectWaitMs,
157
+ });
158
+ }
159
+ catch {
160
+ throw new LaunchError("relay_unavailable", "The existing Tutti host could not complete a bounded Relay reconnect", "The host is still running. Check Relay connectivity, then run `tutti launch` again.");
161
+ }
162
+ if (launchStatus.project_id !== projectId) {
163
+ throw new LaunchError("existing_server_unhealthy", "The reconnected Tutti host identity does not match this project", "The existing host was preserved. Stop it manually, then run `tutti launch` again.");
164
+ }
165
+ const reconnectedProbe = await probeRuntimeEndpoint(endpoint);
166
+ const reconnected = launchStatus.relay_connection?.status === "connected" ||
167
+ (launchStatus.relay_connection === undefined &&
168
+ reconnectedProbe.kind === "alive" &&
169
+ reconnectedProbe.readiness?.relay === "connected");
170
+ if (!reconnected) {
171
+ throw new LaunchError("relay_unavailable", "The existing Tutti host is running but Relay reconnection is not complete", "The host was preserved. Check `tutti ps` or `tutti doctor`, then retry launch.", {
172
+ relay_status: launchStatus.relay_connection?.status ?? "not_connected",
173
+ ...(launchStatus.relay_connection?.reason_code === undefined
174
+ ? {}
175
+ : { reason_code: launchStatus.relay_connection.reason_code }),
176
+ });
177
+ }
178
+ }
90
179
  const relay = relayStatusFromLocalStatus(launchStatus);
91
180
  return {
92
181
  kind: "connected",
@@ -154,58 +243,55 @@ export async function startLaunchProject(options) {
154
243
  ...options,
155
244
  allowWorkspaceRootTakeover,
156
245
  });
157
- const launchStatus = {};
158
- const refreshRelayJoinUrl = async () => {
159
- if (launchStatus.relay === undefined) {
160
- return relayStateFromLaunchStatus(preparation.relay_url, launchStatus);
161
- }
162
- const hostSecret = readHostRegistrationSecret(preparation.tutti_home, preparation.project_id);
163
- const response = await refreshRelayJoinTokenWithRelay({
164
- relayUrl: preparation.relay_url,
246
+ if (context.existing_identity.kind === "missing") {
247
+ await reconcileReplacedWorkspaceHosts({
248
+ tuttiHome: preparation.tutti_home,
249
+ workspaceRoot: preparation.workspace_root,
165
250
  projectId: preparation.project_id,
166
- relayProjectRef: launchStatus.relay.relay_project_ref,
167
- hostConnectionRef: launchStatus.relay.host_connection_ref,
168
- hostRegistrationSecret: hostSecret.secret,
169
- joinToken: { mode: "rotate" },
170
- fetch: fetchImpl,
251
+ fetchImpl,
252
+ probeRuntimeEndpoint,
253
+ shutdownWaitMs,
171
254
  });
172
- const relayWithoutJoinUrl = { ...launchStatus.relay };
173
- delete relayWithoutJoinUrl.join_url;
174
- launchStatus.relay = {
175
- ...relayWithoutJoinUrl,
176
- ...(response.join_token.join_url === undefined
177
- ? {}
178
- : { join_url: response.join_token.join_url }),
179
- join_url_visibility: response.join_token.visibility,
180
- join_token_expires_at: response.join_token.expires_at,
181
- join_token_reusable: response.join_token.reusable,
182
- };
183
- return relayStateFromLaunchStatus(preparation.relay_url, launchStatus);
255
+ }
256
+ const relayRuntime = {};
257
+ const readConnectionState = () => relayRuntime.connectionManager?.getState() ?? {
258
+ status: "unavailable",
259
+ reason_code: "not_started",
260
+ };
261
+ const getLaunchStatus = () => hostLocalLaunchStatusFromConnectionManagerState(preparation.project_id, readConnectionState());
262
+ const refreshRelayJoinUrl = async () => {
263
+ if (relayRuntime.connectionManager === undefined) {
264
+ return relayStateFromConnectionManagerState(preparation.relay_url, readConnectionState());
265
+ }
266
+ await relayRuntime.connectionManager.refreshJoinUrl();
267
+ return relayStateFromConnectionManagerState(preparation.relay_url, relayRuntime.connectionManager.getState());
184
268
  };
185
269
  const host = await startForegroundHostServer({
186
270
  preparation,
187
271
  endpointHost: context.local_diagnostic_endpoint.host,
188
272
  endpointPort: context.local_diagnostic_endpoint.port,
189
273
  createServer: options.createServer ?? createHostServer,
190
- getLaunchStatus: () => launchStatus,
274
+ getLaunchStatus,
191
275
  refreshRelayJoinUrl,
192
276
  ...(options.now === undefined ? {} : { now: options.now }),
193
277
  });
194
- let relay;
195
- try {
196
- const registered = await registerHostWithRelay({
278
+ const connectionManager = createHostRelayConnectionManager({
279
+ tuttiHome: preparation.tutti_home,
280
+ projectId: preparation.project_id,
281
+ workspaceRoot: preparation.workspace_root,
282
+ registerConnection: async ({ joinTokenMode, signal }) => await registerHostWithRelay({
197
283
  preparation,
198
284
  fetchImpl,
199
285
  registerRelayHostConnection: options.registerRelayHostConnection ?? registerRelayHostConnectionWithRelay,
200
286
  serverVersion: HOST_SERVER_VERSION,
287
+ joinTokenMode,
288
+ signal,
201
289
  ...(options.now === undefined ? {} : { now: options.now }),
202
- });
203
- relay = registered.summary;
204
- launchStatus.relay = relay;
205
- const tunnel = (options.connectRelayHostTunnel ?? connectRelayHostTunnel)({
206
- tunnelUrl: relay.tunnel_url,
207
- relayProjectRef: relay.relay_project_ref,
208
- hostConnectionRef: relay.host_connection_ref,
290
+ }),
291
+ connectTunnel: (registered) => (options.connectRelayHostTunnel ?? connectRelayHostTunnel)({
292
+ tunnelUrl: registered.summary.tunnel_url,
293
+ relayProjectRef: registered.summary.relay_project_ref,
294
+ hostConnectionRef: registered.summary.host_connection_ref,
209
295
  hostConnectionToken: registered.hostConnectionToken,
210
296
  handleRequest: createHostProjectApiTunnelRequestHandler({
211
297
  app: host.app,
@@ -215,9 +301,37 @@ export async function startLaunchProject(options) {
215
301
  baseUrl: host.runtime_endpoint.base_url,
216
302
  trustedMetadataStore: host.trusted_relay_metadata_store,
217
303
  }),
218
- });
219
- await tunnel.connected;
220
- host.attachRelayTunnel(tunnel);
304
+ }),
305
+ refreshJoinUrl: async (current) => {
306
+ const hostSecret = readHostRegistrationSecret(preparation.tutti_home, preparation.project_id);
307
+ const response = await refreshRelayJoinTokenWithRelay({
308
+ relayUrl: preparation.relay_url,
309
+ projectId: preparation.project_id,
310
+ relayProjectRef: current.relay_project_ref,
311
+ hostConnectionRef: current.host_connection_ref,
312
+ hostRegistrationSecret: hostSecret.secret,
313
+ joinToken: { mode: "rotate" },
314
+ fetch: fetchImpl,
315
+ });
316
+ const withoutJoinUrl = { ...current };
317
+ delete withoutJoinUrl.join_url;
318
+ return {
319
+ ...withoutJoinUrl,
320
+ ...(response.join_token.join_url === undefined
321
+ ? {}
322
+ : { join_url: response.join_token.join_url }),
323
+ join_url_visibility: response.join_token.visibility,
324
+ join_token_expires_at: response.join_token.expires_at,
325
+ join_token_reusable: response.join_token.reusable,
326
+ };
327
+ },
328
+ ...(options.now === undefined ? {} : { now: options.now }),
329
+ });
330
+ relayRuntime.connectionManager = connectionManager;
331
+ host.attachRelayConnectionManager(connectionManager);
332
+ let relay;
333
+ try {
334
+ relay = await connectionManager.start();
221
335
  }
222
336
  catch (error) {
223
337
  const launchError = error instanceof LaunchError || error instanceof RelayHostControlClientError
@@ -0,0 +1,50 @@
1
+ import { type RelayHostTunnelHandle } from "@tutti/relay-client";
2
+ import type { ProjectId } from "@tutti/shared/ids";
3
+ import type { HostLaunchRelaySummary, RegisteredRelayHostConnection } from "./relay-registration.js";
4
+ export type HostRelayConnectionManagerState = {
5
+ status: "unavailable";
6
+ reason_code: "not_started" | "relay_unavailable" | "workspace_missing" | "workspace_replaced" | "workspace_unreadable" | "fatal_error";
7
+ error_code?: string;
8
+ next_retry_at?: string;
9
+ last_connection?: HostLaunchRelaySummary;
10
+ } | {
11
+ status: "connecting";
12
+ attempt: 0;
13
+ } | {
14
+ status: "connected";
15
+ connection: HostLaunchRelaySummary;
16
+ } | {
17
+ status: "reconnecting";
18
+ attempt: number;
19
+ next_retry_at?: string;
20
+ last_connected_at?: string;
21
+ last_connection?: HostLaunchRelaySummary;
22
+ } | {
23
+ status: "stopped";
24
+ };
25
+ export type HostRelayConnectionManager = {
26
+ start: () => Promise<HostLaunchRelaySummary>;
27
+ reconnectNow: () => Promise<HostRelayConnectionManagerState>;
28
+ refreshJoinUrl: () => Promise<HostLaunchRelaySummary>;
29
+ getState: () => HostRelayConnectionManagerState;
30
+ stop: () => Promise<void>;
31
+ };
32
+ export type CreateHostRelayConnectionManagerOptions = {
33
+ tuttiHome: string;
34
+ projectId: ProjectId;
35
+ workspaceRoot: string;
36
+ registerConnection: (options: {
37
+ joinTokenMode: "reuse_active" | "rotate";
38
+ signal: AbortSignal;
39
+ }) => Promise<RegisteredRelayHostConnection>;
40
+ connectTunnel: (registered: RegisteredRelayHostConnection) => RelayHostTunnelHandle;
41
+ refreshJoinUrl?: (connection: HostLaunchRelaySummary) => Promise<HostLaunchRelaySummary>;
42
+ onStateChange?: (state: HostRelayConnectionManagerState) => void;
43
+ retryDelaysMs?: readonly number[];
44
+ jitterRatio?: number;
45
+ workspaceCheckIntervalMs?: number;
46
+ now?: () => Date;
47
+ random?: () => number;
48
+ };
49
+ export declare function createHostRelayConnectionManager(options: CreateHostRelayConnectionManagerOptions): HostRelayConnectionManager;
50
+ //# sourceMappingURL=host-relay-connection-manager.d.ts.map