u-foo 3.0.21 → 3.0.22
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 +13 -7
- package/README.zh-CN.md +11 -7
- package/package.json +1 -1
- package/src/agents/launch/launcher.js +52 -29
- package/src/app/chat/commandExecutor.js +15 -0
- package/src/app/chat/daemonConnection.js +7 -2
- package/src/app/chat/daemonCoordinator.js +1 -0
- package/src/app/chat/daemonTransport.js +21 -5
- package/src/app/chat/projectCloseController.js +6 -1
- package/src/app/chat/transport.js +84 -8
- package/src/app/cli/run.js +44 -7
- package/src/config.js +48 -0
- package/src/runtime/contracts/eventContract.js +1 -0
- package/src/runtime/daemon/agentProcessManager.js +34 -14
- package/src/runtime/daemon/controlPlaneService.js +14 -5
- package/src/runtime/daemon/endpoint.js +79 -0
- package/src/runtime/daemon/globalDaemon.js +394 -0
- package/src/runtime/daemon/groupOrchestrator.js +6 -0
- package/src/runtime/daemon/index.js +272 -109
- package/src/runtime/daemon/mcpHttpServer.js +5 -0
- package/src/runtime/daemon/ops.js +48 -17
- package/src/runtime/daemon/processLifecycle.js +127 -0
- package/src/runtime/daemon/projectContext.js +46 -0
- package/src/runtime/daemon/projectRuntime.js +221 -0
- package/src/runtime/daemon/projectRuntimeGateway.js +72 -0
- package/src/runtime/daemon/projectRuntimeManager.js +268 -0
- package/src/runtime/daemon/run.js +66 -12
- package/src/runtime/projects/registry.js +26 -4
- package/src/runtime/projects/runtimes.js +1 -1
- package/src/ui/rustChatHost.js +46 -8
|
@@ -176,6 +176,13 @@ function resolveConfiguredLaunchMode(configuredMode = "", options = {}) {
|
|
|
176
176
|
if (mode === "internal" || mode === "tmux" || mode === "terminal" || mode === "host") {
|
|
177
177
|
return mode;
|
|
178
178
|
}
|
|
179
|
+
if (
|
|
180
|
+
normalizeOptionalString(options.tmuxTarget)
|
|
181
|
+
|| normalizeOptionalString(options.tmuxPane)
|
|
182
|
+
|| normalizeOptionalString(options.tmuxSession)
|
|
183
|
+
) {
|
|
184
|
+
return "tmux";
|
|
185
|
+
}
|
|
179
186
|
// Auto mode: defer to the unified detector. Daemon ops differs from the
|
|
180
187
|
// launcher in two ways: host context can also arrive via `options`, and
|
|
181
188
|
// when running under a native terminal app we want to override a stale
|
|
@@ -704,7 +711,6 @@ async function spawnInternalAgent(
|
|
|
704
711
|
|
|
705
712
|
// Daemon 预先在 bus 中注册
|
|
706
713
|
bus.loadBusData();
|
|
707
|
-
process.env.UFOO_PARENT_PID = String(originalPid);
|
|
708
714
|
const replaceAgentId = typeof options.replaceAgentId === "string" ? options.replaceAgentId.trim() : "";
|
|
709
715
|
if (replaceAgentId && bus.busData.agents && bus.busData.agents[replaceAgentId]) {
|
|
710
716
|
delete bus.busData.agents[replaceAgentId];
|
|
@@ -721,6 +727,7 @@ async function spawnInternalAgent(
|
|
|
721
727
|
launchMode,
|
|
722
728
|
parentPid: originalPid,
|
|
723
729
|
providerSessionId,
|
|
730
|
+
scopedNickname: String(extraEnv.UFOO_SCOPED_NICKNAME || "").trim(),
|
|
724
731
|
});
|
|
725
732
|
const finalNickname = joinResult.nickname || requestedNickname || "";
|
|
726
733
|
bus.saveBusData();
|
|
@@ -803,7 +810,14 @@ async function spawnInternalAgent(
|
|
|
803
810
|
return { children, subscriberIds };
|
|
804
811
|
}
|
|
805
812
|
|
|
806
|
-
function spawnTmuxWindow(
|
|
813
|
+
function spawnTmuxWindow(
|
|
814
|
+
projectRoot,
|
|
815
|
+
agent,
|
|
816
|
+
nickname = "",
|
|
817
|
+
extraArgs = [],
|
|
818
|
+
extraEnv = "",
|
|
819
|
+
targetSession = ""
|
|
820
|
+
) {
|
|
807
821
|
return new Promise((resolve, reject) => {
|
|
808
822
|
const normalizedAgent = normalizeLaunchAgent(agent);
|
|
809
823
|
const binary = toTmuxBinary(normalizedAgent);
|
|
@@ -826,11 +840,12 @@ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extr
|
|
|
826
840
|
|
|
827
841
|
// Use detached mode (-d) to avoid stealing focus
|
|
828
842
|
// Use -a flag to insert after current window, avoiding index conflicts
|
|
829
|
-
// Use target session
|
|
830
|
-
|
|
843
|
+
// Use the request-scoped target session when available. The daemon may
|
|
844
|
+
// host multiple projects and must not mutate process.env to select one.
|
|
845
|
+
const normalizedTargetSession = String(targetSession || "").trim();
|
|
831
846
|
const tmuxArgs = ["new-window", "-a", "-d", "-n", windowName];
|
|
832
|
-
if (
|
|
833
|
-
tmuxArgs.push("-t",
|
|
847
|
+
if (normalizedTargetSession) {
|
|
848
|
+
tmuxArgs.push("-t", normalizedTargetSession);
|
|
834
849
|
}
|
|
835
850
|
tmuxArgs.push(runCmd);
|
|
836
851
|
|
|
@@ -846,10 +861,20 @@ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extr
|
|
|
846
861
|
});
|
|
847
862
|
}
|
|
848
863
|
|
|
849
|
-
function resolveTmuxPaneTarget() {
|
|
850
|
-
const explicit = String(
|
|
864
|
+
function resolveTmuxPaneTarget(options = {}) {
|
|
865
|
+
const explicit = String(
|
|
866
|
+
options.tmuxTarget
|
|
867
|
+
|| options.tmux_target
|
|
868
|
+
|| process.env.UFOO_TMUX_TARGET
|
|
869
|
+
|| ""
|
|
870
|
+
).trim();
|
|
851
871
|
if (explicit) return explicit;
|
|
852
|
-
const preferredPane = String(
|
|
872
|
+
const preferredPane = String(
|
|
873
|
+
options.tmuxPane
|
|
874
|
+
|| options.tmux_pane
|
|
875
|
+
|| process.env.UFOO_TMUX_PANE
|
|
876
|
+
|| ""
|
|
877
|
+
).trim();
|
|
853
878
|
if (preferredPane) return preferredPane;
|
|
854
879
|
const currentPane = String(process.env.TMUX_PANE || "").trim();
|
|
855
880
|
if (currentPane) return currentPane;
|
|
@@ -982,15 +1007,21 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
|
|
|
982
1007
|
if (!tmuxAvailable) {
|
|
983
1008
|
throw new Error("tmux is not available or no tmux session is running");
|
|
984
1009
|
}
|
|
985
|
-
|
|
986
|
-
|
|
1010
|
+
let tmuxSession = String(
|
|
1011
|
+
options.tmuxSession
|
|
1012
|
+
|| options.tmux_session
|
|
1013
|
+
|| process.env.UFOO_TMUX_SESSION
|
|
1014
|
+
|| ""
|
|
1015
|
+
).trim();
|
|
1016
|
+
// If no request-scoped or inherited session was supplied, use the first
|
|
1017
|
+
// available session locally without changing the daemon environment.
|
|
1018
|
+
if (!tmuxSession && stdout) {
|
|
987
1019
|
const sessions = stdout.trim().split("\n");
|
|
988
1020
|
if (sessions.length > 0) {
|
|
989
|
-
|
|
990
|
-
process.env.UFOO_TMUX_SESSION = firstSession;
|
|
1021
|
+
tmuxSession = String(sessions[0].split(":")[0] || "").trim();
|
|
991
1022
|
}
|
|
992
1023
|
}
|
|
993
|
-
const paneTarget = resolveTmuxPaneTarget();
|
|
1024
|
+
const paneTarget = resolveTmuxPaneTarget(options);
|
|
994
1025
|
const useSeparateWindow = launchScope === "window";
|
|
995
1026
|
const tmuxLayoutContext = options.tmuxLayoutContext && typeof options.tmuxLayoutContext === "object"
|
|
996
1027
|
? options.tmuxLayoutContext
|
|
@@ -1004,7 +1035,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
|
|
|
1004
1035
|
const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
|
|
1005
1036
|
if (useSeparateWindow) {
|
|
1006
1037
|
// eslint-disable-next-line no-await-in-loop
|
|
1007
|
-
await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix);
|
|
1038
|
+
await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix, tmuxSession);
|
|
1008
1039
|
} else if (useGroupRightColumnLayout && paneTarget) {
|
|
1009
1040
|
const basePane = String(tmuxLayoutContext.basePane || paneTarget).trim() || paneTarget;
|
|
1010
1041
|
tmuxLayoutContext.basePane = basePane;
|
|
@@ -1021,7 +1052,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
|
|
|
1021
1052
|
} catch {
|
|
1022
1053
|
// Fallback to new window when current pane target cannot be resolved.
|
|
1023
1054
|
// eslint-disable-next-line no-await-in-loop
|
|
1024
|
-
await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix);
|
|
1055
|
+
await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix, tmuxSession);
|
|
1025
1056
|
continue;
|
|
1026
1057
|
}
|
|
1027
1058
|
if (!rightColumnPane && splitResult && splitResult.paneId) {
|
|
@@ -1037,7 +1068,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
|
|
|
1037
1068
|
} catch {
|
|
1038
1069
|
// Fallback to new window when current pane target cannot be resolved.
|
|
1039
1070
|
// eslint-disable-next-line no-await-in-loop
|
|
1040
|
-
await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix);
|
|
1071
|
+
await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix, tmuxSession);
|
|
1041
1072
|
}
|
|
1042
1073
|
}
|
|
1043
1074
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class DaemonProcessLifecycle {
|
|
4
|
+
constructor(processRef = process) {
|
|
5
|
+
this.processRef = processRef;
|
|
6
|
+
this.registrations = new Map();
|
|
7
|
+
this.installed = false;
|
|
8
|
+
this.shuttingDown = false;
|
|
9
|
+
this.boundHandlers = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
register(id, hooks = {}) {
|
|
13
|
+
const key = String(id || "").trim();
|
|
14
|
+
if (!key) throw new Error("daemon process lifecycle registration id is required");
|
|
15
|
+
if (this.registrations.has(key)) {
|
|
16
|
+
throw new Error(`daemon process lifecycle already registered: ${key}`);
|
|
17
|
+
}
|
|
18
|
+
this.registrations.set(key, hooks);
|
|
19
|
+
this.install();
|
|
20
|
+
return () => {
|
|
21
|
+
this.registrations.delete(key);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
install() {
|
|
26
|
+
if (this.installed) return;
|
|
27
|
+
this.installed = true;
|
|
28
|
+
const beforeExit = (code) => {
|
|
29
|
+
this.notify("onBeforeExit", code);
|
|
30
|
+
};
|
|
31
|
+
const exit = (code) => {
|
|
32
|
+
this.shutdown(`exit code=${code}`, { sync: true, notify: "onExit", value: code });
|
|
33
|
+
};
|
|
34
|
+
const sigterm = () => {
|
|
35
|
+
this.shutdown("SIGTERM", { sync: true, notify: "onSignal", value: "SIGTERM" });
|
|
36
|
+
this.processRef.exit(0);
|
|
37
|
+
};
|
|
38
|
+
const sigint = () => {
|
|
39
|
+
this.shutdown("SIGINT", { sync: true, notify: "onSignal", value: "SIGINT" });
|
|
40
|
+
this.processRef.exit(0);
|
|
41
|
+
};
|
|
42
|
+
const uncaughtException = (err) => {
|
|
43
|
+
this.shutdown("uncaughtException", {
|
|
44
|
+
sync: true,
|
|
45
|
+
notify: "onFatal",
|
|
46
|
+
value: { kind: "uncaughtException", error: err },
|
|
47
|
+
});
|
|
48
|
+
this.processRef.exit(1);
|
|
49
|
+
};
|
|
50
|
+
const unhandledRejection = (reason) => {
|
|
51
|
+
this.shutdown("unhandledRejection", {
|
|
52
|
+
sync: true,
|
|
53
|
+
notify: "onFatal",
|
|
54
|
+
value: { kind: "unhandledRejection", error: reason },
|
|
55
|
+
});
|
|
56
|
+
this.processRef.exit(1);
|
|
57
|
+
};
|
|
58
|
+
this.boundHandlers = {
|
|
59
|
+
beforeExit,
|
|
60
|
+
exit,
|
|
61
|
+
SIGTERM: sigterm,
|
|
62
|
+
SIGINT: sigint,
|
|
63
|
+
uncaughtException,
|
|
64
|
+
unhandledRejection,
|
|
65
|
+
};
|
|
66
|
+
for (const [event, handler] of Object.entries(this.boundHandlers)) {
|
|
67
|
+
this.processRef.on(event, handler);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
notify(method, value) {
|
|
72
|
+
for (const hooks of Array.from(this.registrations.values())) {
|
|
73
|
+
const handler = hooks && hooks[method];
|
|
74
|
+
if (typeof handler !== "function") continue;
|
|
75
|
+
try {
|
|
76
|
+
handler(value);
|
|
77
|
+
} catch {
|
|
78
|
+
// A lifecycle observer cannot prevent other runtimes from cleaning up.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
shutdown(reason, options = {}) {
|
|
84
|
+
if (this.shuttingDown) return;
|
|
85
|
+
this.shuttingDown = true;
|
|
86
|
+
const registrations = Array.from(this.registrations.values());
|
|
87
|
+
for (const hooks of registrations) {
|
|
88
|
+
try {
|
|
89
|
+
const notify = options.notify && hooks && hooks[options.notify];
|
|
90
|
+
if (typeof notify === "function") notify(options.value);
|
|
91
|
+
} catch {
|
|
92
|
+
// Fatal diagnostics for one runtime must not block cleanup of another.
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
if (hooks && typeof hooks.cleanup === "function") {
|
|
96
|
+
hooks.cleanup(reason, { sync: options.sync === true });
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Continue draining every registered runtime.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
disposeForTests() {
|
|
105
|
+
if (this.boundHandlers && typeof this.processRef.removeListener === "function") {
|
|
106
|
+
for (const [event, handler] of Object.entries(this.boundHandlers)) {
|
|
107
|
+
this.processRef.removeListener(event, handler);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.boundHandlers = null;
|
|
111
|
+
this.registrations.clear();
|
|
112
|
+
this.installed = false;
|
|
113
|
+
this.shuttingDown = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const defaultDaemonProcessLifecycle = new DaemonProcessLifecycle(process);
|
|
118
|
+
|
|
119
|
+
function registerDaemonRuntimeLifecycle(id, hooks = {}) {
|
|
120
|
+
return defaultDaemonProcessLifecycle.register(id, hooks);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
DaemonProcessLifecycle,
|
|
125
|
+
defaultDaemonProcessLifecycle,
|
|
126
|
+
registerDaemonRuntimeLifecycle,
|
|
127
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
const { loadConfig } = require("../../config");
|
|
6
|
+
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
7
|
+
const {
|
|
8
|
+
buildProjectId,
|
|
9
|
+
canonicalProjectRoot,
|
|
10
|
+
} = require("../projects");
|
|
11
|
+
|
|
12
|
+
function cloneJson(value) {
|
|
13
|
+
return JSON.parse(JSON.stringify(value == null ? {} : value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function deepFreeze(value) {
|
|
17
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
18
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
19
|
+
return Object.freeze(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createProjectContext(options = {}) {
|
|
23
|
+
const projectRoot = canonicalProjectRoot(options.projectRoot);
|
|
24
|
+
const configSnapshot = cloneJson(options.config || loadConfig(projectRoot));
|
|
25
|
+
const runtimeGeneration = Number.isInteger(options.runtimeGeneration)
|
|
26
|
+
&& options.runtimeGeneration > 0
|
|
27
|
+
? options.runtimeGeneration
|
|
28
|
+
: 1;
|
|
29
|
+
const context = {
|
|
30
|
+
projectId: options.projectId || buildProjectId(projectRoot),
|
|
31
|
+
projectRoot,
|
|
32
|
+
projectName: options.projectName || path.basename(projectRoot),
|
|
33
|
+
paths: getUfooPaths(projectRoot),
|
|
34
|
+
config: configSnapshot,
|
|
35
|
+
provider: String(options.provider || configSnapshot.agentProvider || ""),
|
|
36
|
+
model: String(options.model || configSnapshot.agentModel || ""),
|
|
37
|
+
daemonTopology: String(options.daemonTopology || configSnapshot.daemonTopology || "global"),
|
|
38
|
+
runtimeGeneration,
|
|
39
|
+
};
|
|
40
|
+
return deepFreeze(context);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
createProjectContext,
|
|
45
|
+
deepFreeze,
|
|
46
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require("crypto");
|
|
4
|
+
|
|
5
|
+
const RUNTIME_STATES = Object.freeze({
|
|
6
|
+
REGISTERED: "registered",
|
|
7
|
+
DORMANT: "dormant",
|
|
8
|
+
ACTIVATING: "activating",
|
|
9
|
+
ACTIVE: "active",
|
|
10
|
+
DRAINING: "draining",
|
|
11
|
+
FAILED: "failed",
|
|
12
|
+
DISPOSED: "disposed",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
class ProjectRuntime {
|
|
16
|
+
constructor(context, options = {}) {
|
|
17
|
+
if (!context || !context.projectId || !context.projectRoot) {
|
|
18
|
+
throw new Error("ProjectRuntime requires an immutable ProjectContext");
|
|
19
|
+
}
|
|
20
|
+
this.context = context;
|
|
21
|
+
this.state = RUNTIME_STATES.REGISTERED;
|
|
22
|
+
this.operations = new Map();
|
|
23
|
+
this.resources = new Map();
|
|
24
|
+
this.activeRequests = new Map();
|
|
25
|
+
this.lastError = null;
|
|
26
|
+
this.lastActiveAt = null;
|
|
27
|
+
this.onActivate = typeof options.onActivate === "function" ? options.onActivate : null;
|
|
28
|
+
this.onSuspend = typeof options.onSuspend === "function" ? options.onSuspend : null;
|
|
29
|
+
this.onRecover = typeof options.onRecover === "function" ? options.onRecover : null;
|
|
30
|
+
this.canSuspendHook = typeof options.canSuspend === "function" ? options.canSuspend : null;
|
|
31
|
+
this.onDispose = typeof options.onDispose === "function" ? options.onDispose : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
registerOperation(name, handler) {
|
|
35
|
+
const operation = String(name || "").trim();
|
|
36
|
+
if (!operation || typeof handler !== "function") {
|
|
37
|
+
throw new Error("registerOperation requires a name and handler");
|
|
38
|
+
}
|
|
39
|
+
this.operations.set(operation, handler);
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
own(name, resource) {
|
|
44
|
+
const key = String(name || "").trim();
|
|
45
|
+
if (!key) throw new Error("runtime resource name is required");
|
|
46
|
+
if (this.resources.has(key)) {
|
|
47
|
+
throw new Error(`runtime resource already exists: ${key}`);
|
|
48
|
+
}
|
|
49
|
+
this.resources.set(key, resource);
|
|
50
|
+
return resource;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
resource(name) {
|
|
54
|
+
return this.resources.get(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async activate() {
|
|
58
|
+
if (this.state === RUNTIME_STATES.ACTIVE) return this;
|
|
59
|
+
if (this.state === RUNTIME_STATES.DISPOSED) {
|
|
60
|
+
throw new Error("disposed runtime cannot be activated");
|
|
61
|
+
}
|
|
62
|
+
this.state = RUNTIME_STATES.ACTIVATING;
|
|
63
|
+
try {
|
|
64
|
+
if (this.onActivate) await this.onActivate(this);
|
|
65
|
+
this.state = RUNTIME_STATES.ACTIVE;
|
|
66
|
+
this.lastActiveAt = new Date().toISOString();
|
|
67
|
+
this.lastError = null;
|
|
68
|
+
return this;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
this.fail(err);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async call(operation, args = {}, requestContext = {}) {
|
|
76
|
+
if (this.state !== RUNTIME_STATES.ACTIVE) {
|
|
77
|
+
const err = new Error(
|
|
78
|
+
`project runtime ${this.context.projectId} is not active (${this.state})`
|
|
79
|
+
);
|
|
80
|
+
err.code = "PROJECT_RUNTIME_NOT_ACTIVE";
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
const handler = this.operations.get(String(operation || ""));
|
|
84
|
+
if (!handler) {
|
|
85
|
+
const err = new Error(`unknown project runtime operation: ${operation || ""}`);
|
|
86
|
+
err.code = "PROJECT_RUNTIME_UNKNOWN_OPERATION";
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
const requestId = String(requestContext.requestId || randomUUID());
|
|
90
|
+
if (this.activeRequests.has(requestId)) {
|
|
91
|
+
const err = new Error(`duplicate project runtime request: ${requestId}`);
|
|
92
|
+
err.code = "PROJECT_RUNTIME_DUPLICATE_REQUEST";
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const externalSignal = requestContext.signal;
|
|
97
|
+
const abortFromExternal = () => controller.abort(externalSignal.reason);
|
|
98
|
+
if (externalSignal) {
|
|
99
|
+
if (externalSignal.aborted) abortFromExternal();
|
|
100
|
+
else externalSignal.addEventListener("abort", abortFromExternal, { once: true });
|
|
101
|
+
}
|
|
102
|
+
this.activeRequests.set(requestId, controller);
|
|
103
|
+
this.lastActiveAt = new Date().toISOString();
|
|
104
|
+
try {
|
|
105
|
+
return await handler(args, {
|
|
106
|
+
context: this.context,
|
|
107
|
+
requestContext: {
|
|
108
|
+
...requestContext,
|
|
109
|
+
requestId,
|
|
110
|
+
},
|
|
111
|
+
runtime: this,
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err && err.runtimeFatal === true) this.fail(err);
|
|
116
|
+
throw err;
|
|
117
|
+
} finally {
|
|
118
|
+
this.activeRequests.delete(requestId);
|
|
119
|
+
if (externalSignal) externalSignal.removeEventListener("abort", abortFromExternal);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
cancel(requestId, reason = "project runtime request cancelled") {
|
|
124
|
+
const controller = this.activeRequests.get(String(requestId || ""));
|
|
125
|
+
if (!controller) return false;
|
|
126
|
+
controller.abort(new Error(reason));
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fail(err) {
|
|
131
|
+
this.lastError = {
|
|
132
|
+
code: err && err.code ? String(err.code) : "PROJECT_RUNTIME_FAILED",
|
|
133
|
+
message: err && err.message ? err.message : String(err || "runtime failed"),
|
|
134
|
+
at: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
this.state = RUNTIME_STATES.FAILED;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
canSuspend() {
|
|
140
|
+
if (this.state !== RUNTIME_STATES.ACTIVE || this.activeRequests.size > 0) return false;
|
|
141
|
+
if (this.canSuspendHook) return this.canSuspendHook(this) === true;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async suspend() {
|
|
146
|
+
if (!this.canSuspend()) {
|
|
147
|
+
const err = new Error(`project runtime ${this.context.projectId} cannot suspend`);
|
|
148
|
+
err.code = "PROJECT_RUNTIME_BUSY";
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
this.state = RUNTIME_STATES.DRAINING;
|
|
152
|
+
try {
|
|
153
|
+
if (this.onSuspend) await this.onSuspend(this);
|
|
154
|
+
this.state = RUNTIME_STATES.DORMANT;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this.fail(err);
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async recover() {
|
|
162
|
+
if (this.state !== RUNTIME_STATES.FAILED) return this;
|
|
163
|
+
this.state = RUNTIME_STATES.ACTIVATING;
|
|
164
|
+
try {
|
|
165
|
+
if (this.onRecover) await this.onRecover(this);
|
|
166
|
+
this.state = RUNTIME_STATES.ACTIVE;
|
|
167
|
+
this.lastActiveAt = new Date().toISOString();
|
|
168
|
+
this.lastError = null;
|
|
169
|
+
return this;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
this.fail(err);
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
dispose() {
|
|
177
|
+
if (this.state === RUNTIME_STATES.DISPOSED) return;
|
|
178
|
+
this.state = RUNTIME_STATES.DRAINING;
|
|
179
|
+
for (const controller of this.activeRequests.values()) {
|
|
180
|
+
controller.abort(new Error("project runtime disposed"));
|
|
181
|
+
}
|
|
182
|
+
this.activeRequests.clear();
|
|
183
|
+
if (this.onDispose) {
|
|
184
|
+
try {
|
|
185
|
+
this.onDispose(this);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this.lastError = {
|
|
188
|
+
code: err && err.code ? String(err.code) : "PROJECT_RUNTIME_DISPOSE_FAILED",
|
|
189
|
+
message: err && err.message ? err.message : String(err || "runtime dispose failed"),
|
|
190
|
+
at: new Date().toISOString(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.state = RUNTIME_STATES.DISPOSED;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
status() {
|
|
198
|
+
return {
|
|
199
|
+
project_id: this.context.projectId,
|
|
200
|
+
project_root: this.context.projectRoot,
|
|
201
|
+
project_name: this.context.projectName,
|
|
202
|
+
topology: this.context.daemonTopology,
|
|
203
|
+
generation: this.context.runtimeGeneration,
|
|
204
|
+
state: this.state,
|
|
205
|
+
active_request_count: this.activeRequests.size,
|
|
206
|
+
resource_count: this.resources.size,
|
|
207
|
+
last_active_at: this.lastActiveAt,
|
|
208
|
+
last_error: this.lastError,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createProjectRuntime(context, options = {}) {
|
|
214
|
+
return new ProjectRuntime(context, options);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
ProjectRuntime,
|
|
219
|
+
RUNTIME_STATES,
|
|
220
|
+
createProjectRuntime,
|
|
221
|
+
};
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
assertToolAllowedForCallerTier,
|
|
12
12
|
} = require("../../tools/registry");
|
|
13
13
|
const { CALLER_TIERS } = require("../../tools/types");
|
|
14
|
+
const { ProjectRuntimeManager } = require("./projectRuntimeManager");
|
|
14
15
|
|
|
15
16
|
const MCP_EXPOSED_SHARED_TOOLS = Object.freeze([
|
|
16
17
|
"read_project_registry",
|
|
@@ -140,6 +141,71 @@ class LocalProjectRuntimeGateway {
|
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
class ManagedProjectRuntimeGateway {
|
|
145
|
+
constructor(options = {}) {
|
|
146
|
+
this.options = options;
|
|
147
|
+
this.activeRequestProjects = new Map();
|
|
148
|
+
if (options.manager) {
|
|
149
|
+
this.manager = options.manager;
|
|
150
|
+
} else {
|
|
151
|
+
const managerOptions = options.managerOptions || {};
|
|
152
|
+
const callerConfigureRuntime = managerOptions.configureRuntime;
|
|
153
|
+
this.manager = new ProjectRuntimeManager({
|
|
154
|
+
...managerOptions,
|
|
155
|
+
configureRuntime: (runtime, context) => {
|
|
156
|
+
for (const operation of [
|
|
157
|
+
...CONTROL_PLANE_OPERATIONS,
|
|
158
|
+
...MCP_EXPOSED_SHARED_TOOLS.filter((name) => name !== "read_project_registry"),
|
|
159
|
+
]) {
|
|
160
|
+
runtime.registerOperation(operation, (args, callContext) =>
|
|
161
|
+
executeProjectRuntimeOperation(
|
|
162
|
+
context.projectRoot,
|
|
163
|
+
operation,
|
|
164
|
+
args,
|
|
165
|
+
{
|
|
166
|
+
...callContext.requestContext,
|
|
167
|
+
signal: callContext.signal,
|
|
168
|
+
},
|
|
169
|
+
this.options
|
|
170
|
+
));
|
|
171
|
+
}
|
|
172
|
+
if (typeof callerConfigureRuntime === "function") {
|
|
173
|
+
callerConfigureRuntime(runtime, context);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async call(projectRoot, operation, args = {}, context = {}) {
|
|
181
|
+
const requestId = String(context.requestId || context.toolCallId || crypto.randomUUID());
|
|
182
|
+
this.activeRequestProjects.set(requestId, projectRoot);
|
|
183
|
+
try {
|
|
184
|
+
return await this.manager.call(projectRoot, operation, args, {
|
|
185
|
+
...context,
|
|
186
|
+
requestId,
|
|
187
|
+
});
|
|
188
|
+
} finally {
|
|
189
|
+
this.activeRequestProjects.delete(requestId);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
cancel(requestId) {
|
|
194
|
+
const id = String(requestId || "");
|
|
195
|
+
const projectRoot = this.activeRequestProjects.get(id);
|
|
196
|
+
return projectRoot ? this.manager.cancel(projectRoot, id) : false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
status() {
|
|
200
|
+
return this.manager.status();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
dispose() {
|
|
204
|
+
this.manager.dispose();
|
|
205
|
+
this.activeRequestProjects.clear();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
143
209
|
function projectRuntimeError(code, message) {
|
|
144
210
|
const err = new Error(String(message || "project runtime request failed"));
|
|
145
211
|
err.code = String(code || "project_runtime_error");
|
|
@@ -344,14 +410,20 @@ function createSocketProjectRuntimeGateway(options = {}) {
|
|
|
344
410
|
return new SocketProjectRuntimeGateway(options);
|
|
345
411
|
}
|
|
346
412
|
|
|
413
|
+
function createManagedProjectRuntimeGateway(options = {}) {
|
|
414
|
+
return new ManagedProjectRuntimeGateway(options);
|
|
415
|
+
}
|
|
416
|
+
|
|
347
417
|
module.exports = {
|
|
348
418
|
MCP_EXPOSED_SHARED_TOOLS,
|
|
349
419
|
CONTROL_PLANE_OPERATIONS,
|
|
350
420
|
stripRoutingArgs,
|
|
351
421
|
executeProjectRuntimeOperation,
|
|
352
422
|
LocalProjectRuntimeGateway,
|
|
423
|
+
ManagedProjectRuntimeGateway,
|
|
353
424
|
SocketProjectRuntimeGateway,
|
|
354
425
|
createLocalProjectRuntimeGateway,
|
|
426
|
+
createManagedProjectRuntimeGateway,
|
|
355
427
|
createSocketProjectRuntimeGateway,
|
|
356
428
|
connectProjectRuntimeSocket,
|
|
357
429
|
resolveCallTimeoutMs,
|