@xfey/tenso 0.1.134 → 0.1.135
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/args.d.ts +2 -0
- package/dist/server-shell/cli/args.js +4 -2
- package/dist/server-shell/cli/cli.js +9 -0
- package/dist/server-shell/desktop-integration/manager.js +4 -0
- package/dist/server-shell/desktop-runtime/bridge.d.ts +2 -0
- package/dist/server-shell/desktop-runtime/bridge.js +101 -0
- package/dist/server-shell/desktop-runtime/installation.d.ts +4 -0
- package/dist/server-shell/desktop-runtime/installation.js +61 -0
- package/dist/server-shell/desktop-runtime/protocol.d.ts +10 -0
- package/dist/server-shell/desktop-runtime/protocol.js +22 -0
- package/dist/server-shell/desktop-runtime/session.d.ts +32 -0
- package/dist/server-shell/desktop-runtime/session.js +187 -0
- package/dist/server-shell/local-console/package-update-lock.d.ts +3 -0
- package/dist/server-shell/local-console/package-update-lock.js +16 -1
- package/dist/server-shell/local-console/server.js +43 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +1 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +7 -1
- package/package.json +1 -1
- package/web/assets/{homepage-motion-scene-VTaJfW68.js → homepage-motion-scene-Cg4GsGlM.js} +1 -1
- package/web/assets/{index-CoiyXibu.js → index-BEKodsau.js} +2 -2
- package/web/index.html +1 -1
|
@@ -149,11 +149,13 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
149
149
|
: "internal-package-update-complete",
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
|
-
if (internalCommand === "console-run") {
|
|
152
|
+
if (internalCommand === "console-run" || internalCommand === "desktop-runtime") {
|
|
153
153
|
if (args.length > 0) {
|
|
154
154
|
throw new LaunchError("git_bootstrap_failed", "Internal console-run does not accept options", "Internal commands are managed by Tenso.");
|
|
155
155
|
}
|
|
156
|
-
return {
|
|
156
|
+
return {
|
|
157
|
+
kind: internalCommand === "console-run" ? "internal-console-run" : "internal-desktop-runtime",
|
|
158
|
+
};
|
|
157
159
|
}
|
|
158
160
|
if (internalCommand === "desktop-open") {
|
|
159
161
|
if (args.length > 1) {
|
|
@@ -14,6 +14,8 @@ import { resolveRelayUrl } from "./launch.js";
|
|
|
14
14
|
import { ensureLocalConsole, openLocalConsoleBrowser, readLocalConsoleServiceStatus, restartLocalConsoleService, runLocalConsoleProcess, stopLocalConsoleService, } from "../local-console/index.js";
|
|
15
15
|
import { runPackageUpdateCompletionProtocol, runPackageUpdateWorkerProtocol, } from "../local-console/package-update-process.js";
|
|
16
16
|
import { recoverPackageUpdateForConsoleStartup } from "../local-console/package-update-recovery.js";
|
|
17
|
+
import { runDesktopRuntimeBridge } from "../desktop-runtime/bridge.js";
|
|
18
|
+
import { openInstalledMacApplication } from "../desktop-runtime/installation.js";
|
|
17
19
|
const HELP_TEXT = `Usage:
|
|
18
20
|
tenso
|
|
19
21
|
tenso launch [target] [--yes] [--foreground]
|
|
@@ -235,6 +237,10 @@ async function ensureUserLocalConsole(options) {
|
|
|
235
237
|
return (await ensureLocalConsole({ cwd: options.cwd, env, source: options.source })).url;
|
|
236
238
|
}
|
|
237
239
|
async function runLocalConsoleCommand() {
|
|
240
|
+
if (openInstalledMacApplication()) {
|
|
241
|
+
process.stdout.write("Opened Tenso for Mac.\n");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
238
244
|
const url = await ensureUserLocalConsole({ cwd: process.cwd(), source: "terminal" });
|
|
239
245
|
if (openLocalConsoleBrowser(url)) {
|
|
240
246
|
process.stdout.write(`Tenso is ready at ${new URL(url).origin}/console\n`);
|
|
@@ -407,6 +413,9 @@ try {
|
|
|
407
413
|
case "internal-console-run":
|
|
408
414
|
await runLocalConsoleProcess();
|
|
409
415
|
break;
|
|
416
|
+
case "internal-desktop-runtime":
|
|
417
|
+
await runDesktopRuntimeBridge();
|
|
418
|
+
break;
|
|
410
419
|
case "internal-desktop-open":
|
|
411
420
|
await runDesktopOpenCommand(command.url);
|
|
412
421
|
break;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
+
import { installedMacApplication } from "../desktop-runtime/installation.js";
|
|
2
3
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { resolveTuttiHome } from "../../providers/openai/index.js";
|
|
@@ -139,6 +140,9 @@ function createRealContext(options) {
|
|
|
139
140
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
140
141
|
const env = options.env ?? process.env;
|
|
141
142
|
const platform = options.platform ?? process.platform;
|
|
143
|
+
const nativeApplication = platform === "darwin" ? installedMacApplication(env) : null;
|
|
144
|
+
if (nativeApplication !== null)
|
|
145
|
+
return { status: "installed", entry_path: nativeApplication, changed: false };
|
|
142
146
|
const runner = options.runner ?? runDesktopCommand;
|
|
143
147
|
const packageIdentity = readCliPackageIdentity();
|
|
144
148
|
const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { resolveTuttiHome } from "../../providers/openai/index.js";
|
|
4
|
+
import { ensureLocalConsole, readLocalConsoleServiceStatus, } from "../local-console/managed-console.js";
|
|
5
|
+
import { readLocalConsoleRuntimeEndpoint, } from "../local-console/runtime-endpoint.js";
|
|
6
|
+
import { executeDesktopOpenIntent, parseTuttiDesktopUrl } from "../desktop-integration/index.js";
|
|
7
|
+
import { resolveRelayUrl } from "../cli/launch.js";
|
|
8
|
+
import { parseDesktopRequest } from "./protocol.js";
|
|
9
|
+
import { retireOwnedNpmLauncher } from "./installation.js";
|
|
10
|
+
async function control(endpoint, path, body) {
|
|
11
|
+
const response = await fetch(`${endpoint.base_url}/local-console/v1/control/${path}`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
authorization: `Bearer ${endpoint.control_token}`,
|
|
15
|
+
"content-type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify(body ?? {}),
|
|
18
|
+
signal: AbortSignal.timeout(300_000),
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new Error(response.status === 404 ? "previous_installation_running" : "service_unavailable");
|
|
22
|
+
return (await response.json());
|
|
23
|
+
}
|
|
24
|
+
export async function runDesktopRuntimeBridge() {
|
|
25
|
+
const cwd = dirname(resolve(process.argv[1]));
|
|
26
|
+
const tuttiHome = resolveTuttiHome(process.env.TUTTI_HOME, cwd);
|
|
27
|
+
let booted = false;
|
|
28
|
+
let consoleInstance;
|
|
29
|
+
let inputBytes = 0;
|
|
30
|
+
process.stdin.on("data", (chunk) => {
|
|
31
|
+
inputBytes += chunk.length;
|
|
32
|
+
if (inputBytes > 64 * 1024)
|
|
33
|
+
process.exit(1);
|
|
34
|
+
});
|
|
35
|
+
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
36
|
+
for await (const line of input) {
|
|
37
|
+
inputBytes = 0;
|
|
38
|
+
let id = 0;
|
|
39
|
+
try {
|
|
40
|
+
const request = parseDesktopRequest(line);
|
|
41
|
+
id = request.id;
|
|
42
|
+
const intent = request.action === "protocol" ? parseTuttiDesktopUrl(request.url) : undefined;
|
|
43
|
+
let openUrl;
|
|
44
|
+
let result = { status: "ready" };
|
|
45
|
+
const current = readLocalConsoleRuntimeEndpoint(tuttiHome);
|
|
46
|
+
if (request.action === "quit" && current === null)
|
|
47
|
+
result = { status: "stopped" };
|
|
48
|
+
else if (request.action === "status" && current === null)
|
|
49
|
+
result = { status: "blocked", reason: "recovery_required" };
|
|
50
|
+
else if (request.action === "quit" || request.action === "status") {
|
|
51
|
+
result = await control(current, "desktop-session", {
|
|
52
|
+
action: request.action === "quit" ? "exit" : "status",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
if (!booted && current !== null) {
|
|
57
|
+
const status = await readLocalConsoleServiceStatus({ cwd });
|
|
58
|
+
if (status.status === "running" && status.update_required) {
|
|
59
|
+
const stopped = await control(current, "idle-shutdown");
|
|
60
|
+
if (stopped.status === "blocked") {
|
|
61
|
+
process.stdout.write(JSON.stringify({ id, ...stopped }) + "\n");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const access = await ensureLocalConsole({ cwd, source: "desktop" });
|
|
67
|
+
if (!booted || consoleInstance !== access.endpoint.instance_id) {
|
|
68
|
+
result = await control(access.endpoint, "desktop-session", { action: "restore" });
|
|
69
|
+
booted = result.status === "ready";
|
|
70
|
+
consoleInstance = booted ? access.endpoint.instance_id : undefined;
|
|
71
|
+
if (booted)
|
|
72
|
+
retireOwnedNpmLauncher(tuttiHome);
|
|
73
|
+
}
|
|
74
|
+
if (result.status === "ready") {
|
|
75
|
+
if (request.action === "protocol") {
|
|
76
|
+
await executeDesktopOpenIntent({
|
|
77
|
+
intent: intent,
|
|
78
|
+
accessUrl: access.url,
|
|
79
|
+
relayUrl: resolveRelayUrl(process.env),
|
|
80
|
+
openBrowser: (url) => {
|
|
81
|
+
openUrl = url;
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
openUrl = access.url;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(JSON.stringify({ id, ...result, ...(openUrl ? { open_url: openUrl } : {}) }) + "\n");
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const reason = error instanceof Error &&
|
|
94
|
+
["invalid_request", "previous_installation_running"].includes(error.message)
|
|
95
|
+
? error.message
|
|
96
|
+
: "service_unavailable";
|
|
97
|
+
process.stdout.write(JSON.stringify({ id, status: "error", reason }) + "\n");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=bridge.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function installedMacApplication(env?: NodeJS.ProcessEnv): string | null;
|
|
2
|
+
export declare function openInstalledMacApplication(): boolean;
|
|
3
|
+
export declare function retireOwnedNpmLauncher(tuttiHome: string): void;
|
|
4
|
+
//# sourceMappingURL=installation.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { readCliPackageIdentity } from "../cli/version.js";
|
|
6
|
+
import { createMacosDesktopAdapter } from "../desktop-integration/macos.js";
|
|
7
|
+
import { readDesktopIntegrationRecord, writeDesktopIntegrationRecord, } from "../desktop-integration/record.js";
|
|
8
|
+
export function installedMacApplication(env = process.env) {
|
|
9
|
+
if (process.platform !== "darwin" ||
|
|
10
|
+
(env.TUTTI_HOME !== undefined && resolve(env.TUTTI_HOME) !== join(homedir(), ".tutti")))
|
|
11
|
+
return null;
|
|
12
|
+
for (const app of ["/Applications/Tenso.app", join(homedir(), "Applications/Tenso.app")]) {
|
|
13
|
+
try {
|
|
14
|
+
const distribution = JSON.parse(readFileSync(join(app, "Contents/Resources/desktop-distribution.json"), "utf8"));
|
|
15
|
+
if (distribution.schema_version === 1 &&
|
|
16
|
+
typeof distribution.version === "string" &&
|
|
17
|
+
existsSync(join(app, "Contents/MacOS/Tenso")))
|
|
18
|
+
return app;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* An npm-generated launcher or an unrelated app is not a distribution. */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function openInstalledMacApplication() {
|
|
27
|
+
const app = installedMacApplication();
|
|
28
|
+
if (app === null)
|
|
29
|
+
return false;
|
|
30
|
+
return spawnSync("/usr/bin/open", ["-a", app], { stdio: "ignore", shell: false }).status === 0;
|
|
31
|
+
}
|
|
32
|
+
export function retireOwnedNpmLauncher(tuttiHome) {
|
|
33
|
+
const app = installedMacApplication();
|
|
34
|
+
if (app === null || resolve(tuttiHome) !== join(homedir(), ".tutti"))
|
|
35
|
+
return;
|
|
36
|
+
const current = readCliPackageIdentity();
|
|
37
|
+
if (current.root !== join(app, "Contents/Resources/host"))
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
const adapter = createMacosDesktopAdapter({
|
|
41
|
+
homeDir: homedir(),
|
|
42
|
+
assetRoot: join(current.root, "desktop-assets"),
|
|
43
|
+
});
|
|
44
|
+
const record = readDesktopIntegrationRecord(tuttiHome);
|
|
45
|
+
if (record?.state !== "installed" ||
|
|
46
|
+
record.entry_path !== adapter.entryPath ||
|
|
47
|
+
adapter.entryPath === app ||
|
|
48
|
+
adapter.inspect() !== "owned")
|
|
49
|
+
return;
|
|
50
|
+
adapter.remove();
|
|
51
|
+
writeDesktopIntegrationRecord(tuttiHome, {
|
|
52
|
+
...record,
|
|
53
|
+
state: "removed",
|
|
54
|
+
updated_at: new Date().toISOString(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* Unknown or damaged ownership records are never removed. */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=installation.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type DesktopRequest = {
|
|
2
|
+
id: number;
|
|
3
|
+
action: "boot" | "open" | "status" | "quit";
|
|
4
|
+
} | {
|
|
5
|
+
id: number;
|
|
6
|
+
action: "protocol";
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function parseDesktopRequest(line: string): DesktopRequest;
|
|
10
|
+
//# sourceMappingURL=protocol.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function parseDesktopRequest(line) {
|
|
2
|
+
if (Buffer.byteLength(line) > 8192)
|
|
3
|
+
throw new Error("invalid_request");
|
|
4
|
+
const value = JSON.parse(line);
|
|
5
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
6
|
+
throw new Error("invalid_request");
|
|
7
|
+
const v = value;
|
|
8
|
+
if (!Number.isSafeInteger(v.id) || v.id < 1)
|
|
9
|
+
throw new Error("invalid_request");
|
|
10
|
+
if (v.action === "protocol" &&
|
|
11
|
+
typeof v.url === "string" &&
|
|
12
|
+
v.url.length < 4096 &&
|
|
13
|
+
Object.keys(v).sort().join() === "action,id,url") {
|
|
14
|
+
return v;
|
|
15
|
+
}
|
|
16
|
+
if (["boot", "open", "status", "quit"].includes(v.action) &&
|
|
17
|
+
Object.keys(v).sort().join() === "action,id") {
|
|
18
|
+
return v;
|
|
19
|
+
}
|
|
20
|
+
throw new Error("invalid_request");
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ProjectId } from "@tutti/shared/ids";
|
|
2
|
+
import type { LocalConsoleOperationCoordinator } from "../local-console/operation-coordinator.js";
|
|
3
|
+
import { type PackageUpdateHostReadiness } from "../local-console/package-update-service.js";
|
|
4
|
+
import { restorePackageUpdateHosts, stopPackageUpdateHost } from "../local-console/package-update-hosts.js";
|
|
5
|
+
import { type PackageUpdateLockAcquisition } from "../local-console/package-update-lock.js";
|
|
6
|
+
export type DesktopResult = {
|
|
7
|
+
status: "ready" | "blocked" | "stopped";
|
|
8
|
+
reason?: "host_busy" | "host_identity_unverified" | "console_busy" | "update_in_progress" | "recovery_required";
|
|
9
|
+
running_projects?: number;
|
|
10
|
+
restore_failed?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function readDesktopRestoreSet(tuttiHome: string): ProjectId[];
|
|
13
|
+
export declare function writeDesktopRestoreSet(tuttiHome: string, ids: readonly ProjectId[]): void;
|
|
14
|
+
type DesktopSessionOptions = {
|
|
15
|
+
tuttiHome: string;
|
|
16
|
+
env: NodeJS.ProcessEnv;
|
|
17
|
+
operations: LocalConsoleOperationCoordinator;
|
|
18
|
+
inspect?: () => Promise<PackageUpdateHostReadiness>;
|
|
19
|
+
stop?: typeof stopPackageUpdateHost;
|
|
20
|
+
restore?: typeof restorePackageUpdateHosts;
|
|
21
|
+
acquireLock?: () => PackageUpdateLockAcquisition;
|
|
22
|
+
};
|
|
23
|
+
export declare class DesktopSessionService {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(options: DesktopSessionOptions);
|
|
26
|
+
status(): Promise<DesktopResult>;
|
|
27
|
+
restore(): Promise<DesktopResult>;
|
|
28
|
+
prepareExit(): Promise<DesktopResult>;
|
|
29
|
+
dispose(): void;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { inspectMachineProjects } from "../cli/machine-project-inspector.js";
|
|
5
|
+
import { readHostLocalPackageUpdateReadiness } from "../cli/local-control-client.js";
|
|
6
|
+
import { inspectPackageUpdateHosts, } from "../local-console/package-update-service.js";
|
|
7
|
+
import { restorePackageUpdateHosts, stopPackageUpdateHost, } from "../local-console/package-update-hosts.js";
|
|
8
|
+
import { tryAcquirePackageUpdateLock, recoverDesktopPackageUpdateLock, } from "../local-console/package-update-lock.js";
|
|
9
|
+
export function readDesktopRestoreSet(tuttiHome) {
|
|
10
|
+
const path = join(tuttiHome, "desktop", "session.json");
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return [];
|
|
13
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
15
|
+
throw new Error("desktop_record_invalid");
|
|
16
|
+
const record = value;
|
|
17
|
+
if (record.schema_version !== 1 ||
|
|
18
|
+
Object.keys(record).sort().join() !== "restore_project_ids,schema_version" ||
|
|
19
|
+
!Array.isArray(record.restore_project_ids) ||
|
|
20
|
+
record.restore_project_ids.length > 10000 ||
|
|
21
|
+
!record.restore_project_ids.every((id) => typeof id === "string" && /^prj_[0-9A-HJKMNP-TV-Z]{26}$/u.test(id))) {
|
|
22
|
+
throw new Error("desktop_record_invalid");
|
|
23
|
+
}
|
|
24
|
+
return [...new Set(record.restore_project_ids)];
|
|
25
|
+
}
|
|
26
|
+
export function writeDesktopRestoreSet(tuttiHome, ids) {
|
|
27
|
+
const path = join(tuttiHome, "desktop", "session.json");
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
29
|
+
chmodSync(dirname(path), 0o700);
|
|
30
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(temporary, JSON.stringify({ schema_version: 1, restore_project_ids: [...new Set(ids)] }) + "\n", { mode: 0o600, flag: "wx" });
|
|
33
|
+
renameSync(temporary, path);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
if (existsSync(temporary))
|
|
37
|
+
unlinkSync(temporary);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class DesktopSessionService {
|
|
41
|
+
#options;
|
|
42
|
+
#release;
|
|
43
|
+
#stopped = false;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.#options = options;
|
|
46
|
+
}
|
|
47
|
+
async #inspect() {
|
|
48
|
+
if (this.#options.inspect)
|
|
49
|
+
return this.#options.inspect();
|
|
50
|
+
return inspectPackageUpdateHosts({
|
|
51
|
+
projects: await inspectMachineProjects({ tuttiHome: this.#options.tuttiHome }),
|
|
52
|
+
readReadiness: async (project) => project.endpoint.kind === "valid"
|
|
53
|
+
? readHostLocalPackageUpdateReadiness({ endpoint: project.endpoint.record })
|
|
54
|
+
: "unsupported",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async status() {
|
|
58
|
+
if (this.#stopped)
|
|
59
|
+
return { status: "stopped" };
|
|
60
|
+
if (this.#options.operations.state() !== "idle")
|
|
61
|
+
return { status: "blocked", reason: "console_busy" };
|
|
62
|
+
const result = await this.#inspect();
|
|
63
|
+
return result.status === "ready"
|
|
64
|
+
? { status: "ready", running_projects: result.restore_project_ids.length }
|
|
65
|
+
: { status: "blocked", reason: result.reason_code };
|
|
66
|
+
}
|
|
67
|
+
#acquire() {
|
|
68
|
+
const operation = this.#options.operations.tryBeginPackageUpdate();
|
|
69
|
+
if (!operation)
|
|
70
|
+
return { status: "blocked", reason: "console_busy" };
|
|
71
|
+
let lock;
|
|
72
|
+
try {
|
|
73
|
+
recoverDesktopPackageUpdateLock(this.#options.tuttiHome);
|
|
74
|
+
lock =
|
|
75
|
+
this.#options.acquireLock?.() ??
|
|
76
|
+
tryAcquirePackageUpdateLock({ tuttiHome: this.#options.tuttiHome, purpose: "desktop" });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
operation.release();
|
|
80
|
+
return { status: "blocked", reason: "recovery_required" };
|
|
81
|
+
}
|
|
82
|
+
if (lock.status !== "acquired") {
|
|
83
|
+
operation.release();
|
|
84
|
+
return {
|
|
85
|
+
status: "blocked",
|
|
86
|
+
reason: lock.status === "active" ? "update_in_progress" : "recovery_required",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
this.#release = () => {
|
|
90
|
+
lock.lease.release();
|
|
91
|
+
operation.release();
|
|
92
|
+
};
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
async restore() {
|
|
96
|
+
const blocked = this.#acquire();
|
|
97
|
+
if (blocked)
|
|
98
|
+
return blocked;
|
|
99
|
+
try {
|
|
100
|
+
const ids = readDesktopRestoreSet(this.#options.tuttiHome);
|
|
101
|
+
const result = await this.#restore(ids);
|
|
102
|
+
writeDesktopRestoreSet(this.#options.tuttiHome, result.failed_project_ids);
|
|
103
|
+
return { status: "ready", restore_failed: result.failed_project_ids.length };
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.dispose();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
#restore(projectIds) {
|
|
110
|
+
return (this.#options.restore ?? restorePackageUpdateHosts)({
|
|
111
|
+
tuttiHome: this.#options.tuttiHome,
|
|
112
|
+
projectIds,
|
|
113
|
+
env: this.#options.env,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async prepareExit() {
|
|
117
|
+
if (this.#stopped)
|
|
118
|
+
return { status: "stopped" };
|
|
119
|
+
const blocked = this.#acquire();
|
|
120
|
+
if (blocked)
|
|
121
|
+
return blocked;
|
|
122
|
+
const stopped = [];
|
|
123
|
+
let previous;
|
|
124
|
+
let recoveryWritten = false;
|
|
125
|
+
try {
|
|
126
|
+
const readiness = await this.#inspect();
|
|
127
|
+
if (readiness.status === "blocked")
|
|
128
|
+
return { status: "blocked", reason: readiness.reason_code };
|
|
129
|
+
try {
|
|
130
|
+
previous = readDesktopRestoreSet(this.#options.tuttiHome);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return { status: "blocked", reason: "recovery_required" };
|
|
134
|
+
}
|
|
135
|
+
// Persist before stopping the first Host, so a crash can restore partial progress.
|
|
136
|
+
writeDesktopRestoreSet(this.#options.tuttiHome, [
|
|
137
|
+
...previous,
|
|
138
|
+
...readiness.restore_project_ids,
|
|
139
|
+
]);
|
|
140
|
+
recoveryWritten = true;
|
|
141
|
+
for (const projectId of readiness.restore_project_ids) {
|
|
142
|
+
const result = await (this.#options.stop ?? stopPackageUpdateHost)({
|
|
143
|
+
tuttiHome: this.#options.tuttiHome,
|
|
144
|
+
projectId,
|
|
145
|
+
});
|
|
146
|
+
if (result !== "stopped") {
|
|
147
|
+
// A rejected response may follow an accepted shutdown whose response was lost.
|
|
148
|
+
// Reconcile the attempted Host as well; restore skips verified running Hosts.
|
|
149
|
+
const restored = await this.#restore([...stopped, projectId]);
|
|
150
|
+
writeDesktopRestoreSet(this.#options.tuttiHome, [
|
|
151
|
+
...previous,
|
|
152
|
+
...restored.failed_project_ids,
|
|
153
|
+
]);
|
|
154
|
+
return {
|
|
155
|
+
status: "blocked",
|
|
156
|
+
reason: result,
|
|
157
|
+
restore_failed: restored.failed_project_ids.length,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
stopped.push(projectId);
|
|
161
|
+
}
|
|
162
|
+
this.#stopped = true;
|
|
163
|
+
// Hold both leases until Console closes, preventing a new Launch during quit/update.
|
|
164
|
+
return { status: "stopped" };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
if (!recoveryWritten)
|
|
168
|
+
return { status: "blocked", reason: "recovery_required" };
|
|
169
|
+
const restored = await this.#restore(readDesktopRestoreSet(this.#options.tuttiHome));
|
|
170
|
+
writeDesktopRestoreSet(this.#options.tuttiHome, restored.failed_project_ids);
|
|
171
|
+
return {
|
|
172
|
+
status: "blocked",
|
|
173
|
+
reason: "recovery_required",
|
|
174
|
+
restore_failed: restored.failed_project_ids.length,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
if (!this.#stopped)
|
|
179
|
+
this.dispose();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
dispose() {
|
|
183
|
+
this.#release?.();
|
|
184
|
+
this.#release = undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -29,6 +29,7 @@ export declare function tryAcquirePackageUpdateLock(options: {
|
|
|
29
29
|
pid?: number;
|
|
30
30
|
staleMs?: number;
|
|
31
31
|
processAlive?: (pid: number) => boolean;
|
|
32
|
+
purpose?: "desktop";
|
|
32
33
|
}): PackageUpdateLockAcquisition;
|
|
33
34
|
export declare function removeRecoverablePackageUpdateLock(options: {
|
|
34
35
|
tuttiHome: string;
|
|
@@ -36,6 +37,8 @@ export declare function removeRecoverablePackageUpdateLock(options: {
|
|
|
36
37
|
staleMs?: number;
|
|
37
38
|
processAlive?: (pid: number) => boolean;
|
|
38
39
|
}): boolean;
|
|
40
|
+
/** Only native lifecycle locks with a proven dead owner; never a pending npm update. */
|
|
41
|
+
export declare function recoverDesktopPackageUpdateLock(tuttiHome: string): void;
|
|
39
42
|
export declare function transferPackageUpdateLockFromCurrentProcess(options: {
|
|
40
43
|
tuttiHome: string;
|
|
41
44
|
nextPid: number;
|
|
@@ -92,7 +92,7 @@ export function tryAcquirePackageUpdateLock(options) {
|
|
|
92
92
|
const path = packageUpdateLockPath(options.tuttiHome);
|
|
93
93
|
const now = options.now ?? Date.now;
|
|
94
94
|
ensurePrivateRuntimeDirectory(path);
|
|
95
|
-
const ownerId = randomBytes(16).toString("base64url")
|
|
95
|
+
const ownerId = `${options.purpose === "desktop" ? "desktop-" : ""}${randomBytes(16).toString("base64url")}`;
|
|
96
96
|
const timestamp = new Date(now()).toISOString();
|
|
97
97
|
const record = {
|
|
98
98
|
schema_version: 1,
|
|
@@ -176,6 +176,21 @@ export function removeRecoverablePackageUpdateLock(options) {
|
|
|
176
176
|
return false;
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
/** Only native lifecycle locks with a proven dead owner; never a pending npm update. */
|
|
180
|
+
export function recoverDesktopPackageUpdateLock(tuttiHome) {
|
|
181
|
+
const path = packageUpdateLockPath(tuttiHome);
|
|
182
|
+
const record = parseRecord(path);
|
|
183
|
+
if (!record?.owner_id.startsWith("desktop-") || defaultProcessAlive(record.pid))
|
|
184
|
+
return;
|
|
185
|
+
if (parseRecord(path)?.owner_id !== record.owner_id)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
unlinkSync(path);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* Another lifecycle process may have recovered it. */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
179
194
|
export function transferPackageUpdateLockFromCurrentProcess(options) {
|
|
180
195
|
if (!isPositiveInteger(options.nextPid)) {
|
|
181
196
|
return false;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createProjectFolder, defaultProjectsDirectory, } from "./project-folders.js";
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { DesktopSessionService } from "../desktop-runtime/session.js";
|
|
3
4
|
import { registerConsoleBrowserBridge } from "./browser-bridge.js";
|
|
4
5
|
import { LocalConsoleManagedProjectService } from "./managed-project-service.js";
|
|
5
6
|
import { registerLocalConsoleManagedProjectRoutes } from "./managed-project-routes.js";
|
|
@@ -81,6 +82,17 @@ export function createLocalConsoleServer(options) {
|
|
|
81
82
|
...(options.now === undefined ? {} : { now: options.now }),
|
|
82
83
|
});
|
|
83
84
|
const operations = options.operationCoordinator ?? new LocalConsoleOperationCoordinator();
|
|
85
|
+
const desktopSession = new DesktopSessionService({
|
|
86
|
+
tuttiHome,
|
|
87
|
+
operations,
|
|
88
|
+
env: options.env ?? process.env,
|
|
89
|
+
});
|
|
90
|
+
let nativeShutdownLease;
|
|
91
|
+
app.addHook("onClose", (_instance, done) => {
|
|
92
|
+
desktopSession.dispose();
|
|
93
|
+
nativeShutdownLease?.release();
|
|
94
|
+
done();
|
|
95
|
+
});
|
|
84
96
|
const projects = new LocalConsoleProjectService({
|
|
85
97
|
tuttiHome,
|
|
86
98
|
operations,
|
|
@@ -232,6 +244,37 @@ export function createLocalConsoleServer(options) {
|
|
|
232
244
|
setImmediate(() => options.onShutdown?.());
|
|
233
245
|
return { status: "stopping" };
|
|
234
246
|
});
|
|
247
|
+
// Native-only control: never included in the browser bridge allowlist.
|
|
248
|
+
app.post(`${LOCAL_CONSOLE_API_BASE}/control/desktop-session`, {
|
|
249
|
+
schema: {
|
|
250
|
+
body: {
|
|
251
|
+
type: "object",
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
required: ["action"],
|
|
254
|
+
properties: { action: { enum: ["status", "restore", "exit"] } },
|
|
255
|
+
},
|
|
256
|
+
querystring: { type: "object", additionalProperties: false, properties: {} },
|
|
257
|
+
},
|
|
258
|
+
}, async (request) => {
|
|
259
|
+
requireControl(request);
|
|
260
|
+
if (request.body.action === "status")
|
|
261
|
+
return desktopSession.status();
|
|
262
|
+
if (request.body.action === "restore")
|
|
263
|
+
return desktopSession.restore();
|
|
264
|
+
const result = await desktopSession.prepareExit();
|
|
265
|
+
if (result.status === "stopped")
|
|
266
|
+
setImmediate(() => options.onShutdown?.());
|
|
267
|
+
return result;
|
|
268
|
+
});
|
|
269
|
+
app.post(`${LOCAL_CONSOLE_API_BASE}/control/idle-shutdown`, (request) => {
|
|
270
|
+
requireControl(request);
|
|
271
|
+
const lease = operations.tryBeginPackageUpdate();
|
|
272
|
+
if (lease === null)
|
|
273
|
+
return { status: "blocked", reason: "console_busy" };
|
|
274
|
+
nativeShutdownLease = lease;
|
|
275
|
+
setImmediate(() => options.onShutdown?.());
|
|
276
|
+
return { status: "stopping" };
|
|
277
|
+
});
|
|
235
278
|
app.post(`${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
|
|
236
279
|
schema: {
|
|
237
280
|
body: {
|
|
@@ -1157,6 +1157,7 @@ export declare const FeishuSetupProjectionSchema: import("@sinclair/typebox").TU
|
|
|
1157
1157
|
visibility: import("@sinclair/typebox").TLiteral<"not_recoverable">;
|
|
1158
1158
|
}>, import("@sinclair/typebox").TObject<{
|
|
1159
1159
|
state: import("@sinclair/typebox").TLiteral<"connected">;
|
|
1160
|
+
group_url: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
1160
1161
|
}>]>;
|
|
1161
1162
|
export declare const IntegrationConversationBindingRequestSchema: import("@sinclair/typebox").TObject<{
|
|
1162
1163
|
protocol_version: import("@sinclair/typebox").TLiteral<1>;
|
|
@@ -316,7 +316,13 @@ export const FeishuSetupProjectionSchema = Type.Union([
|
|
|
316
316
|
expires_at: Type.Optional(IsoDateTimeStringSchema),
|
|
317
317
|
visibility: Type.Literal("not_recoverable"),
|
|
318
318
|
}, { additionalProperties: false }),
|
|
319
|
-
Type.Object({
|
|
319
|
+
Type.Object({
|
|
320
|
+
state: Type.Literal("connected"),
|
|
321
|
+
group_url: Type.Optional(Type.String({
|
|
322
|
+
pattern: "^https://applink\\.feishu\\.cn/client/chat/open\\?chatId=oc_[A-Za-z0-9_-]{1,128}$",
|
|
323
|
+
maxLength: 256,
|
|
324
|
+
})),
|
|
325
|
+
}, { additionalProperties: false }),
|
|
320
326
|
]);
|
|
321
327
|
export const IntegrationConversationBindingRequestSchema = Type.Object({
|
|
322
328
|
protocol_version: Type.Literal(1),
|