Live mode: create a new project (new_project, qet_live_new_project)

An assistant in live mode could only work on the project already open.
new_project makes one as File > New does -- this installation's
new-folio defaults, optional title and number of folios -- and makes it
the current project, so the next request works on it. An optional
absolute path saves it at once, never over an existing file: QElectroTech
refuses, and the MCP server refuses before asking (a new never-overwrite
class in its path policy, so an "overwrite" flag cannot get round it).

Pages and links across pages already worked through scripts
(qet.addFolio, setFolioTitle, linkElements across folios); the tool's
description now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-05 18:41:58 +13:00
parent ecf995174e
commit 42b1e43e4a
4 changed files with 150 additions and 2 deletions
+50
View File
@@ -4085,6 +4085,22 @@ def tool_live_show_folio(folio: int) -> dict:
return _live_call({"cmd": "show_folio", "folio": folio})
def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> dict:
_require_script_consent()
if not isinstance(folios, int) or isinstance(folios, bool) or not 1 <= folios <= 100:
raise ValueError("'folios' must be a whole number from 1 to 100")
if not isinstance(title, str) or not isinstance(path, str):
raise ValueError("'title' and 'path' must be text")
if path and not Path(path).expanduser().is_absolute():
raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet")
request = {"cmd": "new_project", "folios": folios}
if title:
request["title"] = title
if path:
request["path"] = str(Path(path).expanduser())
return _live_call(request)
def tool_live_undo_last() -> dict:
_require_script_consent()
return _live_call({"cmd": "undo_last"})
@@ -5164,6 +5180,31 @@ TOOLS = [
},
"handler": lambda a: tool_live_show_folio(a["folio"]),
},
{
"name": "qet_live_new_project",
"description": "LIVE MODE. Create a new project in the QElectroTech the "
"user has open, as File > New does (this installation's "
"new-folio defaults), and make it the current project: every "
"following qet_live_* call works on it. Optional title, number "
"of empty folios (pages, 1-100) and an absolute path to save it "
"to at once -- never over an existing file. Then, in "
"qet_live_run_script: qet.addFolio() / qet.insertFolio(i) add "
"pages, qet.setFolioTitle(i, text) names them, and "
"qet.linkElements(folioA, a, folioB, b) links across pages -- "
"a going folio report arrow to a coming one, or a coil to its "
"contacts on another page.",
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "the project's title"},
"folios": {"type": "integer", "default": 1, "minimum": 1, "maximum": 100},
"path": {"type": "string",
"description": "absolute .qet path to save to now; must not exist"},
},
},
"handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1),
a.get("path", "")),
},
{
"name": "qet_live_undo_last",
"description": "LIVE MODE. Undo the newest step in the open project, only if "
@@ -5298,6 +5339,9 @@ _DATA_PATHS = {
"qet_check": {"read": ("project",)},
"qet_layout_check": {"read": ("project",)},
"qet_project_new": {"write": ("output",)},
# QElectroTech writes this file, but where is the client's choice:
# held to the same workspace as every other file a tool writes.
"qet_live_new_project": {"write": ("path",)},
"qet_element_build": {"write": ("output",)},
# The scripts folder is chosen by scripts_dir(), never by the client,
# so only the project a script is tried on is a data path here.
@@ -5318,6 +5362,9 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"}
# Tools whose "overwrite" guards a file the server names itself (the
# stored script, in scripts_dir()), not a client-chosen output path.
_OVERWRITE_OWN_FILE = {"qet_script_install"}
# Tools that create a file and never replace one, whatever the client asks:
# no "overwrite" in their schema, and the flag is ignored if sent anyway.
_NEVER_OVERWRITE = {"qet_live_new_project"}
# qet_edit operations that name a file of their own.
_DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"}
@@ -5517,6 +5564,9 @@ def enforce_path_policy(tool_name: str, arguments: dict) -> None:
# Writing over something that is already there is the one step this
# server cannot undo, so it is the one step it will not take on its
# own. qet_project_new already had this flag; the others now match it.
if out.exists() and tool_name in _NEVER_OVERWRITE:
raise ValueError(f"{arg!r} already exists: {out}. This tool only "
"creates new files; choose another name.")
if out.exists() and not arguments.get("overwrite"):
raise ValueError(
f"{arg!r} already exists: {out}. Pass \"overwrite\": true to "
+38 -2
View File
@@ -184,7 +184,7 @@ class ToolRegistry(unittest.TestCase):
"qet_live_run_stored", "qet_live_command", "qet_live_show_folio",
"qet_live_undo_last", "qet_live_screenshot", "qet_about",
"qet_recording_list", "qet_recording_read", "qet_recording_check",
"qet_recording_remove", "qet_layout_check"})
"qet_recording_remove", "qet_layout_check", "qet_live_new_project"})
class EditValidation(unittest.TestCase):
@@ -2829,7 +2829,20 @@ class PathPolicy(unittest.TestCase):
guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")}
advertised = {t["name"] for t in m.TOOLS
if "overwrite" in t["inputSchema"].get("properties", {})}
self.assertEqual(guarded, advertised - m._OVERWRITE_OWN_FILE)
self.assertEqual(guarded - m._NEVER_OVERWRITE, advertised - m._OVERWRITE_OWN_FILE)
self.assertFalse(advertised & m._NEVER_OVERWRITE)
def test_never_overwrite_tools_ignore_the_flag(self):
with tempfile.TemporaryDirectory() as d:
target = Path(d) / "exists.qet"
target.write_text("x")
with mock.patch.dict(os.environ, {"QET_MCP_WORKSPACE": d}):
for args in ({"path": str(target)}, {"path": str(target), "overwrite": True}):
with self.assertRaisesRegex(ValueError, "only creates new files"):
m.enforce_path_policy("qet_live_new_project", args)
m.enforce_path_policy("qet_live_new_project", {"path": str(Path(d) / "new.qet")})
with self.assertRaises(ValueError):
m.enforce_path_policy("qet_live_new_project", {"path": "/etc/new.qet"})
def test_every_data_path_argument_is_guarded(self):
"""The other direction: a tool whose schema takes a data path must be
@@ -3533,6 +3546,29 @@ class LiveClient(unittest.TestCase):
with self.assertRaises(ValueError):
m.tool_live_show_folio("2")
def test_new_project_sends_only_what_was_given(self):
self.session()
m.tool_live_new_project()
m.tool_live_new_project("Pump station", 3, "/tmp/x/pump.qet")
self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")}
for r in self.seen],
[{"cmd": "new_project", "folios": 1},
{"cmd": "new_project", "folios": 3, "title": "Pump station",
"path": "/tmp/x/pump.qet"}])
for bad in (0, 101, "2", True):
with self.assertRaises(ValueError):
m.tool_live_new_project(folios=bad)
with self.assertRaisesRegex(ValueError, "absolute"):
m.tool_live_new_project(path="pump.qet")
self.assertEqual(len(self.seen), 2)
def test_new_project_needs_script_consent(self):
self.session()
with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}):
with self.assertRaises(ValueError):
m.tool_live_new_project()
self.assertEqual(self.seen, [])
def test_stale_session_file(self):
self.info({"socket": self.sock_path + "-gone", "token": "T0K"})
with self.assertRaisesRegex(ValueError, "could not reach"):
+61
View File
@@ -48,6 +48,9 @@
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include <QDialog>
#include <QDir>
#include <QFileInfo>
#include <QMdiSubWindow>
#include <QDialogButtonBox>
#include <QLabel>
#include "../projectview.h"
@@ -251,6 +254,8 @@ void LiveServer::handle(const QJsonObject &request)
answer = runStored(request.value(QStringLiteral("script")).toString());
} else if (cmd == QLatin1String("command")) {
answer = command(request.value(QStringLiteral("action")).toString());
} else if (cmd == QLatin1String("new_project")) {
answer = newProject(request);
} else if (cmd == QLatin1String("show_folio")) {
answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1));
} else if (cmd == QLatin1String("undo_last")) {
@@ -441,6 +446,62 @@ QJsonObject LiveServer::showFolio(int folio)
return {{QStringLiteral("ok"), true}, {QStringLiteral("folio"), folio}};
}
/**
@brief LiveServer::newProject
A new project, as File > New makes it: the new-folio defaults of this
QElectroTech, opened and made the current project, so the next script
works on it. Optional: "title", "folios" (how many empty folios, 1 to
100, default 1), and "path" to save it to at once -- never over an
existing file, so an assistant cannot replace the user's work.
*/
QJsonObject LiveServer::newProject(const QJsonObject &request)
{
QETDiagramEditor *e = editor();
if (!e) return failure(QStringLiteral("no QElectroTech editor window is open"));
const int folios = request.value(QStringLiteral("folios")).toInt(1);
if (folios < 1 || folios > 100)
return failure(QStringLiteral("folios must be between 1 and 100, not %1").arg(folios));
const QString path = request.value(QStringLiteral("path")).toString().trimmed();
if (!path.isEmpty()) {
const QFileInfo info(path);
if (info.isRelative())
return failure(QStringLiteral("path must be absolute: %1").arg(path));
if (info.exists())
return failure(QStringLiteral("%1 already exists; a new project is never "
"saved over a file").arg(path));
if (!info.dir().exists())
return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath()));
}
auto project = new QETProject(e);
for (int i = 0; i < folios; ++i) project->addNewDiagram();
const QString title = request.value(QStringLiteral("title")).toString().trimmed();
if (!title.isEmpty()) project->setTitle(title);
if (!e->addProject(project)) return failure(QStringLiteral("QElectroTech refused the new project"));
//Current at once, whether or not QElectroTech is the active
//application: the next request runs on it (see editor())
for (ProjectView *pv : e->openedProjects()) {
if (pv->project() == project) {
for (QMdiSubWindow *w : e->m_workspace.subWindowList())
if (w->widget() == pv) e->m_workspace.setActiveSubWindow(w);
}
}
if (!path.isEmpty()) {
project->setFilePath(path);
const QETResult result = project->write();
if (!result.isOk())
return failure(QStringLiteral("the project was created but not saved to %1: %2")
.arg(path, result.errorMessage()));
}
QJsonObject answer = status();
answer.insert(QStringLiteral("created"), true);
return answer;
}
/**
@brief LiveServer::undoLast
Undo the newest step, only if the assistant made it: what the user did
+1
View File
@@ -84,6 +84,7 @@ class LiveServer : public QObject
QJsonObject runStored(const QString &id);
QJsonObject command(const QString &id);
QJsonObject showFolio(int folio);
QJsonObject newProject(const QJsonObject &request);
QJsonObject undoLast();
QJsonObject screenshot();
bool confirm(const QString &name, const QString &source);