@xfey/tutti 0.1.35 → 0.1.37

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
  },
@@ -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
@@ -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) {
@@ -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
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,6 +8,7 @@ export type LocalConsoleProject = {
7
8
  workspace_path: string;
8
9
  status: RuntimeProjectRow["status"];
9
10
  provider_status: "configured" | "not_configured" | "invalid";
11
+ provider_model?: string;
10
12
  relay_project_ref?: RelayProjectRef;
11
13
  join_url?: string;
12
14
  open_url?: string;
@@ -27,6 +29,11 @@ export type LocalConsoleLaunchResult = {
27
29
  join_url: string;
28
30
  join_token_expires_at?: string;
29
31
  };
32
+ export type LocalConsoleOpenResult = {
33
+ project: LocalConsoleProject;
34
+ open_url: string;
35
+ disposition: "opened_current" | "updated" | "deferred_busy" | "deferred_unsupported" | "deferred_unavailable";
36
+ };
30
37
  export declare class LocalConsoleProjectError extends Error {
31
38
  readonly code: string;
32
39
  readonly statusCode: number;
@@ -37,6 +44,7 @@ export declare class LocalConsoleProjectService {
37
44
  constructor(options: {
38
45
  tuttiHome: string;
39
46
  serviceEnvironment?: NodeJS.ProcessEnv;
47
+ fetchImpl?: FetchLike;
40
48
  });
41
49
  listProjects(context: LocalConsoleInvocationContext): Promise<LocalConsoleProject[]>;
42
50
  discoverModels(input: {
@@ -53,6 +61,7 @@ export declare class LocalConsoleProjectService {
53
61
  workspacePath: string;
54
62
  provider?: LocalConsoleProviderInput;
55
63
  }, context: LocalConsoleInvocationContext): Promise<LocalConsoleLaunchResult>;
64
+ openProject(projectId: string, context: LocalConsoleInvocationContext): Promise<LocalConsoleOpenResult>;
56
65
  refreshInvite(projectId: string, context: LocalConsoleInvocationContext): Promise<{
57
66
  join_url: string;
58
67
  expires_at?: string;
@@ -3,14 +3,16 @@ import { resolve } from "node:path";
3
3
  import { relayProjectRouteSegment } from "@tutti/shared/ids";
4
4
  import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, } from "../../providers/openai/index.js";
5
5
  import { formatCliErrorReason, LaunchError } from "../cli/errors.js";
6
+ import { createRuntimeEndpointProbe, waitForHostShutdown, } from "../cli/host-runtime-endpoint.js";
6
7
  import { prepareLaunchProject, resolveRelayUrl } from "../cli/launch.js";
7
- import { rotateHostLocalInvite } from "../cli/local-control-client.js";
8
+ import { requestHostLocalIdleShutdown, rotateHostLocalInvite, } from "../cli/local-control-client.js";
8
9
  import { readHostRegistrationSecret, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "../cli/machine-local.js";
9
10
  import { spawnDetachedHost, waitForManagedHostReady } from "../cli/managed-host.js";
10
11
  import { resolveManagedProjectContext } from "../cli/project-resolver.js";
11
12
  import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
12
13
  import { readCliVersion } from "../cli/version.js";
13
14
  import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
15
+ const OPEN_UPDATE_SHUTDOWN_WAIT_MS = 5_000;
14
16
  export class LocalConsoleProjectError extends Error {
15
17
  code;
16
18
  statusCode;
@@ -48,7 +50,7 @@ function validateProvider(input) {
48
50
  }
49
51
  return provider;
50
52
  }
51
- function projectFromRuntimeRow(row, providerStatus, metadata) {
53
+ function projectFromRuntimeRow(row, providerStatus, providerModel, metadata) {
52
54
  const currentVersion = readCliVersion();
53
55
  const openUrl = projectOpenUrl(metadata.relayUrl, row.relay_project_ref);
54
56
  return {
@@ -57,6 +59,7 @@ function projectFromRuntimeRow(row, providerStatus, metadata) {
57
59
  workspace_path: row.workspace_root,
58
60
  status: row.status,
59
61
  provider_status: providerStatus,
62
+ ...(providerModel === undefined ? {} : { provider_model: providerModel }),
60
63
  ...(row.relay_project_ref === undefined ? {} : { relay_project_ref: row.relay_project_ref }),
61
64
  ...(row.join_url === undefined ? {} : { join_url: row.join_url }),
62
65
  ...(openUrl === undefined ? {} : { open_url: openUrl }),
@@ -117,11 +120,13 @@ function relayUrlFromJoinUrl(joinUrl) {
117
120
  export class LocalConsoleProjectService {
118
121
  #tuttiHome;
119
122
  #serviceEnvironment;
123
+ #fetchImpl;
120
124
  #launches = new Map();
121
125
  #mutationTail = Promise.resolve();
122
126
  constructor(options) {
123
127
  this.#tuttiHome = resolve(options.tuttiHome);
124
128
  this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
129
+ this.#fetchImpl = options.fetchImpl ?? fetch;
125
130
  }
126
131
  #operationOptions(context) {
127
132
  return {
@@ -142,6 +147,7 @@ export class LocalConsoleProjectService {
142
147
  const operation = this.#operationOptions(context);
143
148
  const rows = await listRuntimeProjects({
144
149
  all: true,
150
+ fetchImpl: this.#fetchImpl,
145
151
  ...operation,
146
152
  });
147
153
  return rows.map((row) => {
@@ -165,14 +171,36 @@ export class LocalConsoleProjectService {
165
171
  tuttiHome: this.#tuttiHome,
166
172
  projectId: row.project_id,
167
173
  });
174
+ const providerModel = provider.status === "not_configured" ? undefined : provider.default_model;
168
175
  const activityAt = readProjectActivityAt(this.#tuttiHome, row, binding);
169
- return projectFromRuntimeRow(row, provider.status, {
176
+ return projectFromRuntimeRow(row, provider.status, providerModel, {
170
177
  ...(activityAt === undefined ? {} : { activityAt }),
171
178
  ...(createdAt === undefined ? {} : { createdAt }),
172
179
  relayUrl: binding?.relay_url ?? relayUrlFromJoinUrl(row.join_url) ?? resolveRelayUrl(operation.env),
173
180
  });
174
181
  });
175
182
  }
183
+ async #readProject(projectId, context) {
184
+ const project = (await this.listProjects(context)).find((candidate) => candidate.project_id === projectId);
185
+ if (project === undefined) {
186
+ throw new LocalConsoleProjectError("project_not_found", "The project could not be read from the machine project list.", 404);
187
+ }
188
+ return project;
189
+ }
190
+ async #startManagedHost(options) {
191
+ spawnDetachedHost({
192
+ workspaceRoot: options.workspaceRoot,
193
+ env: options.env,
194
+ inheritProcessEnv: false,
195
+ });
196
+ return await waitForManagedHostReady({
197
+ tuttiHome: options.tuttiHome,
198
+ projectId: options.projectId,
199
+ workspaceRoot: options.workspaceRoot,
200
+ fetchImpl: this.#fetchImpl,
201
+ ...(options.inviteMode === undefined ? {} : { inviteMode: options.inviteMode }),
202
+ });
203
+ }
176
204
  async discoverModels(input) {
177
205
  const provider = validateProvider({
178
206
  base_url: input.baseUrl,
@@ -228,15 +256,11 @@ export class LocalConsoleProjectService {
228
256
  throw new LocalConsoleProjectError("provider_configuration_required", "This project does not have a valid saved Provider. Configure it in the launch form.");
229
257
  }
230
258
  try {
231
- spawnDetachedHost({
259
+ const ready = await this.#startManagedHost({
232
260
  workspaceRoot: preparation.workspace_root,
233
- env: operation.env,
234
- inheritProcessEnv: false,
235
- });
236
- const ready = await waitForManagedHostReady({
237
261
  tuttiHome: preparation.tutti_home,
238
262
  projectId: preparation.project_id,
239
- workspaceRoot: preparation.workspace_root,
263
+ env: operation.env,
240
264
  });
241
265
  if (ready.join_url === undefined) {
242
266
  throw new LocalConsoleProjectError("relay_registration_failed", "The host started, but Relay did not return a visible join link.");
@@ -259,6 +283,87 @@ export class LocalConsoleProjectService {
259
283
  throw this.#normalizeError(error);
260
284
  }
261
285
  }
286
+ async openProject(projectId, context) {
287
+ return await this.#runMutation(async () => {
288
+ const current = await this.#readProject(projectId, context);
289
+ if (current.open_url === undefined) {
290
+ throw new LocalConsoleProjectError("project_open_unavailable", "This project does not have a Relay destination yet.");
291
+ }
292
+ if (current.update_required !== true) {
293
+ return {
294
+ project: current,
295
+ open_url: current.open_url,
296
+ disposition: "opened_current",
297
+ };
298
+ }
299
+ let managed;
300
+ try {
301
+ managed = resolveManagedProjectContext({
302
+ target: projectId,
303
+ ...this.#operationOptions(context),
304
+ });
305
+ }
306
+ catch (error) {
307
+ throw this.#normalizeError(error);
308
+ }
309
+ if (!managed.workspace_exists ||
310
+ managed.runtime_endpoint === null ||
311
+ managed.provider_config.status !== "configured") {
312
+ return {
313
+ project: current,
314
+ open_url: current.open_url,
315
+ disposition: "deferred_unavailable",
316
+ };
317
+ }
318
+ let shutdownDisposition;
319
+ try {
320
+ shutdownDisposition = await requestHostLocalIdleShutdown({
321
+ endpoint: managed.runtime_endpoint,
322
+ fetchImpl: this.#fetchImpl,
323
+ });
324
+ }
325
+ catch {
326
+ return {
327
+ project: current,
328
+ open_url: current.open_url,
329
+ disposition: "deferred_unavailable",
330
+ };
331
+ }
332
+ if (shutdownDisposition !== "accepted") {
333
+ return {
334
+ project: current,
335
+ open_url: current.open_url,
336
+ disposition: shutdownDisposition === "busy" ? "deferred_busy" : "deferred_unsupported",
337
+ };
338
+ }
339
+ try {
340
+ await waitForHostShutdown({
341
+ endpoint: managed.runtime_endpoint,
342
+ probeRuntimeEndpoint: createRuntimeEndpointProbe(this.#fetchImpl),
343
+ shutdownWaitMs: OPEN_UPDATE_SHUTDOWN_WAIT_MS,
344
+ });
345
+ await this.#startManagedHost({
346
+ workspaceRoot: managed.workspace_root,
347
+ tuttiHome: managed.tutti_home,
348
+ projectId: managed.project_id,
349
+ env: this.#operationOptions(context).env,
350
+ inviteMode: "preserve",
351
+ });
352
+ const updated = await this.#readProject(projectId, context);
353
+ if (updated.open_url === undefined) {
354
+ throw new LocalConsoleProjectError("project_open_unavailable", "The updated project does not have a Relay destination.");
355
+ }
356
+ return {
357
+ project: updated,
358
+ open_url: updated.open_url,
359
+ disposition: "updated",
360
+ };
361
+ }
362
+ catch (error) {
363
+ throw this.#normalizeError(error);
364
+ }
365
+ });
366
+ }
262
367
  async refreshInvite(projectId, context) {
263
368
  return await this.#runMutation(async () => {
264
369
  const project = resolveManagedProjectContext({
@@ -269,7 +374,10 @@ export class LocalConsoleProjectService {
269
374
  throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
270
375
  }
271
376
  try {
272
- const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
377
+ const status = await rotateHostLocalInvite({
378
+ endpoint: project.runtime_endpoint,
379
+ fetchImpl: this.#fetchImpl,
380
+ });
273
381
  const joinUrl = status.relay?.join_url;
274
382
  if (joinUrl === undefined) {
275
383
  throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
@@ -276,6 +276,10 @@ export function createLocalConsoleServer(options) {
276
276
  ...(request.body.provider === undefined ? {} : { provider: request.body.provider }),
277
277
  }, session.context);
278
278
  });
279
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/open`, async (request) => {
280
+ const session = requireSession(request, true);
281
+ return await projects.openProject(request.params.projectId, session.context);
282
+ });
279
283
  app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/invite`, async (request) => {
280
284
  const session = requireSession(request, true);
281
285
  return await projects.refreshInvite(request.params.projectId, session.context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",