@xfey/tutti 0.1.33 → 0.1.34
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/README.md +3 -3
- package/dist/server-shell/cli/args.d.ts +6 -0
- package/dist/server-shell/cli/args.js +30 -0
- package/dist/server-shell/cli/cli.js +118 -6
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/host-runtime-endpoint.d.ts +1 -0
- package/dist/server-shell/cli/host-runtime-endpoint.js +10 -5
- package/dist/server-shell/cli/machine-project-inspector.d.ts +1 -0
- package/dist/server-shell/cli/machine-project-inspector.js +1 -0
- package/dist/server-shell/cli/managed-host.d.ts +1 -0
- package/dist/server-shell/cli/managed-host.js +6 -4
- package/dist/server-shell/cli/runtime-commands.d.ts +1 -0
- package/dist/server-shell/cli/runtime-commands.js +5 -1
- package/dist/server-shell/local-console/folder-picker.d.ts +0 -1
- package/dist/server-shell/local-console/folder-picker.js +2 -33
- package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
- package/dist/server-shell/local-console/invocation-context.js +56 -0
- package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
- package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
- package/dist/server-shell/local-console/managed-console.d.ts +38 -0
- package/dist/server-shell/local-console/managed-console.js +254 -36
- package/dist/server-shell/local-console/project-service.d.ts +11 -7
- package/dist/server-shell/local-console/project-service.js +102 -57
- package/dist/server-shell/local-console/runtime-endpoint.d.ts +15 -1
- package/dist/server-shell/local-console/runtime-endpoint.js +21 -5
- package/dist/server-shell/local-console/server.d.ts +12 -0
- package/dist/server-shell/local-console/server.js +152 -45
- package/dist/server-shell/local-console/session.d.ts +10 -15
- package/dist/server-shell/local-console/session.js +69 -17
- package/package.json +1 -1
- package/web/assets/index-B84rQ4YJ.js +29 -0
- package/web/assets/{index-BcZpeSaX.css → index-C7RDpM1L.css} +1 -1
- package/web/index.html +2 -2
- package/web/assets/index-DKf4jz_E.js +0 -29
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection,
|
|
3
|
+
import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, } from "../../providers/openai/index.js";
|
|
4
4
|
import { formatCliErrorReason, LaunchError } from "../cli/errors.js";
|
|
5
5
|
import { prepareLaunchProject } from "../cli/launch.js";
|
|
6
6
|
import { rotateHostLocalInvite } from "../cli/local-control-client.js";
|
|
7
|
+
import { readMachineProjectBinding, readMachineRuntimeEndpoint } from "../cli/machine-local.js";
|
|
7
8
|
import { spawnDetachedHost, waitForManagedHostReady } from "../cli/managed-host.js";
|
|
8
9
|
import { resolveManagedProjectContext } from "../cli/project-resolver.js";
|
|
9
10
|
import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
|
|
11
|
+
import { readCliVersion } from "../cli/version.js";
|
|
12
|
+
import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
|
|
10
13
|
export class LocalConsoleProjectError extends Error {
|
|
11
14
|
code;
|
|
12
15
|
statusCode;
|
|
@@ -44,7 +47,8 @@ function validateProvider(input) {
|
|
|
44
47
|
}
|
|
45
48
|
return provider;
|
|
46
49
|
}
|
|
47
|
-
function projectFromRuntimeRow(row, providerStatus) {
|
|
50
|
+
function projectFromRuntimeRow(row, providerStatus, activityAt) {
|
|
51
|
+
const currentVersion = readCliVersion();
|
|
48
52
|
return {
|
|
49
53
|
project_id: row.project_id,
|
|
50
54
|
display_name: row.display_name,
|
|
@@ -56,29 +60,69 @@ function projectFromRuntimeRow(row, providerStatus) {
|
|
|
56
60
|
...(row.relay_connection_status === undefined
|
|
57
61
|
? {}
|
|
58
62
|
: { relay_connection_status: row.relay_connection_status }),
|
|
63
|
+
...(activityAt === undefined ? {} : { activity_at: activityAt }),
|
|
64
|
+
...(row.host_version === undefined ? {} : { host_version: row.host_version }),
|
|
65
|
+
...(row.host_version === undefined || row.host_version === currentVersion
|
|
66
|
+
? {}
|
|
67
|
+
: { update_required: true }),
|
|
59
68
|
};
|
|
60
69
|
}
|
|
70
|
+
function readProjectActivityAt(tuttiHome, row) {
|
|
71
|
+
if (row.last_connected_at !== undefined) {
|
|
72
|
+
return row.last_connected_at;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const endpoint = readMachineRuntimeEndpoint(tuttiHome, row.project_id);
|
|
76
|
+
if (endpoint !== null) {
|
|
77
|
+
return endpoint.updated_at;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Invalid endpoint state is already represented by the project status.
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return readMachineProjectBinding(tuttiHome, row.project_id)?.updated_at;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
61
90
|
export class LocalConsoleProjectService {
|
|
62
|
-
#
|
|
63
|
-
#
|
|
91
|
+
#tuttiHome;
|
|
92
|
+
#serviceEnvironment;
|
|
64
93
|
#launches = new Map();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
this.#
|
|
94
|
+
#mutationTail = Promise.resolve();
|
|
95
|
+
constructor(options) {
|
|
96
|
+
this.#tuttiHome = resolve(options.tuttiHome);
|
|
97
|
+
this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
|
|
98
|
+
}
|
|
99
|
+
#operationOptions(context) {
|
|
100
|
+
return {
|
|
101
|
+
cwd: context.currentDirectory,
|
|
102
|
+
env: createLocalConsoleOperationEnvironment({
|
|
103
|
+
serviceEnvironment: this.#serviceEnvironment,
|
|
104
|
+
invocationEnvironment: context.environment,
|
|
105
|
+
tuttiHome: this.#tuttiHome,
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
68
108
|
}
|
|
69
|
-
|
|
70
|
-
const
|
|
109
|
+
#runMutation(operation) {
|
|
110
|
+
const result = this.#mutationTail.then(operation, operation);
|
|
111
|
+
this.#mutationTail = result.then(() => undefined, () => undefined);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
async listProjects(context) {
|
|
115
|
+
const operation = this.#operationOptions(context);
|
|
71
116
|
const rows = await listRuntimeProjects({
|
|
72
117
|
all: true,
|
|
73
|
-
|
|
74
|
-
env: this.#env,
|
|
118
|
+
...operation,
|
|
75
119
|
});
|
|
76
120
|
return rows.map((row) => {
|
|
77
121
|
const provider = readOpenAiProviderConfigProjection({
|
|
78
|
-
tuttiHome,
|
|
122
|
+
tuttiHome: this.#tuttiHome,
|
|
79
123
|
projectId: row.project_id,
|
|
80
124
|
});
|
|
81
|
-
return projectFromRuntimeRow(row, provider.status);
|
|
125
|
+
return projectFromRuntimeRow(row, provider.status, readProjectActivityAt(this.#tuttiHome, row));
|
|
82
126
|
});
|
|
83
127
|
}
|
|
84
128
|
async discoverModels(input) {
|
|
@@ -92,29 +136,29 @@ export class LocalConsoleProjectService {
|
|
|
92
136
|
apiKey: provider.api_key,
|
|
93
137
|
});
|
|
94
138
|
}
|
|
95
|
-
async launchProject(input) {
|
|
139
|
+
async launchProject(input, context) {
|
|
96
140
|
const workspacePath = validateWorkspacePath(input.workspacePath);
|
|
97
141
|
const active = this.#launches.get(workspacePath);
|
|
98
142
|
if (active !== undefined) {
|
|
99
143
|
return await active;
|
|
100
144
|
}
|
|
101
|
-
const launch = this.#launchProject({
|
|
145
|
+
const launch = this.#runMutation(async () => await this.#launchProject({
|
|
102
146
|
workspacePath,
|
|
103
147
|
...(input.provider === undefined ? {} : { provider: validateProvider(input.provider) }),
|
|
104
|
-
}).finally(() => {
|
|
148
|
+
}, context)).finally(() => {
|
|
105
149
|
this.#launches.delete(workspacePath);
|
|
106
150
|
});
|
|
107
151
|
this.#launches.set(workspacePath, launch);
|
|
108
152
|
return await launch;
|
|
109
153
|
}
|
|
110
|
-
async #launchProject(input) {
|
|
154
|
+
async #launchProject(input, context) {
|
|
155
|
+
const operation = this.#operationOptions(context);
|
|
111
156
|
let preparation;
|
|
112
157
|
try {
|
|
113
158
|
preparation = await prepareLaunchProject({
|
|
114
159
|
workspacePath: input.workspacePath,
|
|
115
160
|
yes: true,
|
|
116
|
-
|
|
117
|
-
env: this.#env,
|
|
161
|
+
...operation,
|
|
118
162
|
});
|
|
119
163
|
}
|
|
120
164
|
catch (error) {
|
|
@@ -138,7 +182,8 @@ export class LocalConsoleProjectService {
|
|
|
138
182
|
try {
|
|
139
183
|
spawnDetachedHost({
|
|
140
184
|
workspaceRoot: preparation.workspace_root,
|
|
141
|
-
env:
|
|
185
|
+
env: operation.env,
|
|
186
|
+
inheritProcessEnv: false,
|
|
142
187
|
});
|
|
143
188
|
const ready = await waitForManagedHostReady({
|
|
144
189
|
tuttiHome: preparation.tutti_home,
|
|
@@ -148,7 +193,7 @@ export class LocalConsoleProjectService {
|
|
|
148
193
|
if (ready.join_url === undefined) {
|
|
149
194
|
throw new LocalConsoleProjectError("relay_registration_failed", "The host started, but Relay did not return a visible join link.");
|
|
150
195
|
}
|
|
151
|
-
const projects = await this.listProjects();
|
|
196
|
+
const projects = await this.listProjects(context);
|
|
152
197
|
const project = projects.find((item) => item.project_id === preparation.project_id);
|
|
153
198
|
if (project === undefined) {
|
|
154
199
|
throw new LocalConsoleProjectError("project_not_found", "The launched project could not be read from the machine project list.");
|
|
@@ -165,44 +210,44 @@ export class LocalConsoleProjectService {
|
|
|
165
210
|
throw this.#normalizeError(error);
|
|
166
211
|
}
|
|
167
212
|
}
|
|
168
|
-
async refreshInvite(projectId) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.");
|
|
213
|
+
async refreshInvite(projectId, context) {
|
|
214
|
+
return await this.#runMutation(async () => {
|
|
215
|
+
const project = resolveManagedProjectContext({
|
|
216
|
+
target: projectId,
|
|
217
|
+
...this.#operationOptions(context),
|
|
218
|
+
});
|
|
219
|
+
if (project.runtime_endpoint === null) {
|
|
220
|
+
throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
|
|
182
221
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
222
|
+
try {
|
|
223
|
+
const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
|
|
224
|
+
const joinUrl = status.relay?.join_url;
|
|
225
|
+
if (joinUrl === undefined) {
|
|
226
|
+
throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
join_url: joinUrl,
|
|
230
|
+
...(status.relay?.join_token_expires_at === undefined
|
|
231
|
+
? {}
|
|
232
|
+
: { expires_at: status.relay.join_token_expires_at }),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
throw this.#normalizeError(error);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
193
239
|
}
|
|
194
|
-
async stopProject(projectId) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
240
|
+
async stopProject(projectId, context) {
|
|
241
|
+
return await this.#runMutation(async () => {
|
|
242
|
+
try {
|
|
243
|
+
return {
|
|
244
|
+
message: await runStopCommand(projectId, this.#operationOptions(context)),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
throw this.#normalizeError(error);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
206
251
|
}
|
|
207
252
|
#normalizeError(error) {
|
|
208
253
|
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):
|
|
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
|
|
11
|
-
return (
|
|
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:
|
|
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
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import fastify from "fastify";
|
|
4
5
|
import { resolveTuttiHome } from "../../providers/openai/index.js";
|
|
6
|
+
import { readCliVersion } from "../cli/version.js";
|
|
5
7
|
import { registerHostWebStaticRoutes, resolvePackagedWebStaticRoot } from "../http/static-web.js";
|
|
6
|
-
import {
|
|
8
|
+
import { detectLocalFolderPicker, pickLocalFolder } from "./folder-picker.js";
|
|
9
|
+
import { LOCAL_CONSOLE_INVOCATION_ENV_KEYS, createLocalConsoleServiceEnvironment, } from "./invocation-context.js";
|
|
7
10
|
import { LocalConsoleProjectError, LocalConsoleProjectService, } from "./project-service.js";
|
|
8
11
|
import { LOCAL_CONSOLE_SESSION_COOKIE, LocalConsoleSessionRegistry, localConsoleSessionCookie, parseCookieHeader, } from "./session.js";
|
|
9
12
|
import { createLocalConsoleSecret, deleteLocalConsoleRuntimeEndpoint, writeLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
|
|
@@ -59,12 +62,13 @@ function requireLoopbackHost(request) {
|
|
|
59
62
|
}
|
|
60
63
|
export function createLocalConsoleServer(options) {
|
|
61
64
|
const app = fastify({ logger: false, bodyLimit: 64 * 1024 });
|
|
65
|
+
const tuttiHome = options.tuttiHome ?? resolveTuttiHome(options.env?.TUTTI_HOME, options.cwd);
|
|
62
66
|
const sessions = new LocalConsoleSessionRegistry({
|
|
63
67
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
64
68
|
});
|
|
65
69
|
const projects = new LocalConsoleProjectService({
|
|
66
|
-
|
|
67
|
-
...(options.env === undefined ? {} : {
|
|
70
|
+
tuttiHome,
|
|
71
|
+
...(options.env === undefined ? {} : { serviceEnvironment: options.env }),
|
|
68
72
|
});
|
|
69
73
|
app.addHook("onRequest", (request, reply, done) => {
|
|
70
74
|
try {
|
|
@@ -101,10 +105,11 @@ export function createLocalConsoleServer(options) {
|
|
|
101
105
|
if (!tokenEquals(bearerToken(request.headers.authorization), options.controlToken)) {
|
|
102
106
|
throw new LocalConsoleHttpError(401, "unauthorized", "Local Console control token is invalid.");
|
|
103
107
|
}
|
|
108
|
+
options.onActivity?.();
|
|
104
109
|
}
|
|
105
110
|
function requireSession(request, csrf = false) {
|
|
106
111
|
const cookie = parseCookieHeader(headerValue(request.headers.cookie));
|
|
107
|
-
const session = sessions.readSession(cookie[LOCAL_CONSOLE_SESSION_COOKIE]);
|
|
112
|
+
const session = sessions.readSession(cookie[LOCAL_CONSOLE_SESSION_COOKIE], headerValue(request.headers["x-local-console-context"]));
|
|
108
113
|
if (session === null) {
|
|
109
114
|
throw new LocalConsoleHttpError(401, "session_required", "Open Tutti from the CLI again.");
|
|
110
115
|
}
|
|
@@ -115,22 +120,65 @@ export function createLocalConsoleServer(options) {
|
|
|
115
120
|
throw new LocalConsoleHttpError(403, "csrf_rejected", "Local Console request was rejected.");
|
|
116
121
|
}
|
|
117
122
|
}
|
|
123
|
+
options.onActivity?.();
|
|
118
124
|
return session;
|
|
119
125
|
}
|
|
120
|
-
app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({
|
|
126
|
+
app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({
|
|
127
|
+
status: "ready",
|
|
128
|
+
protocol_version: 2,
|
|
129
|
+
...(options.runtime === undefined
|
|
130
|
+
? {}
|
|
131
|
+
: {
|
|
132
|
+
instance_id: options.runtime.instanceId,
|
|
133
|
+
version: options.runtime.version,
|
|
134
|
+
pid: options.runtime.pid,
|
|
135
|
+
}),
|
|
136
|
+
}));
|
|
137
|
+
app.get(`${LOCAL_CONSOLE_API_BASE}/control/status`, (request) => {
|
|
138
|
+
requireControl(request);
|
|
139
|
+
return {
|
|
140
|
+
status: "running",
|
|
141
|
+
protocol_version: 2,
|
|
142
|
+
...(options.runtime === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: {
|
|
145
|
+
instance_id: options.runtime.instanceId,
|
|
146
|
+
version: options.runtime.version,
|
|
147
|
+
entrypoint: options.runtime.entrypoint,
|
|
148
|
+
started_at: options.runtime.startedAt,
|
|
149
|
+
pid: options.runtime.pid,
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
app.post(`${LOCAL_CONSOLE_API_BASE}/control/shutdown`, (request) => {
|
|
154
|
+
requireControl(request);
|
|
155
|
+
setImmediate(() => options.onShutdown?.());
|
|
156
|
+
return { status: "stopping" };
|
|
157
|
+
});
|
|
121
158
|
app.post(`${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
|
|
122
159
|
schema: {
|
|
123
160
|
body: {
|
|
124
161
|
type: "object",
|
|
125
162
|
required: ["current_directory"],
|
|
126
163
|
additionalProperties: false,
|
|
127
|
-
properties: {
|
|
164
|
+
properties: {
|
|
165
|
+
current_directory: { type: "string", minLength: 1, maxLength: 4096 },
|
|
166
|
+
environment: {
|
|
167
|
+
type: "object",
|
|
168
|
+
additionalProperties: false,
|
|
169
|
+
properties: Object.fromEntries(LOCAL_CONSOLE_INVOCATION_ENV_KEYS.map((key) => [
|
|
170
|
+
key,
|
|
171
|
+
{ type: "string", minLength: 1, maxLength: 4096 },
|
|
172
|
+
])),
|
|
173
|
+
},
|
|
174
|
+
},
|
|
128
175
|
},
|
|
129
176
|
},
|
|
130
177
|
}, (request) => {
|
|
131
178
|
requireControl(request);
|
|
132
179
|
const access = sessions.issueAccessToken({
|
|
133
180
|
currentDirectory: resolve(request.body.current_directory),
|
|
181
|
+
environment: request.body.environment ?? {},
|
|
134
182
|
});
|
|
135
183
|
return { access_token: access.token, expires_at: access.expires_at };
|
|
136
184
|
});
|
|
@@ -145,51 +193,41 @@ export function createLocalConsoleServer(options) {
|
|
|
145
193
|
},
|
|
146
194
|
}, (request, reply) => {
|
|
147
195
|
requireSameOrigin(request);
|
|
148
|
-
const
|
|
196
|
+
const cookie = parseCookieHeader(headerValue(request.headers.cookie));
|
|
197
|
+
const session = sessions.exchangeAccessToken(request.body.access_token, cookie[LOCAL_CONSOLE_SESSION_COOKIE]);
|
|
149
198
|
if (session === null) {
|
|
150
199
|
throw new LocalConsoleHttpError(401, "access_token_invalid", "This Local Console access link has expired. Run `tutti` again.");
|
|
151
200
|
}
|
|
152
|
-
|
|
153
|
-
|
|
201
|
+
if (session.sessionToken !== undefined) {
|
|
202
|
+
void reply.header("set-cookie", localConsoleSessionCookie(session.sessionToken, session.expires_at));
|
|
203
|
+
}
|
|
204
|
+
options.onActivity?.();
|
|
205
|
+
return {
|
|
206
|
+
csrf_token: session.csrfToken,
|
|
207
|
+
context_token: session.contextToken,
|
|
208
|
+
expires_at: session.expires_at,
|
|
209
|
+
};
|
|
154
210
|
});
|
|
155
211
|
app.get(`${LOCAL_CONSOLE_API_BASE}/bootstrap`, (request) => {
|
|
156
212
|
const session = requireSession(request);
|
|
157
|
-
const picker = detectLocalFolderPicker(process.platform, options.env ?? process.env);
|
|
158
213
|
return {
|
|
159
214
|
csrf_token: session.csrfToken,
|
|
160
|
-
current_directory: session.currentDirectory,
|
|
215
|
+
current_directory: session.context.currentDirectory,
|
|
161
216
|
platform: process.platform,
|
|
162
|
-
folder_picker:
|
|
217
|
+
folder_picker: detectLocalFolderPicker(process.platform, session.context.environment).kind,
|
|
163
218
|
};
|
|
164
219
|
});
|
|
165
220
|
app.get(`${LOCAL_CONSOLE_API_BASE}/projects`, async (request) => {
|
|
221
|
+
const session = requireSession(request);
|
|
222
|
+
return { projects: await projects.listProjects(session.context) };
|
|
223
|
+
});
|
|
224
|
+
app.get(`${LOCAL_CONSOLE_API_BASE}/heartbeat`, (request) => {
|
|
166
225
|
requireSession(request);
|
|
167
|
-
return {
|
|
226
|
+
return { status: "ok" };
|
|
168
227
|
});
|
|
169
228
|
app.post(`${LOCAL_CONSOLE_API_BASE}/folders/pick`, async (request) => {
|
|
170
|
-
requireSession(request, true);
|
|
171
|
-
return await pickLocalFolder({ env:
|
|
172
|
-
});
|
|
173
|
-
app.post(`${LOCAL_CONSOLE_API_BASE}/folders/create`, {
|
|
174
|
-
schema: {
|
|
175
|
-
body: {
|
|
176
|
-
type: "object",
|
|
177
|
-
required: ["parent_path", "name"],
|
|
178
|
-
additionalProperties: false,
|
|
179
|
-
properties: {
|
|
180
|
-
parent_path: { type: "string", minLength: 1 },
|
|
181
|
-
name: { type: "string", minLength: 1, maxLength: 160 },
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
}, (request) => {
|
|
186
|
-
requireSession(request, true);
|
|
187
|
-
try {
|
|
188
|
-
return { path: createLocalProjectFolder(request.body.parent_path, request.body.name) };
|
|
189
|
-
}
|
|
190
|
-
catch (error) {
|
|
191
|
-
throw new LocalConsoleHttpError(422, "project_folder_create_failed", error instanceof Error ? error.message : "Project folder could not be created.");
|
|
192
|
-
}
|
|
229
|
+
const session = requireSession(request, true);
|
|
230
|
+
return await pickLocalFolder({ env: session.context.environment });
|
|
193
231
|
});
|
|
194
232
|
app.post(`${LOCAL_CONSOLE_API_BASE}/providers/models`, {
|
|
195
233
|
schema: {
|
|
@@ -232,19 +270,19 @@ export function createLocalConsoleServer(options) {
|
|
|
232
270
|
},
|
|
233
271
|
},
|
|
234
272
|
}, async (request) => {
|
|
235
|
-
requireSession(request, true);
|
|
273
|
+
const session = requireSession(request, true);
|
|
236
274
|
return await projects.launchProject({
|
|
237
275
|
workspacePath: request.body.workspace_path,
|
|
238
276
|
...(request.body.provider === undefined ? {} : { provider: request.body.provider }),
|
|
239
|
-
});
|
|
277
|
+
}, session.context);
|
|
240
278
|
});
|
|
241
279
|
app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/invite`, async (request) => {
|
|
242
|
-
requireSession(request, true);
|
|
243
|
-
return await projects.refreshInvite(request.params.projectId);
|
|
280
|
+
const session = requireSession(request, true);
|
|
281
|
+
return await projects.refreshInvite(request.params.projectId, session.context);
|
|
244
282
|
});
|
|
245
283
|
app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/stop`, async (request) => {
|
|
246
|
-
requireSession(request, true);
|
|
247
|
-
return await projects.stopProject(request.params.projectId);
|
|
284
|
+
const session = requireSession(request, true);
|
|
285
|
+
return await projects.stopProject(request.params.projectId, session.context);
|
|
248
286
|
});
|
|
249
287
|
const webStaticRoot = options.webStaticRoot === false
|
|
250
288
|
? undefined
|
|
@@ -261,24 +299,64 @@ function listeningPort(app) {
|
|
|
261
299
|
}
|
|
262
300
|
return address.port;
|
|
263
301
|
}
|
|
302
|
+
function entrypointIdentity(path) {
|
|
303
|
+
try {
|
|
304
|
+
const stats = statSync(path);
|
|
305
|
+
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
264
311
|
export async function runLocalConsoleProcess(options = {}) {
|
|
265
312
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
266
313
|
const env = options.env ?? process.env;
|
|
267
314
|
const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
|
|
315
|
+
const serviceEnvironment = createLocalConsoleServiceEnvironment({ env, tuttiHome });
|
|
268
316
|
const controlToken = createLocalConsoleSecret();
|
|
317
|
+
const instanceId = createLocalConsoleSecret();
|
|
318
|
+
const now = options.now ?? (() => new Date());
|
|
319
|
+
const startedAt = now().toISOString();
|
|
320
|
+
const version = readCliVersion();
|
|
321
|
+
const entrypointArgument = process.argv[1];
|
|
322
|
+
if (entrypointArgument === undefined || entrypointArgument.trim() === "") {
|
|
323
|
+
throw new Error("Cannot locate the Tutti CLI entrypoint.");
|
|
324
|
+
}
|
|
325
|
+
const entrypoint = resolve(entrypointArgument);
|
|
326
|
+
const initialEntrypointIdentity = entrypointIdentity(entrypoint);
|
|
327
|
+
if (initialEntrypointIdentity === null) {
|
|
328
|
+
throw new Error("The Tutti CLI entrypoint is unavailable.");
|
|
329
|
+
}
|
|
330
|
+
let lastActivityAt = now().getTime();
|
|
331
|
+
let requestClose = () => undefined;
|
|
269
332
|
const app = createLocalConsoleServer({
|
|
270
333
|
controlToken,
|
|
271
334
|
cwd,
|
|
272
|
-
|
|
335
|
+
tuttiHome,
|
|
336
|
+
env: serviceEnvironment,
|
|
337
|
+
runtime: {
|
|
338
|
+
instanceId,
|
|
339
|
+
version,
|
|
340
|
+
entrypoint,
|
|
341
|
+
startedAt,
|
|
342
|
+
pid: process.pid,
|
|
343
|
+
},
|
|
344
|
+
onActivity: () => {
|
|
345
|
+
lastActivityAt = now().getTime();
|
|
346
|
+
},
|
|
347
|
+
onShutdown: () => requestClose(),
|
|
273
348
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
274
349
|
});
|
|
275
350
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
276
351
|
writeLocalConsoleRuntimeEndpoint({
|
|
277
352
|
tuttiHome,
|
|
353
|
+
instanceId,
|
|
278
354
|
pid: process.pid,
|
|
279
355
|
baseUrl: `http://127.0.0.1:${listeningPort(app)}`,
|
|
280
356
|
controlToken,
|
|
281
|
-
|
|
357
|
+
version,
|
|
358
|
+
entrypoint,
|
|
359
|
+
now,
|
|
282
360
|
});
|
|
283
361
|
let closing = false;
|
|
284
362
|
const close = async () => {
|
|
@@ -290,12 +368,41 @@ export async function runLocalConsoleProcess(options = {}) {
|
|
|
290
368
|
tuttiHome,
|
|
291
369
|
expectedControlToken: controlToken,
|
|
292
370
|
});
|
|
293
|
-
|
|
371
|
+
const forceClose = setTimeout(() => app.server.closeAllConnections(), 5_000);
|
|
372
|
+
forceClose.unref();
|
|
373
|
+
try {
|
|
374
|
+
await app.close();
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
clearTimeout(forceClose);
|
|
378
|
+
}
|
|
294
379
|
};
|
|
380
|
+
requestClose = () => void close();
|
|
381
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? 60 * 60 * 1_000;
|
|
382
|
+
const idleCheck = setInterval(() => {
|
|
383
|
+
let installedVersion = null;
|
|
384
|
+
try {
|
|
385
|
+
installedVersion = readCliVersion();
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// Package removal is handled as a runtime identity change.
|
|
389
|
+
}
|
|
390
|
+
if (!existsSync(entrypoint) ||
|
|
391
|
+
entrypointIdentity(entrypoint) !== initialEntrypointIdentity ||
|
|
392
|
+
installedVersion !== version) {
|
|
393
|
+
void close();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (now().getTime() - lastActivityAt >= idleTimeoutMs) {
|
|
397
|
+
void close();
|
|
398
|
+
}
|
|
399
|
+
}, Math.min(60_000, Math.max(1_000, Math.floor(idleTimeoutMs / 4))));
|
|
400
|
+
idleCheck.unref();
|
|
295
401
|
process.once("SIGINT", () => void close());
|
|
296
402
|
process.once("SIGTERM", () => void close());
|
|
297
403
|
await new Promise((resolveClosed) => {
|
|
298
404
|
app.server.once("close", resolveClosed);
|
|
299
405
|
});
|
|
406
|
+
clearInterval(idleCheck);
|
|
300
407
|
}
|
|
301
408
|
//# sourceMappingURL=server.js.map
|