@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.
- package/dist/server-shell/cli/host-runtime-endpoint.d.ts +5 -0
- package/dist/server-shell/cli/host-runtime-endpoint.js +3 -0
- package/dist/server-shell/cli/host-server-runtime.d.ts +1 -1
- package/dist/server-shell/cli/host-server-runtime.js +7 -1
- package/dist/server-shell/cli/local-control-client.d.ts +5 -1
- package/dist/server-shell/cli/local-control-client.js +22 -0
- package/dist/server-shell/cli/managed-host.d.ts +1 -0
- package/dist/server-shell/cli/managed-host.js +4 -1
- package/dist/server-shell/http/routes/local-control.d.ts +2 -0
- package/dist/server-shell/http/routes/local-control.js +78 -1
- package/dist/server-shell/local-console/project-service.d.ts +9 -0
- package/dist/server-shell/local-console/project-service.js +118 -10
- package/dist/server-shell/local-console/server.js +4 -0
- package/package.json +1 -1
- package/web/assets/index-BJUNHb0W.js +29 -0
- package/web/assets/{index-Dmbchapb.css → index-C1eEw7ii.css} +1 -1
- package/web/assets/vexflow-bravura-DEW_BI-z.js +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-DPuaTngs.js +0 -29
|
@@ -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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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);
|