@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.
@@ -1,15 +1,18 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { relayProjectRouteSegment } from "@tutti/shared/ids";
3
4
  import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, } from "../../providers/openai/index.js";
4
5
  import { formatCliErrorReason, LaunchError } from "../cli/errors.js";
5
- import { prepareLaunchProject } from "../cli/launch.js";
6
- import { rotateHostLocalInvite } from "../cli/local-control-client.js";
7
- import { readMachineProjectBinding, readMachineRuntimeEndpoint } from "../cli/machine-local.js";
6
+ import { createRuntimeEndpointProbe, waitForHostShutdown, } from "../cli/host-runtime-endpoint.js";
7
+ import { prepareLaunchProject, resolveRelayUrl } from "../cli/launch.js";
8
+ import { requestHostLocalIdleShutdown, rotateHostLocalInvite, } from "../cli/local-control-client.js";
9
+ import { readHostRegistrationSecret, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "../cli/machine-local.js";
8
10
  import { spawnDetachedHost, waitForManagedHostReady } from "../cli/managed-host.js";
9
11
  import { resolveManagedProjectContext } from "../cli/project-resolver.js";
10
12
  import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
11
13
  import { readCliVersion } from "../cli/version.js";
12
14
  import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
15
+ const OPEN_UPDATE_SHUTDOWN_WAIT_MS = 5_000;
13
16
  export class LocalConsoleProjectError extends Error {
14
17
  code;
15
18
  statusCode;
@@ -47,27 +50,43 @@ function validateProvider(input) {
47
50
  }
48
51
  return provider;
49
52
  }
50
- function projectFromRuntimeRow(row, providerStatus, activityAt) {
53
+ function projectFromRuntimeRow(row, providerStatus, providerModel, metadata) {
51
54
  const currentVersion = readCliVersion();
55
+ const openUrl = projectOpenUrl(metadata.relayUrl, row.relay_project_ref);
52
56
  return {
53
57
  project_id: row.project_id,
54
58
  display_name: row.display_name,
55
59
  workspace_path: row.workspace_root,
56
60
  status: row.status,
57
61
  provider_status: providerStatus,
62
+ ...(providerModel === undefined ? {} : { provider_model: providerModel }),
58
63
  ...(row.relay_project_ref === undefined ? {} : { relay_project_ref: row.relay_project_ref }),
59
64
  ...(row.join_url === undefined ? {} : { join_url: row.join_url }),
65
+ ...(openUrl === undefined ? {} : { open_url: openUrl }),
60
66
  ...(row.relay_connection_status === undefined
61
67
  ? {}
62
68
  : { relay_connection_status: row.relay_connection_status }),
63
- ...(activityAt === undefined ? {} : { activity_at: activityAt }),
69
+ ...(metadata.activityAt === undefined ? {} : { activity_at: metadata.activityAt }),
70
+ ...(metadata.createdAt === undefined ? {} : { created_at: metadata.createdAt }),
64
71
  ...(row.host_version === undefined ? {} : { host_version: row.host_version }),
65
72
  ...(row.host_version === undefined || row.host_version === currentVersion
66
73
  ? {}
67
74
  : { update_required: true }),
68
75
  };
69
76
  }
70
- function readProjectActivityAt(tuttiHome, row) {
77
+ function projectOpenUrl(relayUrl, relayProjectRef) {
78
+ if (relayUrl === undefined || relayProjectRef === undefined) {
79
+ return undefined;
80
+ }
81
+ try {
82
+ const url = new URL(`/projects?open=${encodeURIComponent(relayProjectRouteSegment(relayProjectRef))}`, relayUrl);
83
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ function readProjectActivityAt(tuttiHome, row, binding) {
71
90
  if (row.last_connected_at !== undefined) {
72
91
  return row.last_connected_at;
73
92
  }
@@ -81,7 +100,18 @@ function readProjectActivityAt(tuttiHome, row) {
81
100
  // Invalid endpoint state is already represented by the project status.
82
101
  }
83
102
  try {
84
- return readMachineProjectBinding(tuttiHome, row.project_id)?.updated_at;
103
+ return binding?.updated_at;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ function relayUrlFromJoinUrl(joinUrl) {
110
+ if (joinUrl === undefined) {
111
+ return undefined;
112
+ }
113
+ try {
114
+ return new URL(joinUrl).origin;
85
115
  }
86
116
  catch {
87
117
  return undefined;
@@ -90,11 +120,13 @@ function readProjectActivityAt(tuttiHome, row) {
90
120
  export class LocalConsoleProjectService {
91
121
  #tuttiHome;
92
122
  #serviceEnvironment;
123
+ #fetchImpl;
93
124
  #launches = new Map();
94
125
  #mutationTail = Promise.resolve();
95
126
  constructor(options) {
96
127
  this.#tuttiHome = resolve(options.tuttiHome);
97
128
  this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
129
+ this.#fetchImpl = options.fetchImpl ?? fetch;
98
130
  }
99
131
  #operationOptions(context) {
100
132
  return {
@@ -115,14 +147,58 @@ export class LocalConsoleProjectService {
115
147
  const operation = this.#operationOptions(context);
116
148
  const rows = await listRuntimeProjects({
117
149
  all: true,
150
+ fetchImpl: this.#fetchImpl,
118
151
  ...operation,
119
152
  });
120
153
  return rows.map((row) => {
154
+ let binding = null;
155
+ try {
156
+ binding = readMachineProjectBinding(this.#tuttiHome, row.project_id);
157
+ }
158
+ catch {
159
+ // Invalid binding state is already represented by the project status.
160
+ }
161
+ let createdAt = binding?.created_at;
162
+ if (createdAt === undefined) {
163
+ try {
164
+ createdAt = readHostRegistrationSecret(this.#tuttiHome, row.project_id).created_at;
165
+ }
166
+ catch {
167
+ // Damaged legacy metadata remains visible without an invented creation date.
168
+ }
169
+ }
121
170
  const provider = readOpenAiProviderConfigProjection({
122
171
  tuttiHome: this.#tuttiHome,
123
172
  projectId: row.project_id,
124
173
  });
125
- return projectFromRuntimeRow(row, provider.status, readProjectActivityAt(this.#tuttiHome, row));
174
+ const providerModel = provider.status === "not_configured" ? undefined : provider.default_model;
175
+ const activityAt = readProjectActivityAt(this.#tuttiHome, row, binding);
176
+ return projectFromRuntimeRow(row, provider.status, providerModel, {
177
+ ...(activityAt === undefined ? {} : { activityAt }),
178
+ ...(createdAt === undefined ? {} : { createdAt }),
179
+ relayUrl: binding?.relay_url ?? relayUrlFromJoinUrl(row.join_url) ?? resolveRelayUrl(operation.env),
180
+ });
181
+ });
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 }),
126
202
  });
127
203
  }
128
204
  async discoverModels(input) {
@@ -180,15 +256,11 @@ export class LocalConsoleProjectService {
180
256
  throw new LocalConsoleProjectError("provider_configuration_required", "This project does not have a valid saved Provider. Configure it in the launch form.");
181
257
  }
182
258
  try {
183
- spawnDetachedHost({
259
+ const ready = await this.#startManagedHost({
184
260
  workspaceRoot: preparation.workspace_root,
185
- env: operation.env,
186
- inheritProcessEnv: false,
187
- });
188
- const ready = await waitForManagedHostReady({
189
261
  tuttiHome: preparation.tutti_home,
190
262
  projectId: preparation.project_id,
191
- workspaceRoot: preparation.workspace_root,
263
+ env: operation.env,
192
264
  });
193
265
  if (ready.join_url === undefined) {
194
266
  throw new LocalConsoleProjectError("relay_registration_failed", "The host started, but Relay did not return a visible join link.");
@@ -200,6 +272,7 @@ export class LocalConsoleProjectService {
200
272
  }
201
273
  return {
202
274
  project,
275
+ project_created: preparation.project_identity_created,
203
276
  join_url: ready.join_url,
204
277
  ...(ready.join_token_expires_at === undefined
205
278
  ? {}
@@ -210,6 +283,87 @@ export class LocalConsoleProjectService {
210
283
  throw this.#normalizeError(error);
211
284
  }
212
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
+ }
213
367
  async refreshInvite(projectId, context) {
214
368
  return await this.#runMutation(async () => {
215
369
  const project = resolveManagedProjectContext({
@@ -220,7 +374,10 @@ export class LocalConsoleProjectService {
220
374
  throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
221
375
  }
222
376
  try {
223
- const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
377
+ const status = await rotateHostLocalInvite({
378
+ endpoint: project.runtime_endpoint,
379
+ fetchImpl: this.#fetchImpl,
380
+ });
224
381
  const joinUrl = status.relay?.join_url;
225
382
  if (joinUrl === undefined) {
226
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.34",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",