local-operator-ui 0.23.0 → 0.23.1
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/out/main/index.js +627 -741
- package/out/renderer/assets/{_basePickBy-_D80de5G.js → _basePickBy-mmFlh3ho.js} +1 -1
- package/out/renderer/assets/{_baseUniq-2CVM3wP6.js → _baseUniq-DAWnt4WG.js} +1 -1
- package/out/renderer/assets/{agent-details-page-DDzWQkTU.js → agent-details-page-CO72YkSX.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-BJZLQyGG.js → agent-hub-page-DhYc5_gP.js} +1 -1
- package/out/renderer/assets/{agents-page-DemONOpM.js → agents-page-BopOzax_.js} +2 -2
- package/out/renderer/assets/{arc-5WLfklOA.js → arc-BgHsUFIt.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-2esPWMfK.js → architectureDiagram-IEHRJDOE-BWxIiACo.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CxEr3yHk.js → blockDiagram-JOT3LUYC-CoJDUfWc.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-CW3c7Z5k.js → c4Diagram-VJAJSXHY-B8vXmmLx.js} +1 -1
- package/out/renderer/assets/channel-DAvIgUhZ.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-D_Bp0wT-.js → chunk-4BMEZGHF-rO5ve7XY.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-DmJd08BZ.js → chunk-A2AXSNBT-CTPb1xgY.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-ujtyZ3tC.js → chunk-AEK57VVT-5VpTdLBm.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-Dsw-P_RE.js → chunk-D6G4REZN-C3CEf0kC.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-DehW_14d.js → chunk-RZ5BOZE2-B7nn7Euk.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-DMF0PowC.js → chunk-XZIHB7SX-CnPH3vBT.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CP5aSNYY.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CP5aSNYY.js +1 -0
- package/out/renderer/assets/clone-CeahOD_w.js +1 -0
- package/out/renderer/assets/{compact-pagination-uDiiJTfu.js → compact-pagination-Bu8vshU-.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-Cis1R8z2.js → dagre-OKDRZEBW-DUX1RPbJ.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-ccEDsRNu.js → diagram-SSKATNLV-Cpv42kzr.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-CvU-J7lk.js → diagram-VNBRO52H-CGkUnKEL.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-CzjCMPxk.js → erDiagram-Q7BY3M3F-iBbJbyJZ.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-B54QUlmV.js → flowDiagram-4HSFHLVR-B569Tv1u.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-BGrZ0i8Q.js → ganttDiagram-APWFNJXF-nGbcgsgD.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-rcGPhQBW.js → gitGraphDiagram-7IBYFJ6S-vv_MhteL.js} +1 -1
- package/out/renderer/assets/{graph-J0pSfXIT.js → graph-krOMwYSa.js} +1 -1
- package/out/renderer/assets/{index-DdfOG3_H.js → index-BgGl2-0g.js} +1 -1
- package/out/renderer/assets/{index-DqOACDLL.js → index-BqWgqUKZ.js} +350 -350
- package/out/renderer/assets/{index-lFyj7RMx.js → index-CT6w0yXj.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-Bu4VJqUO.js → infoDiagram-PH2N3AL5-DHDA5253.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-CqkMOWse.js → journeyDiagram-U35MCT3I-CIrQ218m.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-6YsTdTHR.js → kanban-definition-NDS4AKOZ-DG_YsZbL.js} +1 -1
- package/out/renderer/assets/{layout-DxRkOtZA.js → layout-CB5zJSjt.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-B_TTtHbw.js → legacy-agents-page-B8pvrWy_.js} +1 -1
- package/out/renderer/assets/{mermaid.core-BGQGjyBM.js → mermaid.core-CYc-R2hQ.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-COAAKLot.js → mindmap-definition-ALO5MXBD-C22fFThl.js} +1 -1
- package/out/renderer/assets/{parseISO-CJIN700N.js → parseISO-vr2fT-wq.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-bDFajT15.js → pieDiagram-IB7DONF6-DKRytw7K.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BGbdCwMH.js → quadrantDiagram-7GDLP6J5-CWdnqGKr.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-D61WJ-zJ.js → radar-MK3ICKWK-B0XgopGa.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-XAgZ5KHN.js → radient-auth-buttons-RrBw8fkd.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DGNaT_h7.js → requirementDiagram-KVF5MWMF-CXL8GuIo.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BDNzMQ43.js → sankeyDiagram-QLVOVGJD-BQKCHfmN.js} +1 -1
- package/out/renderer/assets/{schedules-page-Cr0fc2A0.js → schedules-page-DtNhlycC.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-ByLl22wr.js → sequenceDiagram-X6HHIX6F-awR0fjHu.js} +1 -1
- package/out/renderer/assets/{settings-page-DBLyEih6.js → settings-page-DCLaspsn.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-Cs28xqpn.js → stateDiagram-DGXRK772-BjhA_mJe.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-nAxLNCrm.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-Cc2py6Fo.js → timeline-definition-BDJGKUSR-BFEDYBl8.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-CIJBNaU9.js → use-agent-like-mutation-Bxx_WHvo.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-Buajaj4y.js → xychartDiagram-VJFVF3MP-CwLeRa1a.js} +1 -1
- package/out/renderer/index.html +1 -1
- package/package.json +2 -2
- package/out/renderer/assets/channel-BihnEq5U.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-B-F2uCmh.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-B-F2uCmh.js +0 -1
- package/out/renderer/assets/clone-Cxm1r7GC.js +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CyaNxwAy.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -55,6 +55,11 @@ const profileName = zod.z.string().min(1).max(128).refine(
|
|
|
55
55
|
)
|
|
56
56
|
);
|
|
57
57
|
const target = zod.z.object({ kind: zod.z.enum(["agent", "team"]), name: profileName }).strict();
|
|
58
|
+
const modelSelection = zod.z.object({
|
|
59
|
+
provider: zod.z.string().min(1).max(128),
|
|
60
|
+
model_id: zod.z.string().min(1).max(512),
|
|
61
|
+
reasoning_effort: zod.z.string().min(1).max(64).nullable()
|
|
62
|
+
}).strict();
|
|
58
63
|
const profileFields = zod.z.object({
|
|
59
64
|
kind: zod.z.enum(["role", "specialist"]).optional(),
|
|
60
65
|
description: zod.z.string().max(8e3).optional(),
|
|
@@ -151,7 +156,14 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
151
156
|
op: zod.z.literal("sessions.create"),
|
|
152
157
|
requestId,
|
|
153
158
|
cwd: zod.z.string().min(1).max(4096),
|
|
154
|
-
target: target.optional()
|
|
159
|
+
target: target.optional(),
|
|
160
|
+
/*
|
|
161
|
+
* OMITTED when the user never picked anything, so the body is the one
|
|
162
|
+
* this op sent before the draft's chips could open: making them
|
|
163
|
+
* actionable is strictly additive, and a `null` here would be a
|
|
164
|
+
* different request for every caller that never asked.
|
|
165
|
+
*/
|
|
166
|
+
model: modelSelection.optional()
|
|
155
167
|
}).strict(),
|
|
156
168
|
/*
|
|
157
169
|
* What a NEW conversation's readings would be, without creating anything.
|
|
@@ -168,7 +180,9 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
168
180
|
* disagree with the session the first send creates.
|
|
169
181
|
*
|
|
170
182
|
* Body and response mirror `sessions.create` deliberately: same `cwd`, same
|
|
171
|
-
* optional `target`, same 422 for an unresolvable profile
|
|
183
|
+
* optional `target`, same `model` and same 422 for an unresolvable profile —
|
|
184
|
+
* the pane is asking the question it will ask for real on the first send, so
|
|
185
|
+
* the two bodies are derived from one selection. The response is a
|
|
172
186
|
* `CanonicalFrontendSync` — the wire shape `sessions.watch` streams — whose
|
|
173
187
|
* `snapshot.session_id` is EMPTY, because there is no session. The renderer
|
|
174
188
|
* passes it to the strip and never into the canonical sessions store.
|
|
@@ -177,7 +191,11 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
177
191
|
op: zod.z.literal("sessions.preview"),
|
|
178
192
|
requestId,
|
|
179
193
|
cwd: zod.z.string().min(1).max(4096),
|
|
180
|
-
target: target.optional()
|
|
194
|
+
target: target.optional(),
|
|
195
|
+
/* Present only when the pane's chips were used: the preview then answers
|
|
196
|
+
the reading the CHOSEN model gives, which is the ladder and window the
|
|
197
|
+
first turn will actually get. */
|
|
198
|
+
model: modelSelection.optional()
|
|
181
199
|
}).strict(),
|
|
182
200
|
zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
|
|
183
201
|
zod.z.object({
|
|
@@ -681,7 +699,8 @@ function desktopEndpoint(request) {
|
|
|
681
699
|
body: {
|
|
682
700
|
request_id: request.requestId,
|
|
683
701
|
cwd: request.cwd,
|
|
684
|
-
...request.target ? { target: request.target } : {}
|
|
702
|
+
...request.target ? { target: request.target } : {},
|
|
703
|
+
...request.model ? { model: request.model } : {}
|
|
685
704
|
}
|
|
686
705
|
};
|
|
687
706
|
case "sessions.preview":
|
|
@@ -691,7 +710,8 @@ function desktopEndpoint(request) {
|
|
|
691
710
|
body: {
|
|
692
711
|
request_id: request.requestId,
|
|
693
712
|
cwd: request.cwd,
|
|
694
|
-
...request.target ? { target: request.target } : {}
|
|
713
|
+
...request.target ? { target: request.target } : {},
|
|
714
|
+
...request.model ? { model: request.model } : {}
|
|
695
715
|
}
|
|
696
716
|
};
|
|
697
717
|
case "sessions.get":
|
|
@@ -1828,6 +1848,268 @@ const backendConfig = loadConfig();
|
|
|
1828
1848
|
const apiConfig = {
|
|
1829
1849
|
baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
|
|
1830
1850
|
};
|
|
1851
|
+
const SHEBANG = /^#!([^\r\n]+)\r?\n/;
|
|
1852
|
+
const PYTHON_NAME = /[/\\]python(?:\d+(?:\.\d+)*)?$/;
|
|
1853
|
+
const SERVE_ENTRYPOINT = "from local_operator.cli import main; main()";
|
|
1854
|
+
const PROBE_SCRIPT = "import json, sys; from local_operator.cli import main; print(json.dumps([sys.executable, getattr(sys, '_base_executable', None) or sys.executable, callable(main), [p for p in sys.path if p and p.startswith(sys.prefix)]]))";
|
|
1855
|
+
const DEFAULT_BUDGET = {
|
|
1856
|
+
totalMs: 45e3,
|
|
1857
|
+
attemptMs: 3e4,
|
|
1858
|
+
graceMs: 2e3,
|
|
1859
|
+
slackMs: 2e3
|
|
1860
|
+
};
|
|
1861
|
+
const DISCOVERY_TIMEOUT_MS = 5e3;
|
|
1862
|
+
const INTERPRETER_RESOLUTION_WORST_MS = DEFAULT_BUDGET.totalMs + 2 * (DISCOVERY_TIMEOUT_MS + DEFAULT_BUDGET.graceMs + DEFAULT_BUDGET.slackMs);
|
|
1863
|
+
class ProbeTimeout extends Error {
|
|
1864
|
+
constructor(interpreter, timeoutMs) {
|
|
1865
|
+
super(
|
|
1866
|
+
`${interpreter} did not answer an identity probe within ${timeoutMs} ms`
|
|
1867
|
+
);
|
|
1868
|
+
this.name = "ProbeTimeout";
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
1872
|
+
function runBounded(command, args, env, bounds) {
|
|
1873
|
+
return new Promise((resolve, reject) => {
|
|
1874
|
+
const child = node_child_process.spawn(command, args, {
|
|
1875
|
+
env,
|
|
1876
|
+
// Piped, so the answer is read rather than inherited by the app.
|
|
1877
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1878
|
+
windowsHide: true
|
|
1879
|
+
});
|
|
1880
|
+
let stdout = "";
|
|
1881
|
+
let stderr = "";
|
|
1882
|
+
child.stdout?.on("data", (chunk) => {
|
|
1883
|
+
stdout += chunk.toString();
|
|
1884
|
+
});
|
|
1885
|
+
child.stderr?.on("data", (chunk) => {
|
|
1886
|
+
stderr += chunk.toString();
|
|
1887
|
+
});
|
|
1888
|
+
let settled = false;
|
|
1889
|
+
let timedOut = false;
|
|
1890
|
+
const timers = [];
|
|
1891
|
+
const settle2 = (finish) => {
|
|
1892
|
+
if (settled) return;
|
|
1893
|
+
settled = true;
|
|
1894
|
+
for (const timer of timers) clearTimeout(timer);
|
|
1895
|
+
timers.length = 0;
|
|
1896
|
+
finish();
|
|
1897
|
+
};
|
|
1898
|
+
timers.push(
|
|
1899
|
+
setTimeout(() => {
|
|
1900
|
+
timedOut = true;
|
|
1901
|
+
child.kill("SIGTERM");
|
|
1902
|
+
timers.push(setTimeout(() => child.kill("SIGKILL"), bounds.graceMs));
|
|
1903
|
+
timers.push(
|
|
1904
|
+
setTimeout(
|
|
1905
|
+
() => settle2(() => reject(new ProbeTimeout(command, bounds.timeoutMs))),
|
|
1906
|
+
bounds.graceMs + bounds.slackMs
|
|
1907
|
+
)
|
|
1908
|
+
);
|
|
1909
|
+
}, bounds.timeoutMs)
|
|
1910
|
+
);
|
|
1911
|
+
child.on("error", (error) => settle2(() => reject(error)));
|
|
1912
|
+
child.on(
|
|
1913
|
+
"close",
|
|
1914
|
+
(code, signal) => settle2(() => {
|
|
1915
|
+
if (timedOut) {
|
|
1916
|
+
reject(new ProbeTimeout(command, bounds.timeoutMs));
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (code === 0) {
|
|
1920
|
+
resolve(stdout);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
const detail = errorTail(stderr);
|
|
1924
|
+
reject(
|
|
1925
|
+
new Error(
|
|
1926
|
+
`exited with code ${code ?? "null"}${signal ? ` (${signal})` : ""}${detail ? `: ${detail}` : ""}`
|
|
1927
|
+
)
|
|
1928
|
+
);
|
|
1929
|
+
})
|
|
1930
|
+
);
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
const errorTail = (text, lines = 3) => {
|
|
1934
|
+
const kept = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1935
|
+
if (kept.length === 0) return "";
|
|
1936
|
+
const tail = kept.slice(-lines).join(" | ");
|
|
1937
|
+
return kept.length > lines ? `… ${tail}` : tail;
|
|
1938
|
+
};
|
|
1939
|
+
function parseIdentity(stdout, interpreter) {
|
|
1940
|
+
const parsed = JSON.parse(stdout.trim());
|
|
1941
|
+
const [executable, baseExecutable, callable, prefixPaths] = parsed;
|
|
1942
|
+
if (typeof executable !== "string" || typeof callable !== "boolean")
|
|
1943
|
+
throw new Error(`${interpreter} reported no usable identity`);
|
|
1944
|
+
return {
|
|
1945
|
+
executable,
|
|
1946
|
+
baseExecutable: baseExecutable || executable,
|
|
1947
|
+
callable,
|
|
1948
|
+
prefixPaths: Array.isArray(prefixPaths) ? prefixPaths : []
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
async function probeOnce(interpreter, env, timeoutMs, bounds) {
|
|
1952
|
+
return parseIdentity(
|
|
1953
|
+
await runBounded(interpreter, ["-c", PROBE_SCRIPT], env, {
|
|
1954
|
+
...bounds,
|
|
1955
|
+
timeoutMs
|
|
1956
|
+
}),
|
|
1957
|
+
interpreter
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
async function probeWithin(interpreter, env, deadline2, bounds) {
|
|
1961
|
+
let lastTimeout = null;
|
|
1962
|
+
for (let attempt = 1; ; attempt++) {
|
|
1963
|
+
const remaining = deadline2 - Date.now() - bounds.graceMs - bounds.slackMs;
|
|
1964
|
+
if (remaining <= 0) {
|
|
1965
|
+
if (lastTimeout) throw lastTimeout;
|
|
1966
|
+
throw new Error(
|
|
1967
|
+
`the ${bounds.totalMs} ms interpreter-probe budget ran out before ${interpreter} answered`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
try {
|
|
1971
|
+
return await probeOnce(
|
|
1972
|
+
interpreter,
|
|
1973
|
+
env,
|
|
1974
|
+
Math.min(bounds.attemptMs, remaining),
|
|
1975
|
+
bounds
|
|
1976
|
+
);
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
if (error instanceof ProbeTimeout) {
|
|
1979
|
+
lastTimeout = error;
|
|
1980
|
+
if (attempt < PROBE_ATTEMPTS && Date.now() < deadline2) continue;
|
|
1981
|
+
}
|
|
1982
|
+
throw error;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const PROBE_ATTEMPTS = 2;
|
|
1987
|
+
async function planFor(identity, interpreter, port, env, platform, deadline2, bounds) {
|
|
1988
|
+
if (!identity.callable || !path$1.isAbsolute(identity.executable))
|
|
1989
|
+
throw new Error(
|
|
1990
|
+
`the CLI entrypoint does not import in ${identity.executable || interpreter}`
|
|
1991
|
+
);
|
|
1992
|
+
const args = ["-c", SERVE_ENTRYPOINT, "serve", "--port", String(port)];
|
|
1993
|
+
if (platform !== "win32")
|
|
1994
|
+
return {
|
|
1995
|
+
command: "bash",
|
|
1996
|
+
args: ["-c", 'exec "$@"', "owned-serve", identity.executable, ...args],
|
|
1997
|
+
env
|
|
1998
|
+
};
|
|
1999
|
+
if (identity.executable.toLowerCase() === identity.baseExecutable.toLowerCase())
|
|
2000
|
+
return { command: identity.executable, args, env };
|
|
2001
|
+
if (!path$1.isAbsolute(identity.baseExecutable))
|
|
2002
|
+
throw new Error(
|
|
2003
|
+
`it is a venv redirector whose base interpreter is not an absolute path (${identity.baseExecutable || "nothing"})`
|
|
2004
|
+
);
|
|
2005
|
+
const baseEnv = {
|
|
2006
|
+
...env,
|
|
2007
|
+
// Windows' own separator, deliberately independent of the host evaluating
|
|
2008
|
+
// the plan: the plan describes the Windows launch, not the machine that
|
|
2009
|
+
// built it, and the test host is not Windows.
|
|
2010
|
+
PYTHONPATH: [...identity.prefixPaths, env.PYTHONPATH].filter(Boolean).join(";")
|
|
2011
|
+
};
|
|
2012
|
+
const base = await probeWithin(
|
|
2013
|
+
identity.baseExecutable,
|
|
2014
|
+
baseEnv,
|
|
2015
|
+
deadline2,
|
|
2016
|
+
bounds
|
|
2017
|
+
);
|
|
2018
|
+
if (!base.callable || base.executable.toLowerCase() !== identity.baseExecutable.toLowerCase())
|
|
2019
|
+
throw new Error(
|
|
2020
|
+
`its base interpreter ${identity.baseExecutable} is not the serving process (${base.callable ? `it reported itself as ${base.executable || "nothing"}` : "it cannot import local_operator.cli even with the venv's own import paths"})`
|
|
2021
|
+
);
|
|
2022
|
+
return { command: identity.baseExecutable, args, env: baseEnv };
|
|
2023
|
+
}
|
|
2024
|
+
async function ownedServeLaunch(interpreters, port, env, platform = process.platform, budget = {}, fallbackClaims) {
|
|
2025
|
+
const bounds = {
|
|
2026
|
+
...DEFAULT_BUDGET,
|
|
2027
|
+
...budget
|
|
2028
|
+
};
|
|
2029
|
+
const deadline2 = Date.now() + bounds.totalMs;
|
|
2030
|
+
const rejected = [];
|
|
2031
|
+
const attempt = async (claims) => {
|
|
2032
|
+
for (const interpreter of [...new Set(claims)]) {
|
|
2033
|
+
if (!path$1.isAbsolute(interpreter)) {
|
|
2034
|
+
rejected.push(`${interpreter || "(empty)"}: not an absolute path`);
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
try {
|
|
2038
|
+
const identity = await probeWithin(interpreter, env, deadline2, bounds);
|
|
2039
|
+
return await planFor(
|
|
2040
|
+
identity,
|
|
2041
|
+
interpreter,
|
|
2042
|
+
port,
|
|
2043
|
+
env,
|
|
2044
|
+
platform,
|
|
2045
|
+
deadline2,
|
|
2046
|
+
bounds
|
|
2047
|
+
);
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
rejected.push(`${interpreter}: ${describe(error)}`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
return null;
|
|
2053
|
+
};
|
|
2054
|
+
if (interpreters.length === 0 && !fallbackClaims)
|
|
2055
|
+
throw new Error(
|
|
2056
|
+
"No backend interpreter was resolved. Install the Local Operator backend (`uv tool install local-operator`) or pair an external backend."
|
|
2057
|
+
);
|
|
2058
|
+
const planned = await attempt(interpreters) ?? (fallbackClaims ? await attempt(await fallbackClaims()) : null);
|
|
2059
|
+
if (planned) return planned;
|
|
2060
|
+
throw new Error(
|
|
2061
|
+
`No backend interpreter could be proven. Tried ${rejected.length}: ${rejected.join("; ")}. Install the backend with a supported installer (\`uv tool install local-operator\`) or pair an external backend.`
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
async function firstLine(command, args, env) {
|
|
2065
|
+
try {
|
|
2066
|
+
const stdout = await runBounded(command, args, env, {
|
|
2067
|
+
...DEFAULT_BUDGET,
|
|
2068
|
+
timeoutMs: DISCOVERY_TIMEOUT_MS
|
|
2069
|
+
});
|
|
2070
|
+
const line = stdout.trim().split(/\r?\n/)[0]?.trim();
|
|
2071
|
+
return line ? line : null;
|
|
2072
|
+
} catch {
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
async function windowsInterpreterCandidates(consolePath, env) {
|
|
2077
|
+
const directory = path$1.dirname(consolePath);
|
|
2078
|
+
const tool = path$1.basename(consolePath).replace(/\.(exe|cmd|bat)$/i, "");
|
|
2079
|
+
const candidates = [path$1.join(directory, "python.exe")];
|
|
2080
|
+
const uvTools = env.UV_TOOL_DIR ?? (env.APPDATA ? path$1.join(env.APPDATA, "uv", "tools") : null);
|
|
2081
|
+
if (uvTools) candidates.push(path$1.join(uvTools, tool, "Scripts", "python.exe"));
|
|
2082
|
+
const pipxHome = env.PIPX_HOME ?? (env.LOCALAPPDATA ? path$1.join(env.LOCALAPPDATA, "pipx") : null) ?? (env.USERPROFILE ? path$1.join(env.USERPROFILE, "pipx") : null);
|
|
2083
|
+
if (pipxHome)
|
|
2084
|
+
candidates.push(path$1.join(pipxHome, "venvs", tool, "Scripts", "python.exe"));
|
|
2085
|
+
return [...new Set(candidates)].filter(
|
|
2086
|
+
(candidate) => path$1.isAbsolute(candidate) && fs.existsSync(candidate)
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
async function windowsPathInterpreterCandidates(env) {
|
|
2090
|
+
const found = [];
|
|
2091
|
+
const onPath = await firstLine("where", ["python.exe"], env);
|
|
2092
|
+
if (onPath) found.push(onPath);
|
|
2093
|
+
const viaLauncher = await firstLine(
|
|
2094
|
+
"py",
|
|
2095
|
+
["-3", "-c", "import sys; print(sys.executable)"],
|
|
2096
|
+
env
|
|
2097
|
+
);
|
|
2098
|
+
if (viaLauncher) found.push(viaLauncher);
|
|
2099
|
+
return [...new Set(found)].filter(
|
|
2100
|
+
(candidate) => path$1.isAbsolute(candidate) && fs.existsSync(candidate)
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
function consoleInterpreter(consolePath) {
|
|
2104
|
+
const script = fs.readFileSync(consolePath, "utf8");
|
|
2105
|
+
const shebang = SHEBANG.exec(script)?.[1];
|
|
2106
|
+
if (!shebang || !path$1.isAbsolute(shebang) || !PYTHON_NAME.test(shebang) || !script.includes("from local_operator.cli import main")) {
|
|
2107
|
+
throw new Error(
|
|
2108
|
+
"Cannot safely own this backend launcher; use a directly paired external backend"
|
|
2109
|
+
);
|
|
2110
|
+
}
|
|
2111
|
+
return shebang;
|
|
2112
|
+
}
|
|
1831
2113
|
var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
|
|
1832
2114
|
LocalOperatorStartupMode2["EXISTING_SERVER"] = "EXISTING_SERVER";
|
|
1833
2115
|
LocalOperatorStartupMode2["GLOBAL_INSTALL"] = "GLOBAL_INSTALL";
|
|
@@ -1835,8 +2117,21 @@ var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
|
|
|
1835
2117
|
LocalOperatorStartupMode2["NOT_STARTED"] = "NOT_STARTED";
|
|
1836
2118
|
return LocalOperatorStartupMode2;
|
|
1837
2119
|
})(LocalOperatorStartupMode || {});
|
|
2120
|
+
const CONSOLE_DISCOVERY_TIMEOUT_MS = 5e3;
|
|
2121
|
+
const CONSOLE_RESOLUTION_WORST_MS = 2 * CONSOLE_DISCOVERY_TIMEOUT_MS;
|
|
2122
|
+
const SHUTDOWN_TIMEOUT_DEFAULTS = {
|
|
2123
|
+
restart: 1e4,
|
|
2124
|
+
// 10 seconds for restart operations
|
|
2125
|
+
normal: 5e3,
|
|
2126
|
+
// 5 seconds for normal shutdowns
|
|
2127
|
+
force: 3e3
|
|
2128
|
+
// 3 seconds before force killing after SIGKILL
|
|
2129
|
+
};
|
|
2130
|
+
const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
|
|
2131
|
+
const READINESS_POLL_INTERVAL_MS = 1e3;
|
|
1838
2132
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
1839
2133
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
2134
|
+
const LINE_BREAK = /\r?\n/;
|
|
1840
2135
|
class BackendServiceManager {
|
|
1841
2136
|
process = null;
|
|
1842
2137
|
isRunning = false;
|
|
@@ -1848,8 +2143,18 @@ class BackendServiceManager {
|
|
|
1848
2143
|
appDataPath = electron.app.getPath("userData");
|
|
1849
2144
|
venvPath;
|
|
1850
2145
|
healthCheckInterval = null;
|
|
1851
|
-
|
|
1852
|
-
|
|
2146
|
+
/** The only process this manager may terminate. Null means it owns nothing,
|
|
2147
|
+
* which is a reason to report "nothing to stop" - never to go looking. */
|
|
2148
|
+
ownedServe = null;
|
|
2149
|
+
/** In-flight start, so a stop can await the resolver/readiness work that has
|
|
2150
|
+
* not yet produced a child. Without it, cleanup can return before a spawn
|
|
2151
|
+
* that was already committed, and the caller installs over a live serve. */
|
|
2152
|
+
startPromise = null;
|
|
2153
|
+
restartPromise = null;
|
|
2154
|
+
/** Bumped by every stop. A start that began under an older epoch cannot
|
|
2155
|
+
* report success or adopt its child: the cancellation happened after it
|
|
2156
|
+
* checked, and this is what it re-checks across each await. */
|
|
2157
|
+
startEpoch = 0;
|
|
1853
2158
|
shellEnv = {};
|
|
1854
2159
|
// External/dev backends may be explicitly paired through main's environment.
|
|
1855
2160
|
// Managed starts always rotate this; it is never exposed by preload or logs.
|
|
@@ -1952,14 +2257,7 @@ class BackendServiceManager {
|
|
|
1952
2257
|
// Flag to track when the app is being closed
|
|
1953
2258
|
isAutoUpdating = false;
|
|
1954
2259
|
// Flag to track when an autoupdate is in progress
|
|
1955
|
-
shutdownTimeoutMs = {
|
|
1956
|
-
restart: 1e4,
|
|
1957
|
-
// 10 seconds for restart operations
|
|
1958
|
-
normal: 5e3,
|
|
1959
|
-
// 5 seconds for normal shutdowns
|
|
1960
|
-
force: 3e3
|
|
1961
|
-
// 3 seconds before force killing after SIGKILL
|
|
1962
|
-
};
|
|
2260
|
+
shutdownTimeoutMs = { ...SHUTDOWN_TIMEOUT_DEFAULTS };
|
|
1963
2261
|
// Configurable timeouts for different shutdown scenarios
|
|
1964
2262
|
/**
|
|
1965
2263
|
* Constructor
|
|
@@ -2246,7 +2544,12 @@ class BackendServiceManager {
|
|
|
2246
2544
|
`Checking if local-operator command exists: ${command}`,
|
|
2247
2545
|
LogFileType.BACKEND
|
|
2248
2546
|
);
|
|
2249
|
-
const { stdout } = await execPromise(command
|
|
2547
|
+
const { stdout } = await execPromise(command, {
|
|
2548
|
+
// Bounded: a quit that arrives mid-start waits on this same path, and an
|
|
2549
|
+
// unbounded child here is a wait with nothing underneath it.
|
|
2550
|
+
timeout: CONSOLE_DISCOVERY_TIMEOUT_MS,
|
|
2551
|
+
windowsHide: true
|
|
2552
|
+
});
|
|
2250
2553
|
if (stdout.trim()) {
|
|
2251
2554
|
logger.info(
|
|
2252
2555
|
`local-operator command found at: ${stdout.trim()}`,
|
|
@@ -2374,12 +2677,30 @@ class BackendServiceManager {
|
|
|
2374
2677
|
}
|
|
2375
2678
|
return false;
|
|
2376
2679
|
}
|
|
2680
|
+
/** Keep PATH discovery authoritative; `ownedServeLaunch` proves the identity
|
|
2681
|
+
* of what it found rather than trusting the name it was resolved under. */
|
|
2682
|
+
async resolveGlobalConsole(env) {
|
|
2683
|
+
const { stdout } = await execPromise(
|
|
2684
|
+
process.platform === "win32" ? "where local-operator" : "which local-operator",
|
|
2685
|
+
{ env, timeout: CONSOLE_DISCOVERY_TIMEOUT_MS }
|
|
2686
|
+
);
|
|
2687
|
+
return stdout.trim().split(LINE_BREAK)[0];
|
|
2688
|
+
}
|
|
2377
2689
|
/**
|
|
2378
2690
|
* Start the backend service
|
|
2379
2691
|
* @returns Promise resolving to true if the backend was started successfully, false otherwise
|
|
2380
2692
|
*/
|
|
2381
|
-
|
|
2382
|
-
this.isAppClosing
|
|
2693
|
+
start() {
|
|
2694
|
+
if (this.isAppClosing) return Promise.resolve(false);
|
|
2695
|
+
if (this.startPromise) return this.startPromise;
|
|
2696
|
+
this.startPromise = this.startOwned(this.startEpoch).finally(() => {
|
|
2697
|
+
this.startPromise = null;
|
|
2698
|
+
});
|
|
2699
|
+
return this.startPromise;
|
|
2700
|
+
}
|
|
2701
|
+
async startOwned(epoch) {
|
|
2702
|
+
if (this.ownedServe?.stop) return false;
|
|
2703
|
+
if (this.ownedServe) return this.isRunning;
|
|
2383
2704
|
if (this.isDisabled) {
|
|
2384
2705
|
logger.info(
|
|
2385
2706
|
"Backend Service Manager is disabled. Skipping backend start.",
|
|
@@ -2388,155 +2709,114 @@ class BackendServiceManager {
|
|
|
2388
2709
|
this.notifyBackendReady();
|
|
2389
2710
|
return true;
|
|
2390
2711
|
}
|
|
2391
|
-
|
|
2712
|
+
const existing = await this.checkExistingBackend();
|
|
2713
|
+
if (epoch !== this.startEpoch || this.isAppClosing) return false;
|
|
2714
|
+
if (existing) {
|
|
2392
2715
|
this.isRunning = true;
|
|
2393
2716
|
this.startupMode = "EXISTING_SERVER";
|
|
2394
2717
|
this.startHealthCheck();
|
|
2395
2718
|
return true;
|
|
2396
2719
|
}
|
|
2397
2720
|
this.desktopToken = node_crypto.randomBytes(32).toString("hex");
|
|
2721
|
+
let captured = null;
|
|
2398
2722
|
try {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
);
|
|
2723
|
+
const globalInstall = await this.checkLocalOperatorExists();
|
|
2724
|
+
const env = this.backendSpawnEnv();
|
|
2725
|
+
let interpreters;
|
|
2726
|
+
if (globalInstall) {
|
|
2404
2727
|
this.startupMode = "GLOBAL_INSTALL";
|
|
2405
|
-
const
|
|
2406
|
-
|
|
2407
|
-
logger.info(
|
|
2408
|
-
`Starting backend service with global command: ${cmd} ${args.join(" ")}`,
|
|
2409
|
-
LogFileType.BACKEND
|
|
2410
|
-
);
|
|
2411
|
-
this.process = node_child_process.spawn(cmd, args, {
|
|
2412
|
-
detached: false,
|
|
2413
|
-
// Ensure process is not detached from parent
|
|
2414
|
-
stdio: "pipe",
|
|
2415
|
-
env: this.backendSpawnEnv(),
|
|
2416
|
-
// On Windows, we need to create a new process group to ensure proper termination
|
|
2417
|
-
...process.platform === "win32" ? { windowsHide: true } : {}
|
|
2418
|
-
});
|
|
2728
|
+
const executable = await this.resolveGlobalConsole(env);
|
|
2729
|
+
interpreters = process.platform === "win32" ? await windowsInterpreterCandidates(executable, env) : [consoleInterpreter(executable)];
|
|
2419
2730
|
} else {
|
|
2420
|
-
logger.info(
|
|
2421
|
-
"Global local-operator not found, using virtual environment",
|
|
2422
|
-
LogFileType.BACKEND
|
|
2423
|
-
);
|
|
2424
2731
|
this.startupMode = "APP_BUNDLED_VENV";
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
const localOperatorExe = path$1.join(
|
|
2429
|
-
this.venvPath,
|
|
2430
|
-
"Scripts",
|
|
2431
|
-
"local-operator.exe"
|
|
2432
|
-
);
|
|
2433
|
-
if (fs.existsSync(localOperatorExe)) {
|
|
2434
|
-
cmd = localOperatorExe;
|
|
2435
|
-
args = ["serve", "--port", this.port.toString()];
|
|
2436
|
-
} else {
|
|
2437
|
-
const pythonExe = path$1.join(this.venvPath, "Scripts", "python.exe");
|
|
2438
|
-
if (fs.existsSync(pythonExe)) {
|
|
2439
|
-
cmd = pythonExe;
|
|
2440
|
-
args = [
|
|
2441
|
-
"-m",
|
|
2442
|
-
"local_operator",
|
|
2443
|
-
"serve",
|
|
2444
|
-
"--port",
|
|
2445
|
-
this.port.toString()
|
|
2446
|
-
];
|
|
2447
|
-
} else {
|
|
2448
|
-
const activateScript = path$1.join(
|
|
2449
|
-
this.venvPath,
|
|
2450
|
-
"Scripts",
|
|
2451
|
-
"Activate.ps1"
|
|
2452
|
-
);
|
|
2453
|
-
cmd = "powershell.exe";
|
|
2454
|
-
args = [
|
|
2455
|
-
"-ExecutionPolicy",
|
|
2456
|
-
"Bypass",
|
|
2457
|
-
"-Command",
|
|
2458
|
-
`"& '${activateScript}'; local-operator serve --port ${this.port}"`
|
|
2459
|
-
];
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2462
|
-
} else {
|
|
2463
|
-
const activateScript = path$1.join(this.venvPath, "bin", "activate");
|
|
2464
|
-
cmd = "bash";
|
|
2465
|
-
args = [
|
|
2466
|
-
"-c",
|
|
2467
|
-
`. "${activateScript}" && local-operator serve --port ${this.port}`
|
|
2468
|
-
];
|
|
2469
|
-
}
|
|
2470
|
-
logger.info(
|
|
2471
|
-
`Starting backend service with venv: ${cmd} ${args.join(" ")}`,
|
|
2472
|
-
LogFileType.BACKEND
|
|
2732
|
+
const bin = path$1.join(
|
|
2733
|
+
this.venvPath,
|
|
2734
|
+
process.platform === "win32" ? "Scripts" : "bin"
|
|
2473
2735
|
);
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2736
|
+
interpreters = [
|
|
2737
|
+
path$1.join(bin, process.platform === "win32" ? "python.exe" : "python")
|
|
2738
|
+
];
|
|
2739
|
+
env.VIRTUAL_ENV = this.venvPath;
|
|
2740
|
+
env.PATH = `${bin}${process.platform === "win32" ? ";" : ":"}${env.PATH ?? ""}`;
|
|
2741
|
+
env.PYTHONHOME = void 0;
|
|
2742
|
+
}
|
|
2743
|
+
const launch = await ownedServeLaunch(
|
|
2744
|
+
interpreters,
|
|
2745
|
+
this.port,
|
|
2746
|
+
env,
|
|
2747
|
+
process.platform,
|
|
2748
|
+
{},
|
|
2749
|
+
// The PATH-side claims spawn discovery children, so they are offered
|
|
2750
|
+
// only after the claims above have all failed (round 3, F14).
|
|
2751
|
+
process.platform === "win32" ? () => windowsPathInterpreterCandidates(env) : void 0
|
|
2752
|
+
);
|
|
2753
|
+
if (epoch !== this.startEpoch || this.isAppClosing) return false;
|
|
2754
|
+
const child = node_child_process.spawn(launch.command, launch.args, {
|
|
2755
|
+
detached: false,
|
|
2756
|
+
stdio: "pipe",
|
|
2757
|
+
// The plan owns the environment it proved: a Windows venv's base
|
|
2758
|
+
// interpreter needs the venv's import paths, added there and not here.
|
|
2759
|
+
env: launch.env,
|
|
2760
|
+
windowsHide: true
|
|
2761
|
+
});
|
|
2762
|
+
const generation = this.captureServe(child);
|
|
2763
|
+
captured = generation;
|
|
2764
|
+
if (child.stdout) {
|
|
2765
|
+
child.stdout.on("data", (data) => {
|
|
2485
2766
|
logger.info(`Backend stdout: ${data}`, LogFileType.BACKEND);
|
|
2486
2767
|
});
|
|
2487
2768
|
}
|
|
2488
|
-
if (
|
|
2489
|
-
|
|
2769
|
+
if (child.stderr) {
|
|
2770
|
+
child.stderr.on("data", (data) => {
|
|
2490
2771
|
logger.error(`Backend stderr: ${data}`, LogFileType.BACKEND);
|
|
2491
2772
|
});
|
|
2492
2773
|
}
|
|
2493
|
-
this.process.on("exit", (code) => {
|
|
2494
|
-
logger.info(
|
|
2495
|
-
`Backend process exited with code ${code}`,
|
|
2496
|
-
LogFileType.BACKEND
|
|
2497
|
-
);
|
|
2498
|
-
this.isRunning = false;
|
|
2499
|
-
this.process = null;
|
|
2500
|
-
if (this.exitResolve) {
|
|
2501
|
-
this.exitResolve();
|
|
2502
|
-
this.exitResolve = null;
|
|
2503
|
-
}
|
|
2504
|
-
const isExpectedWindowsExit = process.platform === "win32" && (this.isAppClosing || this.isAutoUpdating) && code === 1;
|
|
2505
|
-
if (code !== 0 && code !== null && !isExpectedWindowsExit) {
|
|
2506
|
-
electron.dialog.showErrorBox(
|
|
2507
|
-
"Backend Error",
|
|
2508
|
-
`The Local Operator backend service exited unexpectedly with code ${code}. Please restart the application.`
|
|
2509
|
-
);
|
|
2510
|
-
}
|
|
2511
|
-
});
|
|
2512
2774
|
let attempts = 0;
|
|
2513
2775
|
const maxAttempts = 30;
|
|
2514
2776
|
while (attempts < maxAttempts) {
|
|
2515
|
-
|
|
2777
|
+
const healthy = await this.checkHealth();
|
|
2778
|
+
if (epoch !== this.startEpoch || generation.exited || generation.stop)
|
|
2779
|
+
break;
|
|
2780
|
+
if (healthy) {
|
|
2516
2781
|
this.isRunning = true;
|
|
2517
2782
|
this.startHealthCheck();
|
|
2518
2783
|
this.notifyBackendReady();
|
|
2519
2784
|
return true;
|
|
2520
2785
|
}
|
|
2521
|
-
await new Promise(
|
|
2786
|
+
await new Promise(
|
|
2787
|
+
(resolve) => setTimeout(resolve, READINESS_POLL_INTERVAL_MS)
|
|
2788
|
+
);
|
|
2522
2789
|
attempts++;
|
|
2523
2790
|
}
|
|
2791
|
+
await this.stopGeneration(generation, false);
|
|
2792
|
+
if (epoch !== this.startEpoch) return false;
|
|
2524
2793
|
logger.error(
|
|
2525
2794
|
"Failed to start backend service after multiple attempts",
|
|
2526
2795
|
LogFileType.BACKEND
|
|
2527
2796
|
);
|
|
2528
|
-
|
|
2797
|
+
this.reportStartFailure(
|
|
2529
2798
|
"Backend Error",
|
|
2530
2799
|
"Failed to start the Local Operator backend service. Please check the logs for more information."
|
|
2531
2800
|
);
|
|
2532
2801
|
return false;
|
|
2533
2802
|
} catch (error) {
|
|
2803
|
+
if (captured) {
|
|
2804
|
+
try {
|
|
2805
|
+
await this.stopGeneration(captured, false);
|
|
2806
|
+
} catch (cleanupError) {
|
|
2807
|
+
logger.error(
|
|
2808
|
+
"Owned backend cleanup after a failed start did not confirm exit",
|
|
2809
|
+
LogFileType.BACKEND,
|
|
2810
|
+
cleanupError
|
|
2811
|
+
);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2534
2814
|
logger.error(
|
|
2535
2815
|
"Error starting backend service:",
|
|
2536
2816
|
LogFileType.BACKEND,
|
|
2537
2817
|
error
|
|
2538
2818
|
);
|
|
2539
|
-
|
|
2819
|
+
this.reportStartFailure(
|
|
2540
2820
|
"Backend Error",
|
|
2541
2821
|
`Error starting the Local Operator backend service: ${error}`
|
|
2542
2822
|
);
|
|
@@ -2544,232 +2824,156 @@ class BackendServiceManager {
|
|
|
2544
2824
|
}
|
|
2545
2825
|
}
|
|
2546
2826
|
/**
|
|
2547
|
-
* Stop the
|
|
2548
|
-
*
|
|
2549
|
-
* @returns Promise resolving when the backend has been stopped
|
|
2827
|
+
* Stop only the captured serve generation, including failed startups. A port
|
|
2828
|
+
* or process name is not ownership. Unconfirmed exit blocks replacement.
|
|
2550
2829
|
*/
|
|
2551
2830
|
async stop(isRestart = false) {
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
}
|
|
2831
|
+
this.startEpoch++;
|
|
2832
|
+
if (!isRestart) this.isAppClosing = true;
|
|
2555
2833
|
this.disposeStreamRelay();
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2834
|
+
this.stopHealthCheck();
|
|
2835
|
+
const generation = this.ownedServe;
|
|
2836
|
+
if (generation) await this.stopGeneration(generation, isRestart);
|
|
2837
|
+
await this.startPromise;
|
|
2838
|
+
}
|
|
2839
|
+
captureServe(child) {
|
|
2840
|
+
let resolveExit = () => {
|
|
2841
|
+
};
|
|
2842
|
+
const generation = {
|
|
2843
|
+
child,
|
|
2844
|
+
exited: false,
|
|
2845
|
+
exit: new Promise((resolve) => {
|
|
2846
|
+
resolveExit = resolve;
|
|
2847
|
+
}),
|
|
2848
|
+
resolveExit: () => resolveExit(),
|
|
2849
|
+
stop: null,
|
|
2850
|
+
timers: /* @__PURE__ */ new Set()
|
|
2851
|
+
};
|
|
2852
|
+
this.ownedServe = generation;
|
|
2853
|
+
this.process = child;
|
|
2854
|
+
const exited = (code) => {
|
|
2855
|
+
if (generation.exited) return;
|
|
2856
|
+
generation.exited = true;
|
|
2857
|
+
for (const timer of generation.timers) clearTimeout(timer);
|
|
2858
|
+
generation.timers.clear();
|
|
2859
|
+
generation.resolveExit();
|
|
2860
|
+
if (this.ownedServe !== generation) return;
|
|
2861
|
+
this.ownedServe = null;
|
|
2862
|
+
this.process = null;
|
|
2863
|
+
this.isRunning = false;
|
|
2864
|
+
if (code != null && code !== 0 && !generation.stop && !this.isAppClosing && !this.isAutoUpdating) {
|
|
2865
|
+
electron.dialog.showErrorBox(
|
|
2866
|
+
"Backend Error",
|
|
2867
|
+
`The Local Operator backend service exited unexpectedly with code ${code}. Please restart the application.`
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
child.once("exit", exited);
|
|
2872
|
+
child.on("error", (error) => {
|
|
2873
|
+
logger.error("Owned backend process error", LogFileType.BACKEND, error);
|
|
2874
|
+
if (child.pid === void 0) exited();
|
|
2875
|
+
});
|
|
2876
|
+
return generation;
|
|
2877
|
+
}
|
|
2878
|
+
/** Signals may only be sent while the handle itself still reports a live
|
|
2879
|
+
* process. Node keeps `kill()` callable on a reaped child, and on a PID the
|
|
2880
|
+
* OS has since recycled that call reaches whatever now holds the number. */
|
|
2881
|
+
canSignal(generation) {
|
|
2882
|
+
return !generation.exited && generation.child.exitCode === null && generation.child.signalCode === null;
|
|
2883
|
+
}
|
|
2884
|
+
/**
|
|
2885
|
+
* The one termination sequence: SIGTERM, wait out the grace, SIGKILL, wait
|
|
2886
|
+
* again, and throw if the process never reported exit.
|
|
2887
|
+
*
|
|
2888
|
+
* Failing rather than returning is the point. Callers use a resolved stop as
|
|
2889
|
+
* permission to replace the backend - install over it, spawn a successor - so
|
|
2890
|
+
* "we signalled it and moved on" would authorise exactly the double-serve
|
|
2891
|
+
* this class exists to prevent. An unconfirmed exit keeps ownership, and the
|
|
2892
|
+
* caller reports failure instead of proceeding.
|
|
2893
|
+
*/
|
|
2894
|
+
stopGeneration(generation, isRestart) {
|
|
2895
|
+
if (generation.stop) return generation.stop;
|
|
2896
|
+
generation.stop = (async () => {
|
|
2897
|
+
const waitForExit = (ms) => new Promise((resolve) => {
|
|
2898
|
+
if (generation.exited) {
|
|
2899
|
+
resolve(true);
|
|
2579
2900
|
return;
|
|
2580
2901
|
}
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2902
|
+
const timer = setTimeout(() => {
|
|
2903
|
+
generation.timers.delete(timer);
|
|
2904
|
+
resolve(false);
|
|
2905
|
+
}, ms);
|
|
2906
|
+
generation.timers.add(timer);
|
|
2907
|
+
void generation.exit.then(() => {
|
|
2908
|
+
clearTimeout(timer);
|
|
2909
|
+
generation.timers.delete(timer);
|
|
2910
|
+
resolve(true);
|
|
2589
2911
|
});
|
|
2590
2912
|
});
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
);
|
|
2599
|
-
node_child_process.spawn("taskkill", ["/pid", pid.toString(), "/t"]);
|
|
2600
|
-
}
|
|
2601
|
-
} else {
|
|
2602
|
-
logger.info(
|
|
2603
|
-
"Sending SIGTERM to backend process",
|
|
2604
|
-
LogFileType.BACKEND
|
|
2605
|
-
);
|
|
2606
|
-
this.process.kill("SIGTERM");
|
|
2607
|
-
}
|
|
2608
|
-
let forceKillTimeoutId = null;
|
|
2609
|
-
const timeoutPromise = new Promise((resolve) => {
|
|
2610
|
-
const timeoutMs = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
|
|
2611
|
-
logger.info(
|
|
2612
|
-
`Setting shutdown timeout to ${timeoutMs}ms for ${isRestart ? "restart" : "normal"} operation`,
|
|
2613
|
-
LogFileType.BACKEND
|
|
2913
|
+
if (this.canSignal(generation)) generation.child.kill("SIGTERM");
|
|
2914
|
+
const grace = isRestart ? this.shutdownTimeoutMs.restart : this.shutdownTimeoutMs.normal;
|
|
2915
|
+
if (!await waitForExit(grace)) {
|
|
2916
|
+
if (this.canSignal(generation)) generation.child.kill("SIGKILL");
|
|
2917
|
+
if (!await waitForExit(this.shutdownTimeoutMs.force)) {
|
|
2918
|
+
throw new Error(
|
|
2919
|
+
"Owned backend exit is unconfirmed; refusing replacement"
|
|
2614
2920
|
);
|
|
2615
|
-
forceKillTimeoutId = setTimeout(() => {
|
|
2616
|
-
if (this.process) {
|
|
2617
|
-
logger.info(
|
|
2618
|
-
`Backend process did not exit within ${timeoutMs}ms, force killing`,
|
|
2619
|
-
LogFileType.BACKEND
|
|
2620
|
-
);
|
|
2621
|
-
try {
|
|
2622
|
-
if (process.platform === "win32" && this.process.pid) {
|
|
2623
|
-
node_child_process.spawn("taskkill", [
|
|
2624
|
-
"/pid",
|
|
2625
|
-
this.process.pid.toString(),
|
|
2626
|
-
"/f",
|
|
2627
|
-
"/t"
|
|
2628
|
-
]);
|
|
2629
|
-
try {
|
|
2630
|
-
execPromise(
|
|
2631
|
-
'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
|
|
2632
|
-
).catch((error) => {
|
|
2633
|
-
logger.warn(
|
|
2634
|
-
"Error terminating python processes:",
|
|
2635
|
-
LogFileType.BACKEND,
|
|
2636
|
-
error
|
|
2637
|
-
);
|
|
2638
|
-
});
|
|
2639
|
-
execPromise("taskkill /f /im local-operator.exe /t").catch(
|
|
2640
|
-
(error) => {
|
|
2641
|
-
logger.warn(
|
|
2642
|
-
"Error terminating local-operator processes:",
|
|
2643
|
-
LogFileType.BACKEND,
|
|
2644
|
-
error
|
|
2645
|
-
);
|
|
2646
|
-
}
|
|
2647
|
-
);
|
|
2648
|
-
} catch (taskkillError) {
|
|
2649
|
-
logger.warn(
|
|
2650
|
-
"Error executing taskkill command:",
|
|
2651
|
-
LogFileType.BACKEND,
|
|
2652
|
-
taskkillError
|
|
2653
|
-
);
|
|
2654
|
-
}
|
|
2655
|
-
} else if (this.process) {
|
|
2656
|
-
this.process.kill("SIGKILL");
|
|
2657
|
-
if (!isRestart) {
|
|
2658
|
-
try {
|
|
2659
|
-
execPromise('pkill -f "local-operator serve"').catch(
|
|
2660
|
-
(error) => {
|
|
2661
|
-
logger.warn(
|
|
2662
|
-
"Error killing processes by pattern:",
|
|
2663
|
-
LogFileType.BACKEND,
|
|
2664
|
-
error
|
|
2665
|
-
);
|
|
2666
|
-
}
|
|
2667
|
-
);
|
|
2668
|
-
} catch (pkillError) {
|
|
2669
|
-
logger.warn(
|
|
2670
|
-
"Error executing pkill command:",
|
|
2671
|
-
LogFileType.BACKEND,
|
|
2672
|
-
pkillError
|
|
2673
|
-
);
|
|
2674
|
-
}
|
|
2675
|
-
}
|
|
2676
|
-
setTimeout(() => {
|
|
2677
|
-
if (this.process) {
|
|
2678
|
-
logger.warn(
|
|
2679
|
-
`Process still exists after SIGKILL, attempting more aggressive termination after ${this.shutdownTimeoutMs.force}ms`,
|
|
2680
|
-
LogFileType.BACKEND
|
|
2681
|
-
);
|
|
2682
|
-
try {
|
|
2683
|
-
if (!isRestart) {
|
|
2684
|
-
execPromise(
|
|
2685
|
-
'pkill -9 -f "local-operator serve"'
|
|
2686
|
-
).catch(() => {
|
|
2687
|
-
});
|
|
2688
|
-
}
|
|
2689
|
-
this.process = null;
|
|
2690
|
-
this.isRunning = false;
|
|
2691
|
-
if (this.exitResolve) {
|
|
2692
|
-
this.exitResolve();
|
|
2693
|
-
this.exitResolve = null;
|
|
2694
|
-
}
|
|
2695
|
-
} catch (finalError) {
|
|
2696
|
-
logger.error(
|
|
2697
|
-
"Error during final force kill attempt:",
|
|
2698
|
-
LogFileType.BACKEND,
|
|
2699
|
-
finalError
|
|
2700
|
-
);
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
}, this.shutdownTimeoutMs.force);
|
|
2704
|
-
}
|
|
2705
|
-
} catch (error) {
|
|
2706
|
-
logger.error(
|
|
2707
|
-
"Error force killing process:",
|
|
2708
|
-
LogFileType.BACKEND,
|
|
2709
|
-
error
|
|
2710
|
-
);
|
|
2711
|
-
}
|
|
2712
|
-
this.process = null;
|
|
2713
|
-
this.isRunning = false;
|
|
2714
|
-
if (this.exitResolve) {
|
|
2715
|
-
this.exitResolve();
|
|
2716
|
-
this.exitResolve = null;
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
resolve();
|
|
2720
|
-
}, timeoutMs);
|
|
2721
|
-
});
|
|
2722
|
-
await Promise.race([this.exitPromise, timeoutPromise]);
|
|
2723
|
-
if (forceKillTimeoutId) {
|
|
2724
|
-
clearTimeout(forceKillTimeoutId);
|
|
2725
|
-
forceKillTimeoutId = null;
|
|
2726
|
-
}
|
|
2727
|
-
if (!isRestart) {
|
|
2728
|
-
logger.info(
|
|
2729
|
-
"Performing additional cleanup for final shutdown",
|
|
2730
|
-
LogFileType.BACKEND
|
|
2731
|
-
);
|
|
2732
|
-
try {
|
|
2733
|
-
if (process.platform === "win32") {
|
|
2734
|
-
await execPromise(
|
|
2735
|
-
'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
|
|
2736
|
-
).catch(() => {
|
|
2737
|
-
});
|
|
2738
|
-
await execPromise("taskkill /f /im local-operator.exe /t").catch(
|
|
2739
|
-
() => {
|
|
2740
|
-
}
|
|
2741
|
-
);
|
|
2742
|
-
} else {
|
|
2743
|
-
await execPromise('pkill -f "local-operator serve"').catch(() => {
|
|
2744
|
-
});
|
|
2745
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
2746
|
-
await execPromise('pkill -9 -f "local-operator serve"').catch(
|
|
2747
|
-
() => {
|
|
2748
|
-
}
|
|
2749
|
-
);
|
|
2750
|
-
}
|
|
2751
|
-
} catch (cleanupError) {
|
|
2752
|
-
logger.warn(
|
|
2753
|
-
"Error during additional cleanup (this may be normal if processes were already terminated):",
|
|
2754
|
-
LogFileType.BACKEND,
|
|
2755
|
-
cleanupError
|
|
2756
|
-
);
|
|
2757
|
-
}
|
|
2758
|
-
}
|
|
2759
|
-
} catch (error) {
|
|
2760
|
-
logger.error(
|
|
2761
|
-
"Error stopping backend process:",
|
|
2762
|
-
LogFileType.BACKEND,
|
|
2763
|
-
error
|
|
2764
|
-
);
|
|
2765
|
-
this.process = null;
|
|
2766
|
-
this.isRunning = false;
|
|
2767
|
-
if (this.exitResolve) {
|
|
2768
|
-
this.exitResolve();
|
|
2769
|
-
this.exitResolve = null;
|
|
2770
2921
|
}
|
|
2771
2922
|
}
|
|
2772
|
-
|
|
2923
|
+
if (!isRestart && !this.ownedServe)
|
|
2924
|
+
this.startupMode = "NOT_STARTED";
|
|
2925
|
+
logger.info("Owned backend generation stopped", LogFileType.BACKEND);
|
|
2926
|
+
})();
|
|
2927
|
+
return generation.stop;
|
|
2928
|
+
}
|
|
2929
|
+
stopHealthCheck() {
|
|
2930
|
+
if (this.healthCheckInterval) clearInterval(this.healthCheckInterval);
|
|
2931
|
+
this.healthCheckInterval = null;
|
|
2932
|
+
}
|
|
2933
|
+
/** Whether a quit may proceed. `will-quit` cannot await a listener, so it
|
|
2934
|
+
* prevents the first quit, runs cleanup, and asks this on the retry. */
|
|
2935
|
+
/** Whether a shutdown is in flight.
|
|
2936
|
+
*
|
|
2937
|
+
* `stop(false)` is terminal and is what the quit path calls, so this is the
|
|
2938
|
+
* state a caller checks before raising blocking UI it would never be able to
|
|
2939
|
+
* dismiss - `index.ts`'s start-failure paths are the callers. */
|
|
2940
|
+
isShuttingDown() {
|
|
2941
|
+
return this.isAppClosing;
|
|
2942
|
+
}
|
|
2943
|
+
/**
|
|
2944
|
+
* Report a start failure to the user - unless the app is on its way out.
|
|
2945
|
+
*
|
|
2946
|
+
* `showErrorBox` is a native modal: it parks the main thread until somebody
|
|
2947
|
+
* dismisses it, and Electron cannot run the quit it was asked for past a
|
|
2948
|
+
* parked thread. On the quit path that is not a message, it is a deadlock -
|
|
2949
|
+
* the quit has already prevented itself, the owned cleanup it waits on can
|
|
2950
|
+
* never finish, and the quit's own failsafe cannot even fire, because that is
|
|
2951
|
+
* a timer on the thread the dialog holds (review round 4, Q-20: a SIGTERM
|
|
2952
|
+
* during the first start left the app alive 50 s later with the failsafe line
|
|
2953
|
+
* never logged). A shutdown in flight logs the failure instead, where the
|
|
2954
|
+
* post-mortem finds it without the process still being up.
|
|
2955
|
+
*/
|
|
2956
|
+
reportStartFailure(title, message) {
|
|
2957
|
+
if (this.isAppClosing) {
|
|
2958
|
+
logger.error(
|
|
2959
|
+
`${title} (not shown; the app is shutting down): ${message}`,
|
|
2960
|
+
LogFileType.BACKEND
|
|
2961
|
+
);
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
electron.dialog.showErrorBox(title, message);
|
|
2965
|
+
}
|
|
2966
|
+
isOwnedCleanupComplete() {
|
|
2967
|
+
return this.isAppClosing && !this.ownedServe && !this.startPromise;
|
|
2968
|
+
}
|
|
2969
|
+
/** Synchronous exit cannot await cleanup. Canonical runtimes deliberately
|
|
2970
|
+
* outlive their HTTP server, so neither PID rediscovery nor descent is safe. */
|
|
2971
|
+
emergencyStopOwned() {
|
|
2972
|
+
this.startEpoch++;
|
|
2973
|
+
this.isAppClosing = true;
|
|
2974
|
+
const generation = this.ownedServe;
|
|
2975
|
+
if (generation && !generation.exited && generation.child.exitCode === null && generation.child.signalCode === null) {
|
|
2976
|
+
generation.child.kill("SIGKILL");
|
|
2773
2977
|
}
|
|
2774
2978
|
}
|
|
2775
2979
|
/**
|
|
@@ -2814,7 +3018,10 @@ class BackendServiceManager {
|
|
|
2814
3018
|
* assertable without waiting out a 30s interval.
|
|
2815
3019
|
*/
|
|
2816
3020
|
async checkUnhealthyBackend() {
|
|
3021
|
+
const epoch = this.startEpoch;
|
|
2817
3022
|
const isHealthy = await this.checkHealth();
|
|
3023
|
+
if (epoch !== this.startEpoch || this.isAppClosing || this.isAutoUpdating || this.isDisabled)
|
|
3024
|
+
return;
|
|
2818
3025
|
if (isHealthy) return;
|
|
2819
3026
|
logger.info("Backend health check failed", LogFileType.BACKEND);
|
|
2820
3027
|
if (this.isExternalBackend) {
|
|
@@ -2832,8 +3039,7 @@ class BackendServiceManager {
|
|
|
2832
3039
|
await this.start();
|
|
2833
3040
|
return;
|
|
2834
3041
|
}
|
|
2835
|
-
await this.
|
|
2836
|
-
await this.start();
|
|
3042
|
+
await this.restart();
|
|
2837
3043
|
}
|
|
2838
3044
|
/**
|
|
2839
3045
|
* Get the port number used by the backend service
|
|
@@ -2883,52 +3089,21 @@ class BackendServiceManager {
|
|
|
2883
3089
|
* timeouts from the stop operation don't affect the newly started process
|
|
2884
3090
|
* @returns Promise resolving to true if the restart was successful, false otherwise
|
|
2885
3091
|
*/
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
this.
|
|
2889
|
-
await this.stop(true);
|
|
2890
|
-
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
2891
|
-
if (this.process) {
|
|
2892
|
-
logger.warn(
|
|
2893
|
-
"Process still exists after stop, attempting to force terminate",
|
|
2894
|
-
LogFileType.BACKEND
|
|
2895
|
-
);
|
|
3092
|
+
restart() {
|
|
3093
|
+
if (this.restartPromise) return this.restartPromise;
|
|
3094
|
+
this.restartPromise = (async () => {
|
|
2896
3095
|
try {
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
this.process.kill("SIGKILL");
|
|
2901
|
-
}
|
|
2902
|
-
await new Promise((resolve) => {
|
|
2903
|
-
if (!this.process) {
|
|
2904
|
-
resolve(null);
|
|
2905
|
-
return;
|
|
2906
|
-
}
|
|
2907
|
-
this.process.once("exit", () => {
|
|
2908
|
-
resolve(null);
|
|
2909
|
-
});
|
|
2910
|
-
setTimeout(resolve, 1e3);
|
|
2911
|
-
});
|
|
2912
|
-
this.process = null;
|
|
2913
|
-
this.isRunning = false;
|
|
3096
|
+
await this.stop(true);
|
|
3097
|
+
if (this.isAppClosing) return false;
|
|
3098
|
+
return await this.start();
|
|
2914
3099
|
} catch (error) {
|
|
2915
|
-
logger.error(
|
|
2916
|
-
|
|
2917
|
-
LogFileType.BACKEND,
|
|
2918
|
-
error
|
|
2919
|
-
);
|
|
3100
|
+
logger.error("Backend restart refused", LogFileType.BACKEND, error);
|
|
3101
|
+
return false;
|
|
2920
3102
|
}
|
|
2921
|
-
}
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
"Backend service restarted successfully",
|
|
2926
|
-
LogFileType.BACKEND
|
|
2927
|
-
);
|
|
2928
|
-
} else {
|
|
2929
|
-
logger.error("Failed to restart backend service", LogFileType.BACKEND);
|
|
2930
|
-
}
|
|
2931
|
-
return success;
|
|
3103
|
+
})().finally(() => {
|
|
3104
|
+
this.restartPromise = null;
|
|
3105
|
+
});
|
|
3106
|
+
return this.restartPromise;
|
|
2932
3107
|
}
|
|
2933
3108
|
}
|
|
2934
3109
|
const linuxInstallScriptRaw = '#!/bin/bash\n# Local Operator Backend Installation Script for Linux\n# This script sets up a virtual environment for the Local Operator backend\n# without requiring sudo or admin privileges.\n\n# Exit on error, undefined variables, and failed pipe commands\nset -e # Exit immediately if a command exits with a non-zero status\nset -u # Error on unset variables\nset -o pipefail # Fail if any command in a pipe fails\n\n# Configuration\n# Variables APP_NAME and MIN_PYTHON_VERSION removed as they were unused\nVENV_NAME="local-operator-venv"\nAPP_DATA_DIR="${HOME}/.config/local-operator"\nVENV_PATH="${APP_DATA_DIR}/${VENV_NAME}"\nLOG_FILE="${APP_DATA_DIR}/backend-install.log"\nTIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")\n\n# Keep CPython\'s bytecode cache out of the application directory.\n#\n# Same reason as the macOS script: an interpreter writing __pycache__/*.pyc\n# beside its own stdlib sources writes into the installed tree. Linux does not\n# code-seal the app the way macOS does, so this is uniformity and hygiene here\n# rather than the load-bearing fix it is on macOS - but it is also the value a\n# standalone run of this script has to agree with, because the app and the\n# script must not disagree about where bytecode goes.\n: "${PYTHONPYCACHEPREFIX:=${APP_DATA_DIR}/python-bytecode-cache}"\nexport PYTHONPYCACHEPREFIX\n\n# Create app data directory if it doesn\'t exist\nif ! mkdir -p "${APP_DATA_DIR}"; then\n echo "ERROR: Unable to create app data directory at ${APP_DATA_DIR}"\n echo "Please check permissions and try again."\n exit 1\nfi\n\n# Handle cleanup on script exit\ncleanup() {\n # Remove any temporary files created during execution\n if [ -f "${APP_DATA_DIR}/get-pip.py" ]; then\n rm -f "${APP_DATA_DIR}/get-pip.py"\n fi\n if [ -n "${FFMPEG_TEMP_ARCHIVE:-}" ] && [ -f "${FFMPEG_TEMP_ARCHIVE}" ]; then\n log "Removing temporary FFmpeg archive: ${FFMPEG_TEMP_ARCHIVE}"\n rm -f "${FFMPEG_TEMP_ARCHIVE}"\n fi\n echo "$(date): Cleanup completed."\n}\n\n# Set trap only for manual interruption (SIGINT, SIGTERM), not normal exits\ntrap cleanup SIGINT SIGTERM\n\n\n# Start logging\nexec > >(tee -a "${LOG_FILE}") 2>&1\necho "[${TIMESTAMP}]: Starting Local Operator backend installation..."\necho "[${TIMESTAMP}]: System information: $(uname -a)"\n\n# Function to log messages with timestamp (defined later, but used by FFmpeg section)\n_log_internal() {\n local internal_timestamp\n internal_timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${internal_timestamp}] $1"\n}\n\n# --- FFmpeg Installation ---\nBIN_DIR="${APP_DATA_DIR}/bin"\nFFMPEG_BIN="${BIN_DIR}/ffmpeg" # Path to ffmpeg binary\n\n_log_internal "Ensuring bin directory exists: ${BIN_DIR}"\nif ! mkdir -p "${BIN_DIR}"; then\n _log_internal "ERROR: Unable to create bin directory at ${BIN_DIR}"\n exit 1\nfi\n\n# Check if FFmpeg is already installed and executable\nif [ -f "${FFMPEG_BIN}" ] && [ -x "${FFMPEG_BIN}" ]; then\n _log_internal "FFmpeg already installed at ${FFMPEG_BIN}. Skipping download."\nelse\n _log_internal "FFmpeg not found or not executable. Attempting to download and install FFmpeg..."\n\n FFMPEG_DOWNLOAD_URL=""\n\n LINUX_ARCH=$(uname -m)\n if [[ "${LINUX_ARCH}" == "x86_64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-x64"\n elif [[ "${LINUX_ARCH}" == "aarch64" ]] || [[ "${LINUX_ARCH}" == "arm64" ]]; then\n FFMPEG_DOWNLOAD_URL="https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0/ffmpeg-linux-arm64"\n else\n _log_internal "Error: Unsupported CPU architecture for FFmpeg download: ${LINUX_ARCH}"\n exit 1\n fi\n\n _log_internal "Downloading FFmpeg from: ${FFMPEG_DOWNLOAD_URL}"\n if command -v curl >/dev/null 2>&1; then\n if ! curl -L "${FFMPEG_DOWNLOAD_URL}" -o "${FFMPEG_BIN}"; then\n _log_internal "Error: Failed to download FFmpeg using curl from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n elif command -v wget >/dev/null 2>&1; then\n if ! wget -O "${FFMPEG_BIN}" "${FFMPEG_DOWNLOAD_URL}"; then\n _log_internal "Error: Failed to download FFmpeg using wget from ${FFMPEG_DOWNLOAD_URL}"\n exit 1\n fi\n else\n _log_internal "Error: Neither curl nor wget found. Cannot download FFmpeg."\n exit 1\n fi\n _log_internal "FFmpeg binary downloaded successfully to ${FFMPEG_BIN}"\n\n chmod +x "${FFMPEG_BIN}"\n _log_internal "Set executable permissions for ${FFMPEG_BIN}"\n\n # Verify FFmpeg is executable after download\n if [ ! -f "${FFMPEG_BIN}" ] || [ ! -x "${FFMPEG_BIN}" ]; then\n _log_internal "Error: FFmpeg binary not found or not executable after download."\n exit 1\n fi\nfi\n\n_log_internal "FFmpeg installation complete. FFmpeg binary is at: ${FFMPEG_BIN}"\n\n# Function to check if a command exists\ncommand_exists() {\n command -v "$1" >/dev/null 2>&1\n}\n\n# Function to log messages with timestamp\nlog() {\n local timestamp\n timestamp=$(date +"%Y-%m-%d %H:%M:%S")\n echo "[${timestamp}] $1"\n}\n\n# Function to display error messages and exit\nerror_exit() {\n log "ERROR: $1" >&2\n exit "${2:-1}"\n}\n\n# Function to check if Python binary is valid for current architecture\nis_valid_python_binary() {\n local python_path="$1"\n \n # First check if file exists and is executable\n if [ ! -f "${python_path}" ] || [ ! -x "${python_path}" ]; then\n return 1\n fi\n \n # Try to run a simple Python command to verify it works on current architecture\n "${python_path}" -c "print(\'Testing Python executable\')" >/dev/null 2>&1\n return $?\n}\n\n# Check for network connectivity to key servers\ncheck_connectivity() {\n log "Checking network connectivity..."\n local servers=("pypi.org" "bootstrap.pypa.io")\n local has_connectivity=false\n \n for server in "${servers[@]}"; do\n if command_exists curl; then\n if curl --connect-timeout 5 -s "https://${server}" -o /dev/null; then\n has_connectivity=true\n break\n fi\n elif command_exists wget; then\n if wget --timeout=5 -q --spider "https://${server}"; then\n has_connectivity=true\n break\n fi\n fi\n done\n \n if [ "$has_connectivity" = false ]; then\n log "WARNING: Network connectivity issues detected. This may affect installation."\n else\n log "Network connectivity confirmed."\n fi\n}\n\n# Call connectivity check\ncheck_connectivity\n\n# Check if PYTHON_BIN is already set by the installer\nif [ -n "${PYTHON_BIN:-}" ]; then\n log "Using Python executable provided by installer: ${PYTHON_BIN}"\n # Verify the provided Python binary works on this architecture\n if ! is_valid_python_binary "${PYTHON_BIN}"; then\n log "Warning: The provided Python binary is not compatible with this system architecture."\n log "Will attempt to find a system Python installation instead."\n PYTHON_BIN=""\n fi\nfi\n\n# If PYTHON_BIN is empty or invalid, try to find a suitable Python installation\nif [ -z "${PYTHON_BIN:-}" ] || ! is_valid_python_binary "${PYTHON_BIN:-}"; then\n # Try to find a suitable Python installation\n log "Looking for a suitable Python installation..."\n \n # Try multiple possible locations to find Python\n POSSIBLE_PYTHON_PATHS=(\n # System paths first (more likely to be compatible with current architecture)\n "/usr/local/bin/python3.12"\n "/usr/bin/python3.12"\n "/usr/local/bin/python3"\n "/usr/bin/python3"\n # From environment variable (set by the installer)\n "${ELECTRON_RESOURCE_PATH:-}/python/bin/python3"\n # Development paths\n "$(dirname "$0")/../../../resources/python/bin/python3"\n "$(pwd)/resources/python/bin/python3"\n )\n\n # Find the first Python that exists and is version 3.12+ and works on this architecture\n PYTHON_BIN=""\n for path in "${POSSIBLE_PYTHON_PATHS[@]}"; do\n # Skip empty paths that might come from unset environment variables\n if [ -z "${path}" ]; then\n continue\n fi\n \n if is_valid_python_binary "${path}"; then\n # Check Python version\n if PY_VERSION=$("${path}" -c "import sys; print(f\'{sys.version_info.major}.{sys.version_info.minor}\')" 2>/dev/null); then\n MAJOR=$(echo "${PY_VERSION}" | cut -d. -f1)\n MINOR=$(echo "${PY_VERSION}" | cut -d. -f2)\n if [ "${MAJOR}" -eq 3 ] && [ "${MINOR}" -ge 12 ]; then\n PYTHON_BIN="${path}"\n log "Found suitable Python ${PY_VERSION} at ${path}"\n break\n else\n log "Python at ${path} is version ${PY_VERSION}, which is below the required 3.12+"\n fi\n fi\n else\n if [ -f "${path}" ]; then\n log "Python at ${path} exists but is not compatible with this system architecture"\n fi\n fi\n done\n\n # If we couldn\'t find a suitable Python, exit with error\n if [ -z "${PYTHON_BIN:-}" ]; then\n error_exit "No suitable Python installation found (version 3.12 or higher required).\\nPlease install Python 3.12 or higher before running this script.\\nYou can install Python from https://www.python.org/downloads/"\n fi\nfi\n\necho "Using Python: $PYTHON_BIN"\n"$PYTHON_BIN" --version || {\n echo "Error: Failed to run Python. Please ensure it is executable and accessible."\n echo "System architecture: $(uname -m)"\n echo "Python binary architecture: $(file "$PYTHON_BIN" 2>/dev/null || echo \'Unable to determine\')"\n exit 1\n}\n\n# Detect the Linux distribution to provide specific instructions\nif command_exists lsb_release; then\n DISTRO=$(lsb_release -is)\n DISTRO_VERSION=$(lsb_release -rs)\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelif [ -f /etc/os-release ]; then\n DISTRO=$(grep -oP \'(?<=^ID=).+\' /etc/os-release | tr -d \'"\')\n DISTRO_VERSION=$(grep -oP \'(?<=^VERSION_ID=).+\' /etc/os-release | tr -d \'"\')\n echo "Detected Linux distribution: $DISTRO $DISTRO_VERSION"\nelse\n DISTRO="unknown"\n echo "Unable to detect Linux distribution"\nfi\n\n# Check if Python has venv module and ensurepip available\nlog "Checking for venv module and ensurepip availability..."\nVENV_AVAILABLE=0\nENSUREPIP_AVAILABLE=0\n\n"${PYTHON_BIN}" -c "import venv" > /dev/null 2>&1 || VENV_AVAILABLE=1\n"${PYTHON_BIN}" -c "import ensurepip" > /dev/null 2>&1 || ENSUREPIP_AVAILABLE=1\n\n# If venv is still not available, try to use virtualenv as a fallback\nif [ $VENV_AVAILABLE -ne 0 ]; then\n echo "venv module is not available. Checking for virtualenv as an alternative..."\n \n # Check if virtualenv is already installed\n if "$PYTHON_BIN" -c "import virtualenv" > /dev/null 2>&1; then\n echo "virtualenv is available, will use it instead of venv"\n else\n echo "ERROR: Neither venv nor virtualenv is available."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n exit 1\n fi\nfi\n\n# Create virtual environment if it doesn\'t exist\nif [ ! -d "$VENV_PATH" ]; then\n echo "Creating virtual environment at $VENV_PATH..."\n # Remove any potentially corrupted virtual environment\n if [ -e "$VENV_PATH" ]; then\n echo "Removing existing but potentially corrupted venv directory..."\n rm -rf "$VENV_PATH"\n fi\n \n # Make sure parent directory exists and is writable\n mkdir -p "$(dirname "$VENV_PATH")"\n \n # Try different methods to create a virtual environment\n VENV_CREATE_STATUS=1\n \n # First try with venv if available\n if [ $VENV_AVAILABLE -eq 0 ] && [ $ENSUREPIP_AVAILABLE -eq 0 ]; then\n echo "Creating virtual environment using venv module..."\n "$PYTHON_BIN" -m venv "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n fi\n \n # If venv failed, try virtualenv\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n echo "venv creation failed with status $VENV_CREATE_STATUS. Falling back to virtualenv..."\n if command_exists virtualenv; then\n virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n elif "$PYTHON_BIN" -m pip list | grep -q virtualenv; then\n "$PYTHON_BIN" -m virtualenv -p "$PYTHON_BIN" "$VENV_PATH"\n VENV_CREATE_STATUS=$?\n else\n echo "ERROR: Both venv and virtualenv are unavailable or failed."\n echo "Python 3.12 and venv should be installed as package dependencies."\n echo "Please ensure Python 3.12 and venv are properly installed on your system."\n echo "You can install Python 3.12 from https://www.python.org/downloads/"\n exit 1\n fi\n fi\n \n # Check if virtual environment creation was successful\n if [ $VENV_CREATE_STATUS -ne 0 ]; then\n log "ERROR: Failed to create virtual environment. Exit code: $VENV_CREATE_STATUS"\n log "Virtual environment path: $VENV_PATH"\n log "Python binary used: $PYTHON_BIN"\n ls -la "$(dirname "$VENV_PATH")" || true\n log "Python executable permissions:"\n ls -la "$PYTHON_BIN" || true\n \n # Check for common issues\n log "Checking for common virtual environment creation issues..."\n \n # Check disk space\n df -h "$(dirname "$VENV_PATH")" || true\n \n # Check if directory is writable\n if [ ! -w "$(dirname "$VENV_PATH")" ]; then\n log "ERROR: Directory $(dirname "$VENV_PATH") is not writable"\n fi\n \n # Try to create a minimal virtual environment manually as a last resort\n log "Attempting to create a minimal virtual environment manually..."\n mkdir -p "$VENV_PATH/bin" || error_exit "Could not create directory $VENV_PATH/bin"\n echo "#!/bin/bash" > "$VENV_PATH/bin/activate"\n echo "export VIRTUAL_ENV=\\"$VENV_PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "export PATH=\\"$VENV_PATH/bin:\\$PATH\\"" >> "$VENV_PATH/bin/activate"\n echo "unset PYTHONHOME" >> "$VENV_PATH/bin/activate"\n chmod +x "$VENV_PATH/bin/activate" || error_exit "Could not set execute permissions on activate script"\n \n # Create symlinks to the system Python\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python" || log "WARNING: Failed to create symlink for python"\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python3" || log "WARNING: Failed to create symlink for python3"\n \n # Try to bootstrap pip\n log "Bootstrapping pip in the minimal virtual environment..."\n if command_exists curl; then\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py" || log "WARNING: Failed to download get-pip.py"\n elif command_exists wget; then\n wget -q -O "$APP_DATA_DIR/get-pip.py" https://bootstrap.pypa.io/get-pip.py || log "WARNING: Failed to download get-pip.py"\n else\n log "ERROR: Neither curl nor wget available to download get-pip.py"\n error_exit "Installation cannot continue without being able to download pip"\n fi\n \n # shellcheck disable=SC1090\n source "$VENV_PATH/bin/activate" && python "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n error_exit "Failed to create even a minimal virtual environment."\n else\n log "Created a minimal virtual environment as a fallback."\n fi\n else\n log "Successfully created virtual environment"\n fi\nfi\n\n# Verify the virtual environment structure\necho "Verifying virtual environment structure..."\nif [ ! -f "$VENV_PATH/bin/python" ]; then\n echo "Python executable missing in virtual environment, creating symlink..."\n ln -sf "$PYTHON_BIN" "$VENV_PATH/bin/python"\nfi\n\nif [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "pip missing in virtual environment, attempting to bootstrap it..."\n curl -s https://bootstrap.pypa.io/get-pip.py -o "$APP_DATA_DIR/get-pip.py"\n "$VENV_PATH/bin/python" "$APP_DATA_DIR/get-pip.py" --no-warn-script-location\n \n if [ ! -f "$VENV_PATH/bin/pip" ]; then\n echo "ERROR: Failed to bootstrap pip in the virtual environment"\n exit 1\n fi\nfi\n\necho "Virtual environment structure verified"\n\n# Activate virtual environment and install local-operator\necho "Installing local-operator in virtual environment..."\nsource "$VENV_PATH/bin/activate"\n\necho "Upgrading pip..."\npython -m pip install --upgrade pip || {\n echo "WARNING: Failed to upgrade pip. Will try to continue with existing pip version."\n pip --version\n}\n\n# Check network connectivity to PyPI\necho "Checking network connectivity to PyPI..."\nif command_exists curl; then\n curl -s https://pypi.org/pypi/local-operator/json -o /dev/null || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nelif command_exists wget; then\n wget -q --spider https://pypi.org/pypi/local-operator/json || {\n echo "WARNING: Could not reach PyPI. Network connectivity issues might prevent installation."\n }\nfi\n\necho "Installing local-operator package..."\npython -m pip install --verbose local-operator || {\n echo "ERROR: Failed to install local-operator package. Exit code: $?"\n echo "Python version:"\n python --version\n echo "pip version:"\n pip --version\n echo "Available pip packages:"\n pip list\n exit 1\n}\necho "local-operator installation successful"\n\n# Verify installation\nif [ -f "$VENV_PATH/bin/local-operator" ]; then\n log "Local Operator backend installed successfully!"\n \n # Show more information about the installed binary\n ls -la "$VENV_PATH/bin/local-operator" || true\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n \n # Try to run the version command with full error output\n log "Testing local-operator binary..."\n "$VENV_PATH/bin/local-operator" --version || {\n log "ERROR: local-operator binary exists but failed to execute. Exit code: $?"\n log "Binary details:"\n file "$VENV_PATH/bin/local-operator" 2>/dev/null || log "Note: \'file\' command not available"\n log "Binary permissions:"\n ls -la "$VENV_PATH/bin/local-operator" || true\n log ""\n log "This could be due to missing dependencies or architecture incompatibility."\n log "Try running the binary manually to see specific errors."\n \n # Don\'t exit here - just warn the user\n log "WARNING: Installation completed but binary verification failed."\n }\n \n # Create a summary of the installation\n SUMMARY_FILE="${APP_DATA_DIR}/installation-summary-${TIMESTAMP}.txt"\n {\n echo "=== Local Operator Backend Installation Summary ==="\n echo "Timestamp: $(date)"\n echo "Python Version: $("${PYTHON_BIN}" --version 2>&1)"\n echo "Virtual Environment: ${VENV_PATH}"\n echo "Binary Location: ${VENV_PATH}/bin/local-operator"\n echo "To activate the virtual environment, run: source ${VENV_PATH}/bin/activate"\n echo "To test the installation, run: ${VENV_PATH}/bin/local-operator --version"\n echo "Log file: ${LOG_FILE}"\n echo "=================================================="\n } > "${SUMMARY_FILE}"\n \n log "Installation summary saved to: ${SUMMARY_FILE}"\nelse\n log "Error: Failed to install Local Operator backend. Binary not found."\n log "Contents of bin directory:"\n ls -la "$VENV_PATH/bin/" || true\n log "Check the log file for details: ${LOG_FILE}"\n error_exit "Installation failed. Binary not installed properly."\nfi\n\nlog "Installation completed successfully at $(date)"\n\n# Clean up temporary files now that installation is complete\ncleanup\n\n# Print final instructions\nlog ""\nlog "=== INSTALLATION COMPLETE ==="\nlog "You can now use the Local Operator backend by running: ${VENV_PATH}/bin/local-operator"\nlog "Installation directory: ${VENV_PATH}"\nlog "Log file: ${LOG_FILE}"\nlog "================================"\n';
|
|
@@ -9379,8 +9554,8 @@ function readInstallIdentity(shimPath) {
|
|
|
9379
9554
|
}
|
|
9380
9555
|
let shebang = null;
|
|
9381
9556
|
try {
|
|
9382
|
-
const
|
|
9383
|
-
shebang =
|
|
9557
|
+
const firstLine2 = fs.readFileSync(shimPath, "utf8").split("\n", 1)[0] ?? "";
|
|
9558
|
+
shebang = firstLine2.startsWith("#!") ? firstLine2 : null;
|
|
9384
9559
|
} catch {
|
|
9385
9560
|
shebang = null;
|
|
9386
9561
|
}
|
|
@@ -10491,86 +10666,6 @@ ${heal.removed.join("\n")}`,
|
|
|
10491
10666
|
electron.ipcMain.removeHandler("quit-for-update-install");
|
|
10492
10667
|
this.mainWindow = null;
|
|
10493
10668
|
}
|
|
10494
|
-
/**
|
|
10495
|
-
* Register the backend service for proper shutdown when app quits
|
|
10496
|
-
* This ensures that restarted backend services are properly shut down
|
|
10497
|
-
*/
|
|
10498
|
-
registerBackendShutdown() {
|
|
10499
|
-
if (!this.backendService || this.backendService.isUsingExternalBackend()) {
|
|
10500
|
-
return;
|
|
10501
|
-
}
|
|
10502
|
-
const shutdownHandler = async () => {
|
|
10503
|
-
logger.info(
|
|
10504
|
-
"Shutting down backend service before app quit...",
|
|
10505
|
-
LogFileType.UPDATE_SERVICE
|
|
10506
|
-
);
|
|
10507
|
-
try {
|
|
10508
|
-
await this.backendService?.stop(false);
|
|
10509
|
-
logger.info(
|
|
10510
|
-
"Backend service successfully shut down before app quit",
|
|
10511
|
-
LogFileType.UPDATE_SERVICE
|
|
10512
|
-
);
|
|
10513
|
-
} catch (error) {
|
|
10514
|
-
logger.error(
|
|
10515
|
-
"Error shutting down backend service before app quit:",
|
|
10516
|
-
LogFileType.UPDATE_SERVICE,
|
|
10517
|
-
error
|
|
10518
|
-
);
|
|
10519
|
-
try {
|
|
10520
|
-
logger.info(
|
|
10521
|
-
"Attempting forced shutdown of backend service...",
|
|
10522
|
-
LogFileType.UPDATE_SERVICE
|
|
10523
|
-
);
|
|
10524
|
-
await this.forceTerminateBackendProcess();
|
|
10525
|
-
logger.info(
|
|
10526
|
-
"Forced shutdown of backend service completed",
|
|
10527
|
-
LogFileType.UPDATE_SERVICE
|
|
10528
|
-
);
|
|
10529
|
-
} catch (forceError) {
|
|
10530
|
-
logger.error(
|
|
10531
|
-
"Error during forced shutdown of backend service:",
|
|
10532
|
-
LogFileType.UPDATE_SERVICE,
|
|
10533
|
-
forceError
|
|
10534
|
-
);
|
|
10535
|
-
}
|
|
10536
|
-
}
|
|
10537
|
-
};
|
|
10538
|
-
electron.app.removeAllListeners("before-quit");
|
|
10539
|
-
electron.app.once("before-quit", shutdownHandler);
|
|
10540
|
-
electron.app.once("will-quit", shutdownHandler);
|
|
10541
|
-
logger.info(
|
|
10542
|
-
"Registered backend service for proper shutdown on app quit",
|
|
10543
|
-
LogFileType.UPDATE_SERVICE
|
|
10544
|
-
);
|
|
10545
|
-
}
|
|
10546
|
-
/**
|
|
10547
|
-
* Force terminate the backend process using platform-specific commands
|
|
10548
|
-
* This is a last resort method when normal termination fails
|
|
10549
|
-
*/
|
|
10550
|
-
async forceTerminateBackendProcess() {
|
|
10551
|
-
if (!this.backendService) {
|
|
10552
|
-
return;
|
|
10553
|
-
}
|
|
10554
|
-
const execAsync = node_util.promisify(node_child_process.exec);
|
|
10555
|
-
try {
|
|
10556
|
-
if (process.platform === "win32") {
|
|
10557
|
-
await execAsync('taskkill /f /im "local-operator serve" /t');
|
|
10558
|
-
await execAsync(
|
|
10559
|
-
`wmic process where "commandline like '%local-operator serve%'" call terminate`
|
|
10560
|
-
);
|
|
10561
|
-
} else {
|
|
10562
|
-
await execAsync('pkill -f "local-operator serve"');
|
|
10563
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
10564
|
-
await execAsync('pkill -9 -f "local-operator serve"');
|
|
10565
|
-
}
|
|
10566
|
-
} catch (error) {
|
|
10567
|
-
logger.warn(
|
|
10568
|
-
"Error during force termination (this may be normal if process was already terminated):",
|
|
10569
|
-
LogFileType.UPDATE_SERVICE,
|
|
10570
|
-
error
|
|
10571
|
-
);
|
|
10572
|
-
}
|
|
10573
|
-
}
|
|
10574
10669
|
/**
|
|
10575
10670
|
* Set up event handlers for the autoUpdater
|
|
10576
10671
|
*/
|
|
@@ -10818,6 +10913,15 @@ ${heal.removed.join("\n")}`,
|
|
|
10818
10913
|
let block = null;
|
|
10819
10914
|
try {
|
|
10820
10915
|
block = await this.runInstallPreflight(this.lastUpdateInfo);
|
|
10916
|
+
if (!block) await this.backendService?.stop(false);
|
|
10917
|
+
} catch (error) {
|
|
10918
|
+
this.updateStage = "idle";
|
|
10919
|
+
logger.error(
|
|
10920
|
+
"Update handoff refused: owned backend cleanup failed",
|
|
10921
|
+
LogFileType.UPDATE_SERVICE,
|
|
10922
|
+
error
|
|
10923
|
+
);
|
|
10924
|
+
return false;
|
|
10821
10925
|
} finally {
|
|
10822
10926
|
this.installPreflightInFlight = false;
|
|
10823
10927
|
}
|
|
@@ -11649,16 +11753,6 @@ ${pipRun.stderr.trim()}`,
|
|
|
11649
11753
|
"Restarting backend service after update...",
|
|
11650
11754
|
LogFileType.UPDATE_SERVICE
|
|
11651
11755
|
);
|
|
11652
|
-
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
11653
|
-
const isHealthy = await this.checkBackendHealth();
|
|
11654
|
-
if (isHealthy) {
|
|
11655
|
-
logger.warn(
|
|
11656
|
-
"Backend service is still running after stop command, attempting force termination",
|
|
11657
|
-
LogFileType.UPDATE_SERVICE
|
|
11658
|
-
);
|
|
11659
|
-
await this.forceTerminateBackendProcess();
|
|
11660
|
-
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
11661
|
-
}
|
|
11662
11756
|
const restartSuccess = await this.backendService.restart();
|
|
11663
11757
|
if (restartSuccess) {
|
|
11664
11758
|
logger.info(
|
|
@@ -11692,7 +11786,6 @@ ${pipRun.stderr.trim()}`,
|
|
|
11692
11786
|
return false;
|
|
11693
11787
|
}
|
|
11694
11788
|
}
|
|
11695
|
-
this.registerBackendShutdown();
|
|
11696
11789
|
} else {
|
|
11697
11790
|
logger.error(
|
|
11698
11791
|
"Failed to restart backend service after update",
|
|
@@ -12280,6 +12373,16 @@ function createWindow() {
|
|
|
12280
12373
|
}
|
|
12281
12374
|
const backendService = new BackendServiceManager();
|
|
12282
12375
|
const backendInstaller = new BackendInstaller();
|
|
12376
|
+
const reportBackendFailure = (message, fileType) => {
|
|
12377
|
+
if (backendService.isShuttingDown()) {
|
|
12378
|
+
logger.error(
|
|
12379
|
+
`Backend Error (not shown; the app is shutting down): ${message}`,
|
|
12380
|
+
fileType
|
|
12381
|
+
);
|
|
12382
|
+
return;
|
|
12383
|
+
}
|
|
12384
|
+
electron.dialog.showErrorBox("Backend Error", message);
|
|
12385
|
+
};
|
|
12283
12386
|
const windowLaunch = resolveWindowLaunchPlan({
|
|
12284
12387
|
env: process.env,
|
|
12285
12388
|
argv: process.argv
|
|
@@ -12644,9 +12747,9 @@ electron.app.whenReady().then(async () => {
|
|
|
12644
12747
|
"Failed to start backend after installation, quitting app",
|
|
12645
12748
|
LogFileType.INSTALLER
|
|
12646
12749
|
);
|
|
12647
|
-
|
|
12648
|
-
"
|
|
12649
|
-
|
|
12750
|
+
reportBackendFailure(
|
|
12751
|
+
"Failed to start the Local Operator backend service after installation. Please restart the application.",
|
|
12752
|
+
LogFileType.INSTALLER
|
|
12650
12753
|
);
|
|
12651
12754
|
electron.app.quit();
|
|
12652
12755
|
return;
|
|
@@ -12658,9 +12761,9 @@ electron.app.whenReady().then(async () => {
|
|
|
12658
12761
|
"Failed to start backend with existing installation, quitting app",
|
|
12659
12762
|
LogFileType.BACKEND
|
|
12660
12763
|
);
|
|
12661
|
-
|
|
12662
|
-
"
|
|
12663
|
-
|
|
12764
|
+
reportBackendFailure(
|
|
12765
|
+
"Failed to start the Local Operator backend service. Please restart the application.",
|
|
12766
|
+
LogFileType.BACKEND
|
|
12664
12767
|
);
|
|
12665
12768
|
electron.app.quit();
|
|
12666
12769
|
return;
|
|
@@ -12784,161 +12887,34 @@ electron.app.on("window-all-closed", () => {
|
|
|
12784
12887
|
);
|
|
12785
12888
|
electron.app.quit();
|
|
12786
12889
|
});
|
|
12787
|
-
|
|
12788
|
-
|
|
12789
|
-
|
|
12790
|
-
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
12795
|
-
|
|
12796
|
-
|
|
12797
|
-
|
|
12798
|
-
"Local Operator backend service successfully stopped",
|
|
12799
|
-
LogFileType.BACKEND
|
|
12800
|
-
);
|
|
12801
|
-
logger.info(
|
|
12802
|
-
"Performing additional cleanup to ensure complete termination",
|
|
12803
|
-
LogFileType.BACKEND
|
|
12804
|
-
);
|
|
12805
|
-
if (process.platform === "win32") {
|
|
12806
|
-
try {
|
|
12807
|
-
require("node:child_process").execSync(
|
|
12808
|
-
'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t',
|
|
12809
|
-
{ stdio: "ignore" }
|
|
12810
|
-
);
|
|
12811
|
-
require("node:child_process").execSync(
|
|
12812
|
-
"taskkill /f /im local-operator.exe /t",
|
|
12813
|
-
{ stdio: "ignore" }
|
|
12814
|
-
);
|
|
12815
|
-
} catch (err) {
|
|
12816
|
-
logger.error(
|
|
12817
|
-
"Error during Windows process cleanup (may be normal):",
|
|
12818
|
-
LogFileType.BACKEND,
|
|
12819
|
-
err
|
|
12820
|
-
);
|
|
12821
|
-
}
|
|
12822
|
-
} else {
|
|
12823
|
-
try {
|
|
12824
|
-
require("node:child_process").execSync(
|
|
12825
|
-
'pkill -f "local-operator serve"',
|
|
12826
|
-
{ stdio: "ignore" }
|
|
12827
|
-
);
|
|
12828
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
12829
|
-
require("node:child_process").execSync(
|
|
12830
|
-
'pkill -9 -f "local-operator serve"',
|
|
12831
|
-
{ stdio: "ignore" }
|
|
12832
|
-
);
|
|
12833
|
-
} catch (err) {
|
|
12834
|
-
logger.error(
|
|
12835
|
-
"Error during Unix process cleanup (may be normal):",
|
|
12836
|
-
LogFileType.BACKEND,
|
|
12837
|
-
err
|
|
12838
|
-
);
|
|
12839
|
-
}
|
|
12840
|
-
}
|
|
12841
|
-
let allProcessesTerminated = true;
|
|
12842
|
-
try {
|
|
12843
|
-
if (process.platform === "win32") {
|
|
12844
|
-
const { stdout: pythonOutput } = require("node:child_process").execSync(
|
|
12845
|
-
`tasklist /fi "imagename eq python.exe" /fo csv`,
|
|
12846
|
-
{ encoding: "utf8" }
|
|
12847
|
-
);
|
|
12848
|
-
const hasPythonProcesses = pythonOutput?.trim().includes("python.exe");
|
|
12849
|
-
const { stdout: localOperatorOutput } = require("node:child_process").execSync(
|
|
12850
|
-
`tasklist /fi "imagename eq local-operator.exe" /fo csv`,
|
|
12851
|
-
{ encoding: "utf8" }
|
|
12852
|
-
);
|
|
12853
|
-
const hasLocalOperatorProcesses = localOperatorOutput?.trim().includes("local-operator.exe");
|
|
12854
|
-
allProcessesTerminated = !hasPythonProcesses && !hasLocalOperatorProcesses;
|
|
12855
|
-
} else {
|
|
12856
|
-
const { stdout } = require("node:child_process").execSync(
|
|
12857
|
-
`pgrep -f "local-operator serve" || echo ""`,
|
|
12858
|
-
{ encoding: "utf8" }
|
|
12859
|
-
);
|
|
12860
|
-
allProcessesTerminated = !stdout?.trim();
|
|
12861
|
-
}
|
|
12862
|
-
} catch (err) {
|
|
12863
|
-
logger.error(
|
|
12864
|
-
"Error checking for remaining processes:",
|
|
12865
|
-
LogFileType.BACKEND,
|
|
12866
|
-
err
|
|
12867
|
-
);
|
|
12868
|
-
allProcessesTerminated = true;
|
|
12869
|
-
}
|
|
12870
|
-
if (!allProcessesTerminated) {
|
|
12871
|
-
logger.error(
|
|
12872
|
-
"Some backend processes may still be running, attempting final cleanup",
|
|
12873
|
-
LogFileType.BACKEND
|
|
12874
|
-
);
|
|
12875
|
-
try {
|
|
12876
|
-
if (process.platform === "win32") {
|
|
12877
|
-
require("node:child_process").execSync(
|
|
12878
|
-
"taskkill /f /im python.exe /t",
|
|
12879
|
-
{ stdio: "ignore" }
|
|
12880
|
-
);
|
|
12881
|
-
} else {
|
|
12882
|
-
require("node:child_process").execSync("pkill -9 -f python", {
|
|
12883
|
-
stdio: "ignore"
|
|
12884
|
-
});
|
|
12885
|
-
}
|
|
12886
|
-
} catch (finalErr) {
|
|
12887
|
-
logger.error(
|
|
12888
|
-
"Error during final cleanup (may be normal):",
|
|
12889
|
-
LogFileType.BACKEND,
|
|
12890
|
-
finalErr
|
|
12891
|
-
);
|
|
12892
|
-
}
|
|
12893
|
-
}
|
|
12894
|
-
} catch (error) {
|
|
12895
|
-
logger.error(
|
|
12896
|
-
"Error stopping the Local Operator backend service:",
|
|
12897
|
-
LogFileType.BACKEND,
|
|
12898
|
-
error
|
|
12899
|
-
);
|
|
12900
|
-
logger.info(
|
|
12901
|
-
"Trying alternative termination approach",
|
|
12902
|
-
LogFileType.BACKEND
|
|
12903
|
-
);
|
|
12904
|
-
if (process.platform === "win32") {
|
|
12905
|
-
try {
|
|
12906
|
-
require("node:child_process").execSync(
|
|
12907
|
-
'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t',
|
|
12908
|
-
{ stdio: "ignore" }
|
|
12909
|
-
);
|
|
12910
|
-
require("node:child_process").execSync(
|
|
12911
|
-
"taskkill /f /im local-operator.exe /t",
|
|
12912
|
-
{ stdio: "ignore" }
|
|
12913
|
-
);
|
|
12914
|
-
} catch (_err) {
|
|
12915
|
-
}
|
|
12916
|
-
} else {
|
|
12917
|
-
try {
|
|
12918
|
-
require("node:child_process").execSync(
|
|
12919
|
-
'pkill -f "local-operator serve"',
|
|
12920
|
-
{ stdio: "ignore" }
|
|
12921
|
-
);
|
|
12922
|
-
require("node:child_process").execSync(
|
|
12923
|
-
'sleep 1 && pkill -9 -f "local-operator serve"',
|
|
12924
|
-
{ stdio: "ignore" }
|
|
12925
|
-
);
|
|
12926
|
-
} catch (_err) {
|
|
12927
|
-
}
|
|
12928
|
-
}
|
|
12929
|
-
} finally {
|
|
12930
|
-
logger.info("Exiting application...", LogFileType.BACKEND);
|
|
12931
|
-
setTimeout(() => {
|
|
12932
|
-
logger.info("Forcing app exit", LogFileType.BACKEND);
|
|
12933
|
-
electron.app.exit(0);
|
|
12934
|
-
}, 2e3);
|
|
12935
|
-
}
|
|
12936
|
-
} else if (!isBackendManagerDisabled && backendService.isUsingExternalBackend()) {
|
|
12937
|
-
logger.info(
|
|
12938
|
-
"Using external backend, skipping termination on app quit",
|
|
12890
|
+
const QUIT_FAILSAFE_MARGIN_MS = 5e3;
|
|
12891
|
+
const QUIT_CLEANUP_FAILSAFE_MS = CONSOLE_RESOLUTION_WORST_MS + INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
|
|
12892
|
+
let backendQuitPending = false;
|
|
12893
|
+
electron.app.on("will-quit", (event) => {
|
|
12894
|
+
if (backendService.isOwnedCleanupComplete()) return;
|
|
12895
|
+
event.preventDefault();
|
|
12896
|
+
if (backendQuitPending) return;
|
|
12897
|
+
backendQuitPending = true;
|
|
12898
|
+
const failsafe = setTimeout(() => {
|
|
12899
|
+
logger.error(
|
|
12900
|
+
`Owned backend cleanup did not finish within ${QUIT_CLEANUP_FAILSAFE_MS} ms; exiting with failure`,
|
|
12939
12901
|
LogFileType.BACKEND
|
|
12940
12902
|
);
|
|
12941
|
-
|
|
12903
|
+
electron.app.exit(1);
|
|
12904
|
+
}, QUIT_CLEANUP_FAILSAFE_MS);
|
|
12905
|
+
failsafe.unref();
|
|
12906
|
+
void backendService.stop(false).then(() => {
|
|
12907
|
+
clearTimeout(failsafe);
|
|
12908
|
+
electron.app.quit();
|
|
12909
|
+
}).catch((error) => {
|
|
12910
|
+
clearTimeout(failsafe);
|
|
12911
|
+
logger.error(
|
|
12912
|
+
"Owned backend cleanup failed; exiting with failure",
|
|
12913
|
+
LogFileType.BACKEND,
|
|
12914
|
+
error
|
|
12915
|
+
);
|
|
12916
|
+
electron.app.exit(1);
|
|
12917
|
+
});
|
|
12942
12918
|
});
|
|
12943
12919
|
electron.app.on("before-quit", () => {
|
|
12944
12920
|
logger.info("App is about to quit", LogFileType.BACKEND);
|
|
@@ -12947,114 +12923,24 @@ electron.app.on("before-quit", () => {
|
|
|
12947
12923
|
void stopBrowserHost();
|
|
12948
12924
|
});
|
|
12949
12925
|
process.on("exit", () => {
|
|
12950
|
-
logger.info(
|
|
12951
|
-
"Process exit event detected, ensuring the Local Operator backend service is terminated",
|
|
12952
|
-
LogFileType.BACKEND
|
|
12953
|
-
);
|
|
12954
12926
|
try {
|
|
12955
|
-
|
|
12956
|
-
logger.info(
|
|
12957
|
-
"Forcing termination of the Local Operator backend service",
|
|
12958
|
-
LogFileType.BACKEND
|
|
12959
|
-
);
|
|
12960
|
-
logger.info(
|
|
12961
|
-
"Performing final cleanup of any remaining backend processes",
|
|
12962
|
-
LogFileType.BACKEND
|
|
12963
|
-
);
|
|
12964
|
-
if (process.platform === "win32") {
|
|
12965
|
-
try {
|
|
12966
|
-
require("node:child_process").spawnSync("cmd.exe", [
|
|
12967
|
-
"/c",
|
|
12968
|
-
'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
|
|
12969
|
-
]);
|
|
12970
|
-
require("node:child_process").spawnSync("cmd.exe", [
|
|
12971
|
-
"/c",
|
|
12972
|
-
"taskkill /f /im local-operator.exe /t"
|
|
12973
|
-
]);
|
|
12974
|
-
} catch (_err) {
|
|
12975
|
-
}
|
|
12976
|
-
} else {
|
|
12977
|
-
try {
|
|
12978
|
-
require("node:child_process").spawnSync("bash", [
|
|
12979
|
-
"-c",
|
|
12980
|
-
`ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -15`
|
|
12981
|
-
]);
|
|
12982
|
-
require("node:child_process").spawnSync("bash", [
|
|
12983
|
-
"-c",
|
|
12984
|
-
`sleep 1 && ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -9`
|
|
12985
|
-
]);
|
|
12986
|
-
} catch (_err) {
|
|
12987
|
-
}
|
|
12988
|
-
}
|
|
12989
|
-
} else if (!process.env.VITE_DISABLE_BACKEND_MANAGER && backendService.isUsingExternalBackend()) {
|
|
12990
|
-
logger.info(
|
|
12991
|
-
"Using external backend, skipping force termination",
|
|
12992
|
-
LogFileType.BACKEND
|
|
12993
|
-
);
|
|
12994
|
-
}
|
|
12927
|
+
backendService.emergencyStopOwned();
|
|
12995
12928
|
} catch (error) {
|
|
12996
|
-
logger.error(
|
|
12929
|
+
logger.error(
|
|
12930
|
+
"Owned backend emergency cleanup failed",
|
|
12931
|
+
LogFileType.BACKEND,
|
|
12932
|
+
error
|
|
12933
|
+
);
|
|
12997
12934
|
}
|
|
12998
12935
|
posthogClient.shutdown();
|
|
12999
12936
|
});
|
|
13000
12937
|
process.on("uncaughtException", (error) => {
|
|
13001
12938
|
logger.error("Uncaught exception", LogFileType.BACKEND, error);
|
|
13002
|
-
|
|
13003
|
-
logger.
|
|
13004
|
-
"
|
|
13005
|
-
LogFileType.BACKEND
|
|
13006
|
-
|
|
13007
|
-
backendService.stop().catch((stopError) => {
|
|
13008
|
-
logger.error(
|
|
13009
|
-
"Error stopping the Local Operator backend service:",
|
|
13010
|
-
LogFileType.BACKEND,
|
|
13011
|
-
stopError
|
|
13012
|
-
);
|
|
13013
|
-
logger.info(
|
|
13014
|
-
"Trying alternative termination approach",
|
|
13015
|
-
LogFileType.BACKEND
|
|
13016
|
-
);
|
|
13017
|
-
if (process.platform === "win32") {
|
|
13018
|
-
try {
|
|
13019
|
-
require("node:child_process").spawnSync("cmd.exe", [
|
|
13020
|
-
"/c",
|
|
13021
|
-
'taskkill /f /im python.exe /fi "WINDOWTITLE eq *local-operator*" /t'
|
|
13022
|
-
]);
|
|
13023
|
-
require("node:child_process").spawnSync("cmd.exe", [
|
|
13024
|
-
"/c",
|
|
13025
|
-
"taskkill /f /im local-operator.exe /t"
|
|
13026
|
-
]);
|
|
13027
|
-
} catch (_err) {
|
|
13028
|
-
}
|
|
13029
|
-
} else {
|
|
13030
|
-
try {
|
|
13031
|
-
require("node:child_process").spawnSync("bash", [
|
|
13032
|
-
"-c",
|
|
13033
|
-
`ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -15`
|
|
13034
|
-
]);
|
|
13035
|
-
require("node:child_process").spawnSync("bash", [
|
|
13036
|
-
"-c",
|
|
13037
|
-
`sleep 1 && ps aux | grep "local-operator serve" | grep -v grep | awk '{print $2}' | xargs -r kill -9`
|
|
13038
|
-
]);
|
|
13039
|
-
} catch (_err) {
|
|
13040
|
-
}
|
|
13041
|
-
}
|
|
13042
|
-
}).finally(() => {
|
|
13043
|
-
setTimeout(() => {
|
|
13044
|
-
logger.info(
|
|
13045
|
-
"Forcing app exit after uncaught exception",
|
|
13046
|
-
LogFileType.BACKEND
|
|
13047
|
-
);
|
|
13048
|
-
process.exit(1);
|
|
13049
|
-
}, 1e3);
|
|
13050
|
-
});
|
|
13051
|
-
} else if (backendService && !process.env.VITE_DISABLE_BACKEND_MANAGER && backendService.isUsingExternalBackend()) {
|
|
13052
|
-
logger.info(
|
|
13053
|
-
"Using external backend, skipping termination on uncaught exception",
|
|
13054
|
-
LogFileType.BACKEND
|
|
12939
|
+
void backendService.stop(false).catch((stopError) => {
|
|
12940
|
+
logger.error(
|
|
12941
|
+
"Owned backend cleanup failed",
|
|
12942
|
+
LogFileType.BACKEND,
|
|
12943
|
+
stopError
|
|
13055
12944
|
);
|
|
13056
|
-
|
|
13057
|
-
} else {
|
|
13058
|
-
process.exit(1);
|
|
13059
|
-
}
|
|
12945
|
+
}).finally(() => process.exit(1));
|
|
13060
12946
|
});
|