@xfey/tutti 0.1.34 → 0.1.36

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.
@@ -19,6 +19,11 @@ export declare function requestExistingHostShutdown(options: {
19
19
  probeRuntimeEndpoint: HostRuntimeEndpointProbe;
20
20
  shutdownWaitMs: number;
21
21
  }): Promise<void>;
22
+ export declare function waitForHostShutdown(options: {
23
+ endpoint: MachineRuntimeEndpointRecord;
24
+ probeRuntimeEndpoint: HostRuntimeEndpointProbe;
25
+ shutdownWaitMs: number;
26
+ }): Promise<void>;
22
27
  export declare function readExistingHostLaunchStatus(options: {
23
28
  endpoint: MachineRuntimeEndpointRecord;
24
29
  fetchImpl: FetchLike;
@@ -85,6 +85,9 @@ export async function requestExistingHostShutdown(options) {
85
85
  if (!response.ok) {
86
86
  throw new LaunchError("takeover_failed", `The existing host server rejected takeover shutdown with HTTP ${response.status}`, "Stop the existing host server manually, then run `tutti launch` again.");
87
87
  }
88
+ await waitForHostShutdown(options);
89
+ }
90
+ export async function waitForHostShutdown(options) {
88
91
  const startedAt = Date.now();
89
92
  while (Date.now() - startedAt < options.shutdownWaitMs) {
90
93
  const probe = await options.probeRuntimeEndpoint(options.endpoint);
@@ -7,7 +7,7 @@ import { type TrustedRelaySessionMetadataStore } from "../session/relay-session-
7
7
  import type { HostRelayConnectionManager } from "./host-relay-connection-manager.js";
8
8
  import type { LaunchPreparationResult } from "./launch.js";
9
9
  import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
10
- export declare const HOST_SERVER_VERSION = "0.0.0";
10
+ export declare const HOST_SERVER_VERSION: string;
11
11
  export type HostServerFactory = typeof createHostServer;
12
12
  export type HostServerHandle = {
13
13
  app: FastifyInstance;
@@ -18,10 +18,11 @@ import { readHostRepoProjection } from "../http/routes/project-api.js";
18
18
  import { WorkspaceEventBus } from "../http/workspace-events.js";
19
19
  import { createTrustedRelaySessionMetadataStore, } from "../session/relay-session-context.js";
20
20
  import { LaunchError } from "./errors.js";
21
+ import { readCliVersion } from "./version.js";
21
22
  import { relayStateFromLaunchStatus } from "./host-relay-status.js";
22
23
  import { appendHostLogLine, readHostRegistrationSecret, createMachineRuntimeEndpointToken, deleteMachineRuntimeEndpoint, ensureHostLogFile, getHostLogFilePath, updateMachineProjectDisplayNameSnapshot, writeMachineRuntimeEndpoint, } from "./machine-local.js";
23
24
  import { readProjectDescription, readProjectDisplayName, writeProjectDescription, writeProjectDisplayName, } from "./project-identity.js";
24
- export const HOST_SERVER_VERSION = "0.0.0";
25
+ export const HOST_SERVER_VERSION = readCliVersion();
25
26
  function createHostRuntimeLogger(options) {
26
27
  return {
27
28
  info(fields, message) {
@@ -262,6 +263,11 @@ export async function startForegroundHostServer(options) {
262
263
  let relayConnectionManager;
263
264
  const localControl = {
264
265
  token,
266
+ requestIdleShutdown: () => {
267
+ const execution = controlPlane.getExecutionStatus();
268
+ const hasActiveActivities = execution.active_activity !== undefined || (execution.active_activities?.length ?? 0) > 0;
269
+ return hasActiveActivities ? "busy" : "accepted";
270
+ },
265
271
  requestShutdown: () => {
266
272
  void close();
267
273
  },
@@ -68,6 +68,7 @@ export async function prepareLaunchProject(options) {
68
68
  tuttiHome,
69
69
  projectId,
70
70
  workspaceRoot,
71
+ relayUrl,
71
72
  displayNameSnapshot: readProjectDisplayName(workspaceRoot) ?? (basename(workspaceRoot) || projectId),
72
73
  };
73
74
  if (options.allowWorkspaceRootTakeover !== undefined) {
@@ -1,4 +1,4 @@
1
- import type { HostLocalLaunchStatus, HostLocalProjectProjection, HostLocalProviderConfigBody } from "../http/routes/local-control.js";
1
+ import type { HostLocalIdleShutdownDisposition, HostLocalLaunchStatus, HostLocalProjectProjection, HostLocalProviderConfigBody } from "../http/routes/local-control.js";
2
2
  import type { HostProviderConfigProjection } from "../http/routes/project-api/types.js";
3
3
  import type { MachineRuntimeEndpointRecord } from "./machine-local.js";
4
4
  export type LocalControlFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
@@ -32,4 +32,8 @@ export declare function requestHostLocalShutdown(options: {
32
32
  endpoint: MachineRuntimeEndpointRecord;
33
33
  fetchImpl?: LocalControlFetch;
34
34
  }): Promise<void>;
35
+ export declare function requestHostLocalIdleShutdown(options: {
36
+ endpoint: MachineRuntimeEndpointRecord;
37
+ fetchImpl?: LocalControlFetch;
38
+ }): Promise<HostLocalIdleShutdownDisposition>;
35
39
  //# sourceMappingURL=local-control-client.d.ts.map
@@ -79,4 +79,26 @@ export async function requestHostLocalShutdown(options) {
79
79
  path: "/host-local/v1/shutdown",
80
80
  });
81
81
  }
82
+ export async function requestHostLocalIdleShutdown(options) {
83
+ const response = await fetchWithTimeout(options.fetchImpl ?? fetch, new URL("/host-local/v1/shutdown-if-idle", options.endpoint.base_url), {
84
+ method: "POST",
85
+ headers: {
86
+ accept: "application/json",
87
+ authorization: `Bearer ${options.endpoint.token}`,
88
+ },
89
+ });
90
+ if (response.status === 404) {
91
+ return "unsupported";
92
+ }
93
+ if (!response.ok) {
94
+ throw new Error(`host-local shutdown-if-idle returned HTTP ${response.status}`);
95
+ }
96
+ const body = (await response.json());
97
+ if (body.disposition !== "accepted" &&
98
+ body.disposition !== "busy" &&
99
+ body.disposition !== "unsupported") {
100
+ throw new Error("host-local shutdown-if-idle returned an invalid response");
101
+ }
102
+ return body.disposition;
103
+ }
82
104
  //# sourceMappingURL=local-control-client.js.map
@@ -5,7 +5,9 @@ export type MachineProjectBindingRecord = {
5
5
  local_store_root: string;
6
6
  display_name_snapshot?: string;
7
7
  relay_project_ref?: RelayProjectRef;
8
+ relay_url?: string;
8
9
  host_registration_secret_ref?: string;
10
+ created_at?: string;
9
11
  updated_at: string;
10
12
  };
11
13
  export type HostRegistrationSecretFile = {
@@ -28,6 +30,7 @@ export type EnsureMachineProjectBindingOptions = {
28
30
  projectId: ProjectId;
29
31
  workspaceRoot: string;
30
32
  displayNameSnapshot?: string;
33
+ relayUrl?: string;
31
34
  allowWorkspaceRootTakeover?: boolean;
32
35
  now?: () => Date;
33
36
  };
@@ -78,7 +78,10 @@ export function readMachineProjectBinding(tuttiHome, projectId) {
78
78
  (typeof raw.relay_project_ref !== "string" ||
79
79
  !isPrefixedId(raw.relay_project_ref, ID_PREFIXES.relayProject))) ||
80
80
  (raw.host_registration_secret_ref !== undefined &&
81
- typeof raw.host_registration_secret_ref !== "string")) {
81
+ typeof raw.host_registration_secret_ref !== "string") ||
82
+ (raw.relay_url !== undefined &&
83
+ (typeof raw.relay_url !== "string" || raw.relay_url.trim() === "")) ||
84
+ (raw.created_at !== undefined && typeof raw.created_at !== "string")) {
82
85
  throw new LaunchError("project_identity_conflict", "Machine-local project binding is invalid or conflicts with the Git config project identity", "Inspect or remove the project binding under TUTTI_HOME after confirming it is not needed.");
83
86
  }
84
87
  const binding = {
@@ -93,9 +96,15 @@ export function readMachineProjectBinding(tuttiHome, projectId) {
93
96
  if (raw.relay_project_ref !== undefined) {
94
97
  binding.relay_project_ref = raw.relay_project_ref;
95
98
  }
99
+ if (raw.relay_url !== undefined) {
100
+ binding.relay_url = raw.relay_url;
101
+ }
96
102
  if (raw.host_registration_secret_ref !== undefined) {
97
103
  binding.host_registration_secret_ref = raw.host_registration_secret_ref;
98
104
  }
105
+ if (raw.created_at !== undefined) {
106
+ binding.created_at = raw.created_at;
107
+ }
99
108
  return binding;
100
109
  }
101
110
  export function readMachineRuntimeEndpoint(tuttiHome, projectId) {
@@ -226,7 +235,11 @@ function ensureHostRegistrationSecret(tuttiHome, projectId, now) {
226
235
  const secretPath = getHostRegistrationSecretPath(tuttiHome, projectId);
227
236
  const ref = createHostRegistrationSecretRef(projectId);
228
237
  if (existsSync(secretPath)) {
229
- return { ref, created: false };
238
+ return {
239
+ ref,
240
+ created: false,
241
+ createdAt: readHostRegistrationSecret(tuttiHome, projectId).created_at,
242
+ };
230
243
  }
231
244
  const timestamp = now().toISOString();
232
245
  const secretFile = {
@@ -237,7 +250,7 @@ function ensureHostRegistrationSecret(tuttiHome, projectId, now) {
237
250
  updated_at: timestamp,
238
251
  };
239
252
  writePrivateJsonFile(secretPath, secretFile);
240
- return { ref, created: true };
253
+ return { ref, created: true, createdAt: timestamp };
241
254
  }
242
255
  export function ensureMachineProjectBinding(options) {
243
256
  const now = options.now ?? (() => new Date());
@@ -256,6 +269,7 @@ export function ensureMachineProjectBinding(options) {
256
269
  workspace_root: workspaceRoot,
257
270
  local_store_root: localStoreRoot,
258
271
  host_registration_secret_ref: existing?.host_registration_secret_ref ?? secret.ref,
272
+ created_at: existing?.created_at ?? secret.createdAt,
259
273
  updated_at: now().toISOString(),
260
274
  };
261
275
  const displayNameSnapshot = options.displayNameSnapshot?.trim();
@@ -268,6 +282,13 @@ export function ensureMachineProjectBinding(options) {
268
282
  if (existing?.relay_project_ref !== undefined) {
269
283
  binding.relay_project_ref = existing.relay_project_ref;
270
284
  }
285
+ const relayUrl = options.relayUrl?.trim();
286
+ if (relayUrl !== undefined && relayUrl !== "") {
287
+ binding.relay_url = relayUrl;
288
+ }
289
+ else if (existing?.relay_url !== undefined) {
290
+ binding.relay_url = existing.relay_url;
291
+ }
271
292
  writePrivateJsonFile(bindingPath, binding);
272
293
  return {
273
294
  binding,
@@ -17,6 +17,7 @@ export declare function waitForManagedHostReady(options: {
17
17
  projectId: ProjectId;
18
18
  workspaceRoot: string;
19
19
  fetchImpl?: FetchLike;
20
+ inviteMode?: "required" | "preserve";
20
21
  timeoutMs?: number;
21
22
  }): Promise<ManagedHostReadyResult>;
22
23
  //# sourceMappingURL=managed-host.d.ts.map
@@ -66,7 +66,7 @@ export async function waitForManagedHostReady(options) {
66
66
  endpoint,
67
67
  ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
68
68
  });
69
- if (status.relay?.join_url === undefined) {
69
+ if (status.relay?.join_url === undefined && options.inviteMode !== "preserve") {
70
70
  status = await rotateHostLocalInvite({
71
71
  endpoint,
72
72
  ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
@@ -84,6 +84,9 @@ export async function waitForManagedHostReady(options) {
84
84
  : { join_token_reusable: status.relay.join_token_reusable }),
85
85
  };
86
86
  }
87
+ if (options.inviteMode === "preserve" && status.relay !== undefined) {
88
+ return { endpoint };
89
+ }
87
90
  lastReason = "host is running but Relay invite is not visible yet";
88
91
  }
89
92
  catch (error) {
@@ -1,4 +1,4 @@
1
- import type { ProjectId } from "@tutti/shared/ids";
1
+ import type { ProjectId, RelayProjectRef } from "@tutti/shared/ids";
2
2
  import { type FetchLike } from "./host-runtime-endpoint.js";
3
3
  import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
4
4
  import type { LocalControlFetch } from "./local-control-client.js";
@@ -9,7 +9,7 @@ export type RuntimeProjectRow = {
9
9
  workspace_root: string;
10
10
  endpoint?: string;
11
11
  provider_status?: string;
12
- relay_project_ref?: string;
12
+ relay_project_ref?: RelayProjectRef;
13
13
  join_url?: string;
14
14
  relay_connection_status?: string;
15
15
  last_connected_at?: string;
@@ -101,6 +101,10 @@ export async function listRuntimeProjects(options = {}) {
101
101
  if (launchStatus?.relay?.relay_project_ref !== undefined) {
102
102
  row.relay_project_ref = launchStatus.relay.relay_project_ref;
103
103
  }
104
+ else if (inspection.binding.kind === "valid" &&
105
+ inspection.binding.record.relay_project_ref !== undefined) {
106
+ row.relay_project_ref = inspection.binding.record.relay_project_ref;
107
+ }
104
108
  if (launchStatus?.relay?.join_url !== undefined) {
105
109
  row.join_url = launchStatus.relay.join_url;
106
110
  }
@@ -14,8 +14,10 @@ export type HostLocalControlOptions = {
14
14
  get: () => ProviderConfigProjection;
15
15
  configure: (input: HostLocalProviderConfigBody) => Promise<ProviderConfigProjection>;
16
16
  };
17
+ requestIdleShutdown?: () => HostLocalIdleShutdownDisposition;
17
18
  requestShutdown: () => void;
18
19
  };
20
+ export type HostLocalIdleShutdownDisposition = "accepted" | "busy" | "unsupported";
19
21
  export type HostLocalLaunchStatus = {
20
22
  project_id?: ProjectId;
21
23
  relay_connection?: {
@@ -31,6 +31,14 @@ class HostLocalProjectValidationError extends Error {
31
31
  this.name = "HostLocalProjectValidationError";
32
32
  }
33
33
  }
34
+ class HostLocalDrainingError extends Error {
35
+ statusCode = 409;
36
+ code = "conflict";
37
+ constructor() {
38
+ super("Host is draining for a safe restart");
39
+ this.name = "HostLocalDrainingError";
40
+ }
41
+ }
34
42
  const HostLocalShutdownResponseSchema = {
35
43
  type: "object",
36
44
  required: ["ok"],
@@ -39,6 +47,14 @@ const HostLocalShutdownResponseSchema = {
39
47
  ok: { type: "boolean", enum: [true] },
40
48
  },
41
49
  };
50
+ const HostLocalIdleShutdownResponseSchema = {
51
+ type: "object",
52
+ required: ["disposition"],
53
+ additionalProperties: false,
54
+ properties: {
55
+ disposition: { type: "string", enum: ["accepted", "busy", "unsupported"] },
56
+ },
57
+ };
42
58
  const HostLocalProviderConfigBodySchema = {
43
59
  type: "object",
44
60
  required: ["api_key"],
@@ -190,6 +206,46 @@ function normalizeProjectBody(input) {
190
206
  return { display_name: displayName };
191
207
  }
192
208
  export function registerHostLocalControlRoutes(app, options) {
209
+ const mutationTracked = Symbol("hostLocalMutationTracked");
210
+ const shutdownPath = "/host-local/v1/shutdown";
211
+ const idleShutdownPath = "/host-local/v1/shutdown-if-idle";
212
+ let activeMutations = 0;
213
+ let draining = false;
214
+ function releaseTrackedMutation(request) {
215
+ const trackedRequest = request;
216
+ if (trackedRequest[mutationTracked] !== true) {
217
+ return;
218
+ }
219
+ trackedRequest[mutationTracked] = false;
220
+ activeMutations = Math.max(0, activeMutations - 1);
221
+ }
222
+ app.addHook("onRequest", (request, _reply, done) => {
223
+ const path = request.url.split("?", 1)[0];
224
+ const mutation = request.method !== "GET" &&
225
+ request.method !== "HEAD" &&
226
+ request.method !== "OPTIONS" &&
227
+ path !== shutdownPath &&
228
+ path !== idleShutdownPath;
229
+ if (!mutation) {
230
+ done();
231
+ return;
232
+ }
233
+ if (draining) {
234
+ done(new HostLocalDrainingError());
235
+ return;
236
+ }
237
+ activeMutations += 1;
238
+ Object.assign(request, { [mutationTracked]: true });
239
+ done();
240
+ });
241
+ app.addHook("onResponse", (request, _reply, done) => {
242
+ releaseTrackedMutation(request);
243
+ done();
244
+ });
245
+ app.addHook("onRequestAbort", (request, done) => {
246
+ releaseTrackedMutation(request);
247
+ done();
248
+ });
193
249
  function requireLocalToken(authorization) {
194
250
  const token = readBearerToken(authorization);
195
251
  if (!tokenEquals(token, options.token)) {
@@ -286,7 +342,28 @@ export function registerHostLocalControlRoutes(app, options) {
286
342
  }
287
343
  return options.project.update(normalizeProjectBody(request.body));
288
344
  });
289
- app.post("/host-local/v1/shutdown", {
345
+ app.post(idleShutdownPath, {
346
+ schema: {
347
+ response: {
348
+ 200: HostLocalIdleShutdownResponseSchema,
349
+ },
350
+ },
351
+ }, (request) => {
352
+ requireLocalToken(request.headers.authorization);
353
+ if (draining) {
354
+ return { disposition: "accepted" };
355
+ }
356
+ if (activeMutations > 0) {
357
+ return { disposition: "busy" };
358
+ }
359
+ const disposition = options.requestIdleShutdown?.() ?? "unsupported";
360
+ if (disposition === "accepted") {
361
+ draining = true;
362
+ setImmediate(options.requestShutdown);
363
+ }
364
+ return { disposition };
365
+ });
366
+ app.post(shutdownPath, {
290
367
  schema: {
291
368
  response: {
292
369
  202: HostLocalShutdownResponseSchema,
@@ -1,4 +1,5 @@
1
- import type { ProjectId } from "@tutti/shared/ids";
1
+ import { type ProjectId, type RelayProjectRef } from "@tutti/shared/ids";
2
+ import { type FetchLike } from "../cli/host-runtime-endpoint.js";
2
3
  import { type RuntimeProjectRow } from "../cli/runtime-commands.js";
3
4
  import { type LocalConsoleInvocationContext } from "./invocation-context.js";
4
5
  export type LocalConsoleProject = {
@@ -7,9 +8,12 @@ export type LocalConsoleProject = {
7
8
  workspace_path: string;
8
9
  status: RuntimeProjectRow["status"];
9
10
  provider_status: "configured" | "not_configured" | "invalid";
10
- relay_project_ref?: string;
11
+ provider_model?: string;
12
+ relay_project_ref?: RelayProjectRef;
11
13
  join_url?: string;
14
+ open_url?: string;
12
15
  relay_connection_status?: string;
16
+ created_at?: string;
13
17
  activity_at?: string;
14
18
  host_version?: string;
15
19
  update_required?: boolean;
@@ -21,9 +25,15 @@ export type LocalConsoleProviderInput = {
21
25
  };
22
26
  export type LocalConsoleLaunchResult = {
23
27
  project: LocalConsoleProject;
28
+ project_created: boolean;
24
29
  join_url: string;
25
30
  join_token_expires_at?: string;
26
31
  };
32
+ export type LocalConsoleOpenResult = {
33
+ project: LocalConsoleProject;
34
+ open_url: string;
35
+ disposition: "opened_current" | "updated" | "deferred_busy" | "deferred_unsupported" | "deferred_unavailable";
36
+ };
27
37
  export declare class LocalConsoleProjectError extends Error {
28
38
  readonly code: string;
29
39
  readonly statusCode: number;
@@ -34,6 +44,7 @@ export declare class LocalConsoleProjectService {
34
44
  constructor(options: {
35
45
  tuttiHome: string;
36
46
  serviceEnvironment?: NodeJS.ProcessEnv;
47
+ fetchImpl?: FetchLike;
37
48
  });
38
49
  listProjects(context: LocalConsoleInvocationContext): Promise<LocalConsoleProject[]>;
39
50
  discoverModels(input: {
@@ -50,6 +61,7 @@ export declare class LocalConsoleProjectService {
50
61
  workspacePath: string;
51
62
  provider?: LocalConsoleProviderInput;
52
63
  }, context: LocalConsoleInvocationContext): Promise<LocalConsoleLaunchResult>;
64
+ openProject(projectId: string, context: LocalConsoleInvocationContext): Promise<LocalConsoleOpenResult>;
53
65
  refreshInvite(projectId: string, context: LocalConsoleInvocationContext): Promise<{
54
66
  join_url: string;
55
67
  expires_at?: string;