@xfey/tutti 0.1.33 → 0.1.35

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 (37) hide show
  1. package/README.md +3 -3
  2. package/dist/server-shell/cli/args.d.ts +6 -0
  3. package/dist/server-shell/cli/args.js +30 -0
  4. package/dist/server-shell/cli/cli.js +118 -6
  5. package/dist/server-shell/cli/errors.d.ts +1 -1
  6. package/dist/server-shell/cli/host-runtime-endpoint.d.ts +1 -0
  7. package/dist/server-shell/cli/host-runtime-endpoint.js +10 -5
  8. package/dist/server-shell/cli/launch.js +1 -0
  9. package/dist/server-shell/cli/machine-local.d.ts +3 -0
  10. package/dist/server-shell/cli/machine-local.js +24 -3
  11. package/dist/server-shell/cli/machine-project-inspector.d.ts +1 -0
  12. package/dist/server-shell/cli/machine-project-inspector.js +1 -0
  13. package/dist/server-shell/cli/managed-host.d.ts +1 -0
  14. package/dist/server-shell/cli/managed-host.js +6 -4
  15. package/dist/server-shell/cli/runtime-commands.d.ts +3 -2
  16. package/dist/server-shell/cli/runtime-commands.js +9 -1
  17. package/dist/server-shell/local-console/folder-picker.d.ts +0 -1
  18. package/dist/server-shell/local-console/folder-picker.js +2 -33
  19. package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
  20. package/dist/server-shell/local-console/invocation-context.js +56 -0
  21. package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
  22. package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
  23. package/dist/server-shell/local-console/managed-console.d.ts +38 -0
  24. package/dist/server-shell/local-console/managed-console.js +254 -36
  25. package/dist/server-shell/local-console/project-service.d.ts +16 -9
  26. package/dist/server-shell/local-console/project-service.js +152 -58
  27. package/dist/server-shell/local-console/runtime-endpoint.d.ts +15 -1
  28. package/dist/server-shell/local-console/runtime-endpoint.js +21 -5
  29. package/dist/server-shell/local-console/server.d.ts +12 -0
  30. package/dist/server-shell/local-console/server.js +152 -45
  31. package/dist/server-shell/local-console/session.d.ts +10 -15
  32. package/dist/server-shell/local-console/session.js +69 -17
  33. package/package.json +1 -1
  34. package/web/assets/index-DPuaTngs.js +29 -0
  35. package/web/assets/{index-BcZpeSaX.css → index-Dmbchapb.css} +1 -1
  36. package/web/index.html +2 -2
  37. package/web/assets/index-DKf4jz_E.js +0 -29
@@ -1,12 +1,16 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, resolveTuttiHome, } from "../../providers/openai/index.js";
3
+ import { relayProjectRouteSegment } from "@tutti/shared/ids";
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 { prepareLaunchProject, resolveRelayUrl } from "../cli/launch.js";
6
7
  import { rotateHostLocalInvite } from "../cli/local-control-client.js";
8
+ import { readHostRegistrationSecret, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "../cli/machine-local.js";
7
9
  import { spawnDetachedHost, waitForManagedHostReady } from "../cli/managed-host.js";
8
10
  import { resolveManagedProjectContext } from "../cli/project-resolver.js";
9
11
  import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
12
+ import { readCliVersion } from "../cli/version.js";
13
+ import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
10
14
  export class LocalConsoleProjectError extends Error {
11
15
  code;
12
16
  statusCode;
@@ -44,7 +48,9 @@ function validateProvider(input) {
44
48
  }
45
49
  return provider;
46
50
  }
47
- function projectFromRuntimeRow(row, providerStatus) {
51
+ function projectFromRuntimeRow(row, providerStatus, metadata) {
52
+ const currentVersion = readCliVersion();
53
+ const openUrl = projectOpenUrl(metadata.relayUrl, row.relay_project_ref);
48
54
  return {
49
55
  project_id: row.project_id,
50
56
  display_name: row.display_name,
@@ -53,32 +59,118 @@ function projectFromRuntimeRow(row, providerStatus) {
53
59
  provider_status: providerStatus,
54
60
  ...(row.relay_project_ref === undefined ? {} : { relay_project_ref: row.relay_project_ref }),
55
61
  ...(row.join_url === undefined ? {} : { join_url: row.join_url }),
62
+ ...(openUrl === undefined ? {} : { open_url: openUrl }),
56
63
  ...(row.relay_connection_status === undefined
57
64
  ? {}
58
65
  : { relay_connection_status: row.relay_connection_status }),
66
+ ...(metadata.activityAt === undefined ? {} : { activity_at: metadata.activityAt }),
67
+ ...(metadata.createdAt === undefined ? {} : { created_at: metadata.createdAt }),
68
+ ...(row.host_version === undefined ? {} : { host_version: row.host_version }),
69
+ ...(row.host_version === undefined || row.host_version === currentVersion
70
+ ? {}
71
+ : { update_required: true }),
59
72
  };
60
73
  }
74
+ function projectOpenUrl(relayUrl, relayProjectRef) {
75
+ if (relayUrl === undefined || relayProjectRef === undefined) {
76
+ return undefined;
77
+ }
78
+ try {
79
+ const url = new URL(`/projects?open=${encodeURIComponent(relayProjectRouteSegment(relayProjectRef))}`, relayUrl);
80
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ function readProjectActivityAt(tuttiHome, row, binding) {
87
+ if (row.last_connected_at !== undefined) {
88
+ return row.last_connected_at;
89
+ }
90
+ try {
91
+ const endpoint = readMachineRuntimeEndpoint(tuttiHome, row.project_id);
92
+ if (endpoint !== null) {
93
+ return endpoint.updated_at;
94
+ }
95
+ }
96
+ catch {
97
+ // Invalid endpoint state is already represented by the project status.
98
+ }
99
+ try {
100
+ return binding?.updated_at;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ function relayUrlFromJoinUrl(joinUrl) {
107
+ if (joinUrl === undefined) {
108
+ return undefined;
109
+ }
110
+ try {
111
+ return new URL(joinUrl).origin;
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
61
117
  export class LocalConsoleProjectService {
62
- #cwd;
63
- #env;
118
+ #tuttiHome;
119
+ #serviceEnvironment;
64
120
  #launches = new Map();
65
- constructor(options = {}) {
66
- this.#cwd = resolve(options.cwd ?? process.cwd());
67
- this.#env = options.env ?? process.env;
121
+ #mutationTail = Promise.resolve();
122
+ constructor(options) {
123
+ this.#tuttiHome = resolve(options.tuttiHome);
124
+ this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
68
125
  }
69
- async listProjects() {
70
- const tuttiHome = resolveTuttiHome(this.#env.TUTTI_HOME, this.#cwd);
126
+ #operationOptions(context) {
127
+ return {
128
+ cwd: context.currentDirectory,
129
+ env: createLocalConsoleOperationEnvironment({
130
+ serviceEnvironment: this.#serviceEnvironment,
131
+ invocationEnvironment: context.environment,
132
+ tuttiHome: this.#tuttiHome,
133
+ }),
134
+ };
135
+ }
136
+ #runMutation(operation) {
137
+ const result = this.#mutationTail.then(operation, operation);
138
+ this.#mutationTail = result.then(() => undefined, () => undefined);
139
+ return result;
140
+ }
141
+ async listProjects(context) {
142
+ const operation = this.#operationOptions(context);
71
143
  const rows = await listRuntimeProjects({
72
144
  all: true,
73
- cwd: this.#cwd,
74
- env: this.#env,
145
+ ...operation,
75
146
  });
76
147
  return rows.map((row) => {
148
+ let binding = null;
149
+ try {
150
+ binding = readMachineProjectBinding(this.#tuttiHome, row.project_id);
151
+ }
152
+ catch {
153
+ // Invalid binding state is already represented by the project status.
154
+ }
155
+ let createdAt = binding?.created_at;
156
+ if (createdAt === undefined) {
157
+ try {
158
+ createdAt = readHostRegistrationSecret(this.#tuttiHome, row.project_id).created_at;
159
+ }
160
+ catch {
161
+ // Damaged legacy metadata remains visible without an invented creation date.
162
+ }
163
+ }
77
164
  const provider = readOpenAiProviderConfigProjection({
78
- tuttiHome,
165
+ tuttiHome: this.#tuttiHome,
79
166
  projectId: row.project_id,
80
167
  });
81
- return projectFromRuntimeRow(row, provider.status);
168
+ const activityAt = readProjectActivityAt(this.#tuttiHome, row, binding);
169
+ return projectFromRuntimeRow(row, provider.status, {
170
+ ...(activityAt === undefined ? {} : { activityAt }),
171
+ ...(createdAt === undefined ? {} : { createdAt }),
172
+ relayUrl: binding?.relay_url ?? relayUrlFromJoinUrl(row.join_url) ?? resolveRelayUrl(operation.env),
173
+ });
82
174
  });
83
175
  }
84
176
  async discoverModels(input) {
@@ -92,29 +184,29 @@ export class LocalConsoleProjectService {
92
184
  apiKey: provider.api_key,
93
185
  });
94
186
  }
95
- async launchProject(input) {
187
+ async launchProject(input, context) {
96
188
  const workspacePath = validateWorkspacePath(input.workspacePath);
97
189
  const active = this.#launches.get(workspacePath);
98
190
  if (active !== undefined) {
99
191
  return await active;
100
192
  }
101
- const launch = this.#launchProject({
193
+ const launch = this.#runMutation(async () => await this.#launchProject({
102
194
  workspacePath,
103
195
  ...(input.provider === undefined ? {} : { provider: validateProvider(input.provider) }),
104
- }).finally(() => {
196
+ }, context)).finally(() => {
105
197
  this.#launches.delete(workspacePath);
106
198
  });
107
199
  this.#launches.set(workspacePath, launch);
108
200
  return await launch;
109
201
  }
110
- async #launchProject(input) {
202
+ async #launchProject(input, context) {
203
+ const operation = this.#operationOptions(context);
111
204
  let preparation;
112
205
  try {
113
206
  preparation = await prepareLaunchProject({
114
207
  workspacePath: input.workspacePath,
115
208
  yes: true,
116
- cwd: this.#cwd,
117
- env: this.#env,
209
+ ...operation,
118
210
  });
119
211
  }
120
212
  catch (error) {
@@ -138,7 +230,8 @@ export class LocalConsoleProjectService {
138
230
  try {
139
231
  spawnDetachedHost({
140
232
  workspaceRoot: preparation.workspace_root,
141
- env: this.#env,
233
+ env: operation.env,
234
+ inheritProcessEnv: false,
142
235
  });
143
236
  const ready = await waitForManagedHostReady({
144
237
  tuttiHome: preparation.tutti_home,
@@ -148,13 +241,14 @@ export class LocalConsoleProjectService {
148
241
  if (ready.join_url === undefined) {
149
242
  throw new LocalConsoleProjectError("relay_registration_failed", "The host started, but Relay did not return a visible join link.");
150
243
  }
151
- const projects = await this.listProjects();
244
+ const projects = await this.listProjects(context);
152
245
  const project = projects.find((item) => item.project_id === preparation.project_id);
153
246
  if (project === undefined) {
154
247
  throw new LocalConsoleProjectError("project_not_found", "The launched project could not be read from the machine project list.");
155
248
  }
156
249
  return {
157
250
  project,
251
+ project_created: preparation.project_identity_created,
158
252
  join_url: ready.join_url,
159
253
  ...(ready.join_token_expires_at === undefined
160
254
  ? {}
@@ -165,44 +259,44 @@ export class LocalConsoleProjectService {
165
259
  throw this.#normalizeError(error);
166
260
  }
167
261
  }
168
- async refreshInvite(projectId) {
169
- const project = resolveManagedProjectContext({
170
- target: projectId,
171
- cwd: this.#cwd,
172
- env: this.#env,
173
- });
174
- if (project.runtime_endpoint === null) {
175
- throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
176
- }
177
- try {
178
- const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
179
- const joinUrl = status.relay?.join_url;
180
- if (joinUrl === undefined) {
181
- throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
262
+ async refreshInvite(projectId, context) {
263
+ return await this.#runMutation(async () => {
264
+ const project = resolveManagedProjectContext({
265
+ target: projectId,
266
+ ...this.#operationOptions(context),
267
+ });
268
+ if (project.runtime_endpoint === null) {
269
+ throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
182
270
  }
183
- return {
184
- join_url: joinUrl,
185
- ...(status.relay?.join_token_expires_at === undefined
186
- ? {}
187
- : { expires_at: status.relay.join_token_expires_at }),
188
- };
189
- }
190
- catch (error) {
191
- throw this.#normalizeError(error);
192
- }
271
+ try {
272
+ const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
273
+ const joinUrl = status.relay?.join_url;
274
+ if (joinUrl === undefined) {
275
+ throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
276
+ }
277
+ return {
278
+ join_url: joinUrl,
279
+ ...(status.relay?.join_token_expires_at === undefined
280
+ ? {}
281
+ : { expires_at: status.relay.join_token_expires_at }),
282
+ };
283
+ }
284
+ catch (error) {
285
+ throw this.#normalizeError(error);
286
+ }
287
+ });
193
288
  }
194
- async stopProject(projectId) {
195
- try {
196
- return {
197
- message: await runStopCommand(projectId, {
198
- cwd: this.#cwd,
199
- env: this.#env,
200
- }),
201
- };
202
- }
203
- catch (error) {
204
- throw this.#normalizeError(error);
205
- }
289
+ async stopProject(projectId, context) {
290
+ return await this.#runMutation(async () => {
291
+ try {
292
+ return {
293
+ message: await runStopCommand(projectId, this.#operationOptions(context)),
294
+ };
295
+ }
296
+ catch (error) {
297
+ throw this.#normalizeError(error);
298
+ }
299
+ });
206
300
  }
207
301
  #normalizeError(error) {
208
302
  if (error instanceof LocalConsoleProjectError) {
@@ -1,17 +1,31 @@
1
1
  export type LocalConsoleRuntimeEndpoint = {
2
+ schema_version: 2;
3
+ instance_id: string;
4
+ pid: number;
5
+ base_url: string;
6
+ control_token: string;
7
+ version: string;
8
+ entrypoint: string;
9
+ started_at: string;
10
+ };
11
+ export type LegacyLocalConsoleRuntimeEndpoint = {
2
12
  schema_version: 1;
3
13
  pid: number;
4
14
  base_url: string;
5
15
  control_token: string;
6
16
  started_at: string;
7
17
  };
18
+ export type ReadableLocalConsoleRuntimeEndpoint = LocalConsoleRuntimeEndpoint | LegacyLocalConsoleRuntimeEndpoint;
8
19
  export declare function createLocalConsoleSecret(): string;
9
- export declare function readLocalConsoleRuntimeEndpoint(tuttiHome: string): LocalConsoleRuntimeEndpoint | null;
20
+ export declare function readLocalConsoleRuntimeEndpoint(tuttiHome: string): ReadableLocalConsoleRuntimeEndpoint | null;
10
21
  export declare function writeLocalConsoleRuntimeEndpoint(options: {
11
22
  tuttiHome: string;
23
+ instanceId: string;
12
24
  pid: number;
13
25
  baseUrl: string;
14
26
  controlToken: string;
27
+ version: string;
28
+ entrypoint: string;
15
29
  now?: () => Date;
16
30
  }): LocalConsoleRuntimeEndpoint;
17
31
  export declare function deleteLocalConsoleRuntimeEndpoint(options: {
@@ -7,10 +7,8 @@ function endpointPath(tuttiHome) {
7
7
  function isRecord(value) {
8
8
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
9
  }
10
- function isEndpoint(value) {
11
- return (isRecord(value) &&
12
- value.schema_version === 1 &&
13
- typeof value.pid === "number" &&
10
+ function hasSharedEndpointFields(value) {
11
+ return (typeof value.pid === "number" &&
14
12
  Number.isInteger(value.pid) &&
15
13
  typeof value.base_url === "string" &&
16
14
  /^http:\/\/127\.0\.0\.1:\d+$/u.test(value.base_url) &&
@@ -18,6 +16,21 @@ function isEndpoint(value) {
18
16
  value.control_token.length >= 32 &&
19
17
  typeof value.started_at === "string");
20
18
  }
19
+ function isEndpoint(value) {
20
+ if (!isRecord(value) || !hasSharedEndpointFields(value)) {
21
+ return false;
22
+ }
23
+ if (value.schema_version === 1) {
24
+ return true;
25
+ }
26
+ return (value.schema_version === 2 &&
27
+ typeof value.instance_id === "string" &&
28
+ value.instance_id.length >= 16 &&
29
+ typeof value.version === "string" &&
30
+ value.version.trim() !== "" &&
31
+ typeof value.entrypoint === "string" &&
32
+ value.entrypoint.trim() !== "");
33
+ }
21
34
  export function createLocalConsoleSecret() {
22
35
  return randomBytes(32).toString("base64url");
23
36
  }
@@ -36,10 +49,13 @@ export function readLocalConsoleRuntimeEndpoint(tuttiHome) {
36
49
  }
37
50
  export function writeLocalConsoleRuntimeEndpoint(options) {
38
51
  const record = {
39
- schema_version: 1,
52
+ schema_version: 2,
53
+ instance_id: options.instanceId,
40
54
  pid: options.pid,
41
55
  base_url: options.baseUrl,
42
56
  control_token: options.controlToken,
57
+ version: options.version,
58
+ entrypoint: options.entrypoint,
43
59
  started_at: (options.now ?? (() => new Date()))().toISOString(),
44
60
  };
45
61
  if (!isEndpoint(record)) {
@@ -1,17 +1,29 @@
1
1
  import { type FastifyInstance } from "fastify";
2
2
  export declare const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
3
+ type LocalConsoleRuntimeMetadata = {
4
+ instanceId: string;
5
+ version: string;
6
+ entrypoint: string;
7
+ startedAt: string;
8
+ pid: number;
9
+ };
3
10
  type LocalConsoleServerOptions = {
4
11
  controlToken: string;
5
12
  cwd: string;
13
+ tuttiHome?: string;
6
14
  env?: NodeJS.ProcessEnv;
7
15
  now?: () => Date;
8
16
  webStaticRoot?: string | false;
17
+ runtime?: LocalConsoleRuntimeMetadata;
18
+ onActivity?: () => void;
19
+ onShutdown?: () => void;
9
20
  };
10
21
  export declare function createLocalConsoleServer(options: LocalConsoleServerOptions): FastifyInstance;
11
22
  export declare function runLocalConsoleProcess(options?: {
12
23
  cwd?: string;
13
24
  env?: NodeJS.ProcessEnv;
14
25
  now?: () => Date;
26
+ idleTimeoutMs?: number;
15
27
  }): Promise<void>;
16
28
  export {};
17
29
  //# sourceMappingURL=server.d.ts.map