From 330ff4592d1bce2098ae4fc2b37b2ee708fdfa5b Mon Sep 17 00:00:00 2001 From: "Steven R. Baker" Date: Thu, 29 Oct 2026 07:55:27 -0700 Subject: [PATCH] Install newer Python support without asking, now that stored data survives it 1.7.0 began asking before filing a newer Grail into a database that already had one, because install.gs recreated every runtime class and committed objects kept the old ones: stored dates, Decimals, tuples, sets, None and user-class instances stopped behaving as their types. That is no longer true. Grail installs with Rowan, keeps its classes, and install-grail.sh rebuilds every deployed module into its committed instance and classes (Grail #1791). Grail's upgrade test checks every stored object against the current class nightly, from 1.9.0's pin c8cbc92 (Grail #1867). The 1.10.0 engine upgrade already reinstalls Grail without a second question. So an update is installed like the MCP server's, from every trigger, and gemdb.reinstallPythonOnUpdate governs it again, default on, as it did before 1.7.0: it was kept in the manifest, deprecated, so a value set then means what it meant. Off, the update waits for Reinstall the Python Execution Engine, which no longer asks either: the command is the request. resumeRunning still files in only a first install, so windows activating together after an update do not race installs. The prompt, the status row's tooltip, the setting's description, the README and docs/automation-boundary.md now say what is measured, and the two cases that still need the user: a class whose bases the update changed while instances are stored (its import is refused with the gemdb.schema.rebase advice), and a class statement inside a function, which is new on every rebuild. Closes #186. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++ README.md | 22 ++-- docs/automation-boundary.md | 97 ++++++++++-------- package.json | 3 +- src/__tests__/databaseStarted.test.ts | 104 ++++++------------- src/__tests__/engineUpgradeStart.test.ts | 2 - src/__tests__/manifest.test.ts | 17 +++- src/__tests__/statusRows.test.ts | 63 +++++++++++- src/config.ts | 10 ++ src/grail.ts | 28 +----- src/lifecycle.ts | 122 ++++++++--------------- src/paths.ts | 7 +- src/statusView.ts | 14 ++- 13 files changed, 260 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 929b3ae..62a8d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,19 @@ wrong. The bundled Python runtime moves forward too. this Python support over the previous one keeps what you have stored working: modules you deployed are rebuilt in place, so stored instances keep their classes, and stored enum members stay members. +- **Newer Python support installs without asking again.** Since 1.7.0, GemDB Code asked before + installing the Python support an update ships into a database that already had it, and warned + that stored dates and times, decimals, tuples and sets, and instances of your own classes would + stop working. That is no longer true: the install keeps them working, and Grail's upgrade test + checks it, from the Python support 1.9.0 installed, every night. So the next time the database + starts or you run Python, GemDB Code installs it, as it already did after the engine upgrade + above. `gemdb.reinstallPythonOnUpdate` is used again: on by default, and turning it off keeps the + current version until you run **GemDB: Reinstall the Python Execution Engine**, which no longer + asks either. A value you set before 1.7.0 means what it meant then. Two cases still need you: a + class whose base classes the update changes, while instances of it are stored, makes importing + its module raise an error that names the `gemdb.schema.rebase(...)` call to run; and instances of + a class defined by a class statement inside a function (other than `collections.namedtuple`) + keep the old class when the module is rebuilt. ## [1.9.0] - 2026-10-20 diff --git a/README.md b/README.md index 0df5551..fa34875 100644 --- a/README.md +++ b/README.md @@ -212,12 +212,17 @@ The first time the database starts, usually right after setup, GemDB Code instal into it. This takes a few minutes and shows its progress in a notification. If the database cannot start yet, this happens the first time you open a shell or run a notebook cell instead. -When a GemDB Code update ships newer Python support, GemDB Code prompts you before installing it into -a database that already has Python support. Data committed with the old version stops working as its -type afterward (dates and times, decimals, tuples and sets, and instances of your classes), so -install it into a database that holds nothing you need. If you keep the current version, GemDB Code -does not prompt you again for that update; **GemDB: Reinstall the Python Execution Engine** installs -it later. +When a GemDB Code update ships newer Python support, GemDB Code installs it into your database the +next time the database starts or you run Python, without prompting you. What you have stored keeps +working: dates and times, decimals, tuples and sets, enum members, and instances of your own classes +keep their types, and modules you deployed into the database are rebuilt in place, so stored objects +keep their classes. Two exceptions remain. If the update changes the base classes of a class you +have stored instances of, importing its module raises an error that names the class and the +`gemdb.schema.rebase(...)` call that moves those instances onto the new class. And a class statement +inside a function (other than `collections.namedtuple`) makes a new class each time its module is +rebuilt, so instances stored before the update keep the old class. To keep the current version +until you choose, turn off `gemdb.reinstallPythonOnUpdate`; **GemDB: Reinstall the Python Execution +Engine** then installs the update when you run it. #### Changes that need your permission @@ -834,8 +839,8 @@ To keep your data, copy out anything you need before you update GemDB Code. ### Settings The default GemDB Code settings are designed to work on most systems, so most users do not need to -change them. These include settings for locating where GemDB Code stores files, collecting garbage, -recording statistics, and setting up the MCP server for AI agents. +change them. These include settings for locating where GemDB Code stores files, updating Python +support, collecting garbage, recording statistics, and setting up the MCP server for AI agents. If you want to review them, open VS Code's Settings (Ctrl+, on Linux, or Cmd+, on macOS) and search for `gemdb`, or go to **Extensions** > **GemDB Code** in the list on the left. If you need to @@ -847,6 +852,7 @@ them to other computers. The following table lists all of the settings and the d | -------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------ | | `gemdb.rootPath` | `~/GemDB` | Where GemDB Code keeps everything; must be on a local disk | | `gemdb.engineVersion` | _(empty)_ | Advanced: override the pinned engine version | +| `gemdb.reinstallPythonOnUpdate` | `true` | Install the Python support a GemDB Code update ships into your database by itself | | `gemdb.maintenance.garbageCollectionIntervalHours` | `24` | Hours between scheduled garbage collections; `0` collects only when space runs short | | `gemdb.maintenance.abortIdleSessionsAfterMinutes` | `10` | Minutes before an idle session with no uncommitted changes is refreshed | | `gemdb.statistics.collect` | `true` | Whether the database records its statistics for GemDB Stats | diff --git a/docs/automation-boundary.md b/docs/automation-boundary.md index 499a364..1bfd0c8 100644 --- a/docs/automation-boundary.md +++ b/docs/automation-boundary.md @@ -133,6 +133,61 @@ sets in any configuration file wins, and the block is removed in favour of it. Never on an external database. See [`statistics.md`](statistics.md), "Recording statistics". +**Installing a newer Grail into a database that already has Python support** +(`grailFileInDue` in `lifecycle.ts`), unless `gemdb.reinstallPythonOnUpdate` +is off. A first install was always automated; an update now is too, from +every trigger, as the MCP server's is. + +From 1.7.0 to 1.9.0 it was asked instead, once per build and only of someone +who asked for the database, because it was not inert. Measured 2026-10-15 on a +database populated under 1.6.0's Grail (`beef473`), Brain Freeze included: +after filing in `9fb5d23`, committed datetimes, dates, timedeltas, Decimals, +tuples, sets, instances of user classes and a stored `None` no longer passed +`isinstance` or compared, and Brain Freeze died on its first request. +`install.gs` recreated every runtime class, and committed objects kept the old +ones. The setting was ignored then, and kept in the manifest, deprecated. + +That reason is gone. Grail upgrades in place: the Rowan load keeps Grail's +classes, and `install-grail.sh` rebuilds every deployed module into its +committed instance and classes (`scripts/redeployModules.gs`; Grail #1791, +Grail's `docs/Persistent_Modules_and_Classes.md` D7). Grail's upgrade test +writes an application's data under one Grail, installs another over it, and +checks that every stored object is still an instance of the current class and +runs its code: dates, datetimes, timedeltas, Decimals, Fractions, tuples, sets, +`None`, enum members, namedtuples, exceptions and instances of user classes. +Its nightly runs it from 1.9.0's pin (`c8cbc92`), the Grail every database +GemDB 1.9.0 created has, and #1867 measured that case at 243 checks, 0 failures. +The engine upgrade already reinstalled Grail without a second question, which +the warning contradicted. + +What an update still does not carry over, and where it is said instead of in +a question: + +- a class whose bases the update changed while instances of it, or of a class + below it, are stored. The redeploy rebuilds such a class when nothing is + stored against it; otherwise its module stays stale, the GemDB log names it, + and importing it raises with the advice to run `gemdb.schema.rebase`; +- a class statement inside a function other than `namedtuple`'s, which makes a + new class on every rebuild, so instances stored before keep the old class. + +Neither is helped by asking before the install: the first is refused with +its remedy, and the second happens on any rebuild of the module, an edit of +its source included. + +It is persistent, which the rule says to ask about, and is automated anyway +for the reason the MCP server's install is: it is GemDB's own code in GemDB's +own database, and the next engine upgrade installs it regardless. So the +setting governs it again, default on: off, the update waits for **Reinstall +the Python Execution Engine**, which the status view's row offers and which +does not ask (the command is the request). A value set before 1.7.0 means +again what it meant then. The `.gemdb-grail-declined` record 1.7.0–1.9.0 +wrote is ignored, and removed by the next install. + +`resumeRunning` still files Grail in only for a first install: after an +extension update every window activates at once onto the running database, and +an update waits for its next use rather than racing installs from editors that +only opened. + ## Asked **Raising shared memory — always prompts; never automate it.** It needs @@ -175,9 +230,9 @@ user's only copy of their data, so it is asked even though it is recoverable: the script copies the extent to `.pre-` first and copies it back if anything fails, but undoing a *successful* upgrade is by hand. The question says where the copy goes and that it stays until the user deletes it. Asked -only from the triggers that ask about a Python update — someone who asked for -the database. Auto-start and an agent get a warning with **Upgrade -Database…**, which runs Start and so asks; a modal nobody asked for would sit +only from the triggers that are someone asking for the database +(`ASKS_ABOUT_ENGINE_UPGRADES` in `lifecycle.ts`). Auto-start and an agent get +a warning with **Upgrade Database…**, which runs Start and so asks; a modal nobody asked for would sit unanswered, and the upgrade stops the database for minutes. Python support and the MCP server are then installed again without a second question: the upgrade removed their compiled methods, and the question already said so. @@ -232,42 +287,6 @@ confirms) or Leave It. Only once the notebook is at least 20 commits behind (`STN_SIGNAL_ABORT_CR_BACKLOG`'s default): a dirty notebook that nothing else commits past holds nothing back, and a prompt then would be noise. -**Installing a newer Grail into a database that already has Python support — -asked, once per build, and only by someone who asked for the database** -(`grailFileInAgreed` in `lifecycle.ts`). It used to be automated, behind -`gemdb.reinstallPythonOnUpdate` (default on), on the reasoning that Grail is -GemDB's own code. It is not inert: the install (then `install.gs`, now a Rowan -load) removes Grail's dictionaries and rebuilds every runtime class, and committed objects keep pointing at the old -ones. Measured 2026-10-15 on a database populated under 1.6.0's Grail -(`beef473`), Brain Freeze included: after filing in `9fb5d23`, committed -datetimes, dates, timedeltas, Decimals, tuples, sets, instances of user classes -and a stored `None` no longer pass `isinstance` or compare, some comparisons end -the script with an error `except BaseException` cannot catch, and Brain Freeze -dies on its first request. Filing the _same_ build in again does exactly the -same, so this is the reinstall, not any one pin. A fresh database on the new -build passed every check. Dictionaries, lists, numbers and strings survive. - -So the setting is ignored (kept in the manifest, deprecated, so existing values -are not reported as unknown) and the question is a modal warning. Placement: - -- Asked from `ensureRunning` for the triggers that are a person asking for the - database (Start, a cell, the Shell, Run or Debug File, the setup commands). - Never from auto-start or the first-run pass, which never prompt, and never - from an MCP tool call, which a modal would leave hanging. Those start the - database on the Grail it already has. -- "Keep" (or dismissing the modal) is recorded per bundled build beside the - database (`.gemdb-grail-declined`), so a notebook's every cell does not ask - again; an update shipping a different build asks afresh, and a new database - starts without the record. -- The status view's Python row and **Reinstall the Python Execution Engine** - take the update later. That command now carries the same warning, since a - reinstall does the same damage. -- A first install is still automated: there is nothing in the database yet. - -Declining is safe because staging happens only on first setup and inside -`fileInGrail`, so the Grail on disk and the Grail in the database stay the -same build. - **Stopping a database session — asked, every time** (`gemdb.stopSession`). Whatever the session has not committed is lost, so it is a modal with the session named. It is never automated, and neither of the stone's automatic diff --git a/package.json b/package.json index 13560c0..2243151 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,7 @@ "scope": "machine", "type": "boolean", "default": true, - "description": "No longer used: GemDB Code asks before installing a newer Python execution engine into your database.", - "deprecationMessage": "No longer used. Installing a newer Python execution engine makes data committed with the current one stop working as its type, so GemDB Code now asks first, whatever this says." + "description": "When a GemDB Code update ships a newer Python execution engine, install it into your database the next time the database starts or you run Python. What you have stored keeps working across the update. When off, the database keeps its current one until you run Reinstall the Python Execution Engine." }, "gemdb.maintenance.abortIdleSessionsAfterMinutes": { "scope": "machine", diff --git a/src/__tests__/databaseStarted.test.ts b/src/__tests__/databaseStarted.test.ts index 736c7f5..0b06d67 100644 --- a/src/__tests__/databaseStarted.test.ts +++ b/src/__tests__/databaseStarted.test.ts @@ -16,16 +16,12 @@ vi.mock('../account', () => ({ ensureDatabaseAccount: () => {} })); const bundledGrailStamp = vi.fn(() => 'grail=0.1-1-gabc\n'); const grailNeedsUpdate = vi.fn(() => false); const fileInGrail = vi.fn(async () => {}); -const grailUpdateDeclined = vi.fn((): string | undefined => undefined); -const recordGrailUpdateDeclined = vi.fn((_extensionPath: string) => {}); vi.mock('../grail', () => ({ grailLabel: () => 'grail 0.1', grailNeedsUpdate: () => grailNeedsUpdate(), stageGrail: () => {}, fileInGrail: () => fileInGrail(), bundledGrailStamp: () => bundledGrailStamp(), - grailUpdateDeclined: () => grailUpdateDeclined(), - recordGrailUpdateDeclined: (extensionPath: string) => recordGrailUpdateDeclined(extensionPath), })); const grailInstalled = vi.fn(() => true); @@ -233,7 +229,11 @@ describe('resumeRunning', () => { ]); }); - it('leaves an update for the user to answer: opening the editor never asks', async () => { + // An update waits for the database's next use (a cell, Start, an agent): + // every window activates at once after an extension update, and two of them + // installing into one database at the same moment is a race not worth + // starting from an editor that only opened. + it('leaves an update to the next use of the database: opening the editor installs nothing', async () => { grailNeedsUpdate.mockReturnValue(true); const ask = vi.spyOn(vscode.window, 'showWarningMessage'); @@ -275,23 +275,19 @@ describe('resumeRunning', () => { }); }); -// Filing a newer Grail into a database that already has Python support makes -// what was committed under the old one stop behaving as its type (measured: -// datetimes, Decimals, tuples, sets, user classes, a stored None). So an -// update is asked about, by whoever started something, never by GemDB alone. +// Installing a newer Grail over the one a database has keeps what was stored +// under the old one working: Grail upgrades in place, rebuilding deployed +// modules into their committed classes (Grail #1791), and Grail's upgrade test +// measures it nightly from 1.9.0's pin. So an update is installed like the MCP +// server's, without a question, unless gemdb.reinstallPythonOnUpdate is off. describe('an update to Python support', () => { - const INSTALL = 'Install Python Support'; let ask: ReturnType; - const answer = (choice: string | undefined) => - ask.mockImplementation(async () => choice as unknown as vscode.MessageItem); beforeEach(() => { __resetSettings(); bundledGrailStamp.mockReturnValue('grail=0.2-1-gdef\n'); grailNeedsUpdate.mockReturnValue(true); grailInstalled.mockReturnValue(true); - grailUpdateDeclined.mockReset().mockReturnValue(undefined); - recordGrailUpdateDeclined.mockClear(); isInstalled.mockReturnValue(true); ensureOsConfigured.mockReset().mockResolvedValue('alreadyConfigured'); isSharedMemoryConfigured.mockReset().mockResolvedValue(true); @@ -302,76 +298,36 @@ describe('an update to Python support', () => { ask = vi.spyOn(vscode.window, 'showWarningMessage'); }); - it('asks, with a warning, before installing it', async () => { - answer(INSTALL); - - expect(await ensureRunning('/ext', TRIGGER.notebook)).toBe(true); - - expect(ask).toHaveBeenCalledTimes(1); - const [message, options] = ask.mock.calls[0] as [string, vscode.MessageOptions]; - expect(options).toMatchObject({ modal: true }); - expect(`${message} ${options.detail}`).toMatch(/dates and times/); - expect(fileInGrail).toHaveBeenCalledTimes(1); - }); - - it('keeps the current Python support, and still starts, when the answer is no', async () => { - answer('Keep Current'); - - expect(await ensureRunning('/ext', TRIGGER.startCommand)).toBe(true); - - expect(fileInGrail).not.toHaveBeenCalled(); - expect(recordGrailUpdateDeclined).toHaveBeenCalledTimes(1); - }); - - it('counts a dismissed question as no', async () => { - answer(undefined); - - await ensureRunning('/ext', TRIGGER.shell); - - expect(fileInGrail).not.toHaveBeenCalled(); - expect(recordGrailUpdateDeclined).toHaveBeenCalledTimes(1); - }); - - it('does not ask again about a build the user has already kept out', async () => { - grailUpdateDeclined.mockReturnValue('grail=0.2-1-gdef\n'); - + it('installs it without asking, and reports it as an update', async () => { expect(await ensureRunning('/ext', TRIGGER.notebook)).toBe(true); expect(ask).not.toHaveBeenCalled(); - expect(fileInGrail).not.toHaveBeenCalled(); - }); - - it('asks again when a later update ships a different build', async () => { - grailUpdateDeclined.mockReturnValue('grail=0.1-1-gabc\n'); - answer(INSTALL); - - await ensureRunning('/ext', TRIGGER.notebook); - - expect(ask).toHaveBeenCalledTimes(1); expect(fileInGrail).toHaveBeenCalledTimes(1); + expect(eventsNamed('databaseStarted').map((e) => e.properties)).toEqual([ + expect.objectContaining({ trigger: 'notebook', outcome: 'started', filedGrail: 'update' }), + ]); }); - it('never asks when nobody asked for the database: auto-start and agents', async () => { - for (const trigger of [TRIGGER.autoStart, TRIGGER.mcp]) { + it('installs it whoever started the database, auto-start and agents included', async () => { + for (const trigger of [TRIGGER.startCommand, TRIGGER.shell, TRIGGER.autoStart, TRIGGER.mcp]) { expect(await ensureRunning('/ext', trigger)).toBe(true); } expect(ask).not.toHaveBeenCalled(); - expect(fileInGrail).not.toHaveBeenCalled(); - expect(recordGrailUpdateDeclined).not.toHaveBeenCalled(); + expect(fileInGrail).toHaveBeenCalledTimes(4); }); - it('ignores the setting that used to install updates unasked', async () => { - __setSetting('gemdb.reinstallPythonOnUpdate', true); - answer('Keep Current'); + it('keeps the current Python support, and still starts, when gemdb.reinstallPythonOnUpdate is off', async () => { + __setSetting('gemdb.reinstallPythonOnUpdate', false); - await ensureRunning('/ext', TRIGGER.notebook); + expect(await ensureRunning('/ext', TRIGGER.notebook)).toBe(true); - expect(ask).toHaveBeenCalledTimes(1); + expect(ask).not.toHaveBeenCalled(); expect(fileInGrail).not.toHaveBeenCalled(); }); - it('installs Python support into a database that has none without asking', async () => { + it('installs Python support into a database that has none, whatever the setting says', async () => { + __setSetting('gemdb.reinstallPythonOnUpdate', false); grailInstalled.mockReturnValue(false); await ensureRunning('/ext', TRIGGER.autoStart); @@ -380,16 +336,20 @@ describe('an update to Python support', () => { expect(fileInGrail).toHaveBeenCalledTimes(1); }); - it('warns before reinstalling on request, and reinstalls only on yes', async () => { + it('reinstalls on request without asking, since the command is the request', async () => { grailNeedsUpdate.mockReturnValue(false); - answer(undefined); + await reinstallGrail('/ext'); - expect(fileInGrail).not.toHaveBeenCalled(); - answer(INSTALL); + expect(ask).not.toHaveBeenCalled(); + expect(fileInGrail).toHaveBeenCalledTimes(1); + }); + + it('reinstalls on request with the setting off: the setting is about updates, not requests', async () => { + __setSetting('gemdb.reinstallPythonOnUpdate', false); + await reinstallGrail('/ext'); - expect(ask).toHaveBeenCalledTimes(2); expect(fileInGrail).toHaveBeenCalledTimes(1); }); }); diff --git a/src/__tests__/engineUpgradeStart.test.ts b/src/__tests__/engineUpgradeStart.test.ts index 0e9f9f0..3c2d4a8 100644 --- a/src/__tests__/engineUpgradeStart.test.ts +++ b/src/__tests__/engineUpgradeStart.test.ts @@ -16,8 +16,6 @@ vi.mock('../grail', () => ({ stageGrail: () => {}, fileInGrail: async () => {}, bundledGrailStamp: () => 'grail=0.1-1-gabc\n', - grailUpdateDeclined: () => undefined, - recordGrailUpdateDeclined: () => {}, })); /** Each test gets its own extent path: `ensureRunning` remembers an extent it has checked. */ diff --git a/src/__tests__/manifest.test.ts b/src/__tests__/manifest.test.ts index 4a1ef17..a82585f 100644 --- a/src/__tests__/manifest.test.ts +++ b/src/__tests__/manifest.test.ts @@ -27,7 +27,12 @@ interface WalkthroughStep { interface Manifest { capabilities?: { untrustedWorkspaces?: { supported?: unknown } }; contributes: { - configuration: { properties: Record }; + configuration: { + properties: Record< + string, + { scope?: string; default?: unknown; description?: string; deprecationMessage?: string } + >; + }; debuggers?: Array<{ type: string; hiddenWhen?: string }>; commands: Array<{ command: string; title: string }>; views: Record>; @@ -84,6 +89,16 @@ describe('the manifest', () => { expect(shownFor(true)).toEqual(['installExternal', 'repl', 'notebook', 'demo']); }); + // 1.7.0 deprecated it when installing a newer Grail broke stored data; Grail + // now upgrades in place, so it governs updates again. Kept in the manifest + // throughout, so a value set before 1.7.0 means again what it meant then. + it('uses gemdb.reinstallPythonOnUpdate again, on by default and not deprecated', () => { + const setting = manifest.contributes.configuration.properties['gemdb.reinstallPythonOnUpdate']; + expect(setting.default).toBe(true); + expect(setting.deprecationMessage).toBeUndefined(); + expect(setting.description).not.toMatch(/no longer used/i); + }); + it('ships the page every walkthrough step shows', () => { const missing = steps .map((step) => step.media.markdown) diff --git a/src/__tests__/statusRows.test.ts b/src/__tests__/statusRows.test.ts index 40e090f..44af84e 100644 --- a/src/__tests__/statusRows.test.ts +++ b/src/__tests__/statusRows.test.ts @@ -22,7 +22,13 @@ const failure: GrailInstallFailure = { describe('the Python row', () => { it('says the install failed, and shows why', () => { - const row = pythonRow({ installed: undefined, bundled: BUNDLED, failure, running: true }); + const row = pythonRow({ + installed: undefined, + bundled: BUNDLED, + failure, + running: true, + installsUpdates: true, + }); expect(row.description).toBe('install failed — see the GemDB output'); expect(row.tooltip).toContain('exit code 1'); @@ -30,13 +36,25 @@ describe('the Python row', () => { }); it('retries the install when the database is running', () => { - const row = pythonRow({ installed: undefined, bundled: BUNDLED, failure, running: true }); + const row = pythonRow({ + installed: undefined, + bundled: BUNDLED, + failure, + running: true, + installsUpdates: true, + }); expect(row.command?.command).toBe('gemdb.reinstallPython'); }); it('starts the database when it is stopped, since starting retries the install', () => { - const row = pythonRow({ installed: undefined, bundled: BUNDLED, failure, running: false }); + const row = pythonRow({ + installed: undefined, + bundled: BUNDLED, + failure, + running: false, + installsUpdates: true, + }); expect(row.command?.command).toBe('gemdb.start'); expect(row.tooltip).toContain('tries again the next time it starts'); @@ -46,7 +64,13 @@ describe('the Python row', () => { // A failed update is recorded against the new build, and the old stamp // may or may not have survived; either way the row must not say Python // is waiting to be installed for the first time. - const row = pythonRow({ installed: OLDER, bundled: BUNDLED, failure, running: true }); + const row = pythonRow({ + installed: OLDER, + bundled: BUNDLED, + failure, + running: true, + installsUpdates: true, + }); expect(row.description).toBe('install failed — see the GemDB output'); }); @@ -57,6 +81,7 @@ describe('the Python row', () => { bundled: BUNDLED, failure: undefined, running: true, + installsUpdates: true, }); expect(row.description).toBe('installs when you first run Python'); @@ -69,6 +94,7 @@ describe('the Python row', () => { bundled: BUNDLED, failure: undefined, running: true, + installsUpdates: true, }); expect(row.description).toBe('0.1-2200-gnew'); @@ -81,11 +107,40 @@ describe('the Python row', () => { bundled: BUNDLED, failure: undefined, running: true, + installsUpdates: true, }); expect(row.description).toBe('0.1-2100-gold — update available'); expect(row.command?.command).toBe('gemdb.reinstallPython'); }); + + it('says an update installs on the next start, and that stored data keeps working', () => { + const row = pythonRow({ + installed: OLDER, + bundled: BUNDLED, + failure: undefined, + running: true, + installsUpdates: true, + }); + + expect(row.tooltip).toContain('the next time the database starts'); + expect(row.tooltip).toContain('keeps working'); + expect(row.tooltip).not.toMatch(/stops working/); + }); + + it('names the setting that holds an update back, when it is off', () => { + const row = pythonRow({ + installed: OLDER, + bundled: BUNDLED, + failure: undefined, + running: true, + installsUpdates: false, + }); + + expect(row.tooltip).toContain('gemdb.reinstallPythonOnUpdate'); + expect(row.tooltip).not.toMatch(/stops working/); + expect(row.command?.command).toBe('gemdb.reinstallPython'); + }); }); describe('the Running row', () => { diff --git a/src/config.ts b/src/config.ts index 3d5e44e..23dc376 100644 --- a/src/config.ts +++ b/src/config.ts @@ -279,6 +279,16 @@ export function rootPath(): string { return expandHome(vscode.workspace.getConfiguration('gemdb').get('rootPath', '~/GemDB')); } +/** + * Whether an extension update's newer Grail is installed into a database that + * already has one without anyone asking. Ignored from 1.7.0 to 1.9.0, while + * that install broke stored data; kept in the manifest throughout, so a value + * set before then means what it meant when it was set. + */ +export function reinstallPythonOnUpdate(): boolean { + return vscode.workspace.getConfiguration('gemdb').get('reinstallPythonOnUpdate', true); +} + /** * The port the bundled MCP server listens on, and why it is not 8000. * diff --git a/src/grail.ts b/src/grail.ts index e3a0117..7c72399 100644 --- a/src/grail.ts +++ b/src/grail.ts @@ -129,36 +129,10 @@ export function recordGrailInstalled(extensionPath: string): void { if (!stamp) return; fs.writeFileSync(grailStampPath(), `${stamp}\n`); fs.rmSync(grailFailurePath(), { force: true }); + // Left by 1.7.0 to 1.9.0, which asked before an update and recorded a "keep". fs.rmSync(grailDeclinedPath(), { force: true }); } -/** - * Record that the user kept the bundled Grail out of the database, so that - * `ensureRunning` does not ask again on every cell for the same build. - * - * Per build: an update that ships a different one is a new question. - */ -export function recordGrailUpdateDeclined(extensionPath: string): void { - const stamp = bundledGrailStamp(extensionPath); - if (!stamp) return; - try { - fs.mkdirSync(path.dirname(grailDeclinedPath()), { recursive: true }); - fs.writeFileSync(grailDeclinedPath(), `${stamp}\n`); - } catch (e) { - // Costs only a repeat of the question. - log(`Could not record that the Python update was declined: ${errorMessage(e)}`); - } -} - -/** The Grail build the user last kept out of the database, if any. */ -export function grailUpdateDeclined(): string | undefined { - try { - return fs.readFileSync(grailDeclinedPath(), 'utf8').trim(); - } catch { - return undefined; - } -} - /** What the status view needs to say that filing Grail in failed. */ export interface GrailInstallFailure { /** The bundled build that failed, or null when the build shipped none. */ diff --git a/src/lifecycle.ts b/src/lifecycle.ts index ea35542..2cb0d0d 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -6,6 +6,7 @@ import { externalDatabase, isExternalDatabase, mcpEnabled, + reinstallPythonOnUpdate, rootPath, STONE_NAME, } from './config'; @@ -40,15 +41,7 @@ import { upgradeScriptPath, } from './engineUpgrade'; import { Progress, installEngine, removeEngine } from './engine'; -import { - bundledGrailStamp, - fileInGrail, - grailLabel, - grailNeedsUpdate, - grailUpdateDeclined, - recordGrailUpdateDeclined, - stageGrail, -} from './grail'; +import { bundledGrailStamp, fileInGrail, grailLabel, grailNeedsUpdate, stageGrail } from './grail'; import { errorMessage, log, logStep, showLog } from './log'; import { bundledMcpStamp, @@ -693,7 +686,7 @@ async function ensureRunningOnce(extensionPath: string, trigger: Trigger): Promi // build on disk differs from the one recorded in the database. const firstTime = !grailInstalled(); let filedGrail: FiledGrail = FILED_GRAIL.no; - if (await grailFileInAgreed(extensionPath, trigger)) { + if (grailFileInDue(extensionPath)) { const stamp = bundledGrailStamp(extensionPath); log( firstTime @@ -744,9 +737,9 @@ const engineChecked = new Set(); * the editor — so the guard, which runs only when a stone is about to start, * would never see it, and every login would fail with 4045. * - * Asked only of someone who asked for the database: the same triggers that are - * asked about a Python update. Anything else (auto-start, an agent) is told - * what is needed, with a button that asks. + * Asked only of someone who asked for the database (`ASKS_ABOUT_ENGINE_UPGRADES`). + * Anything else (auto-start, an agent) is told what is needed, with a button + * that asks. */ async function upgradeEngineIfNeeded( extensionPath: string, @@ -761,7 +754,7 @@ async function upgradeEngineIfNeeded( engineChecked.add(key); return 'none'; } - if (!ASKS_ABOUT_PYTHON_UPDATES.has(trigger)) { + if (!ASKS_ABOUT_ENGINE_UPGRADES.has(trigger)) { log(`The database needs upgrading from GemStone ${plan.from} to ${plan.to}.`); reportFailure('Starting GemDB', new DatabaseUpgradeNeededError(plan.from, plan.to)); return 'notAsked'; @@ -907,19 +900,21 @@ function writeUpgradeStoneConfig(): string { } /** - * Whether the database has no Python support yet. Filing it in then is - * automated: there is nothing in the database for it to break. + * Whether the database has no Python support yet: what `resumeRunning` files + * in. An update waits for the database's next use instead, since every window + * activates at once after an extension update. */ function grailFirstInstallDue(extensionPath: string): boolean { return grailNeedsUpdate(extensionPath) && !grailInstalled(); } /** - * Triggers that are someone asking for the database. The others are GemDB's - * own doing (auto-start, the first-run pass) or an AI agent's tool call, which - * a modal would leave hanging until someone noticed it. + * Triggers that are someone asking for the database, and so may be asked to + * upgrade it. The others are GemDB's own doing (auto-start, the first-run + * pass) or an AI agent's tool call, which a modal would leave hanging until + * someone noticed it. */ -const ASKS_ABOUT_PYTHON_UPDATES = new Set([ +const ASKS_ABOUT_ENGINE_UPGRADES = new Set([ TRIGGER.installCommand, TRIGGER.startCommand, TRIGGER.sharedMemoryCommand, @@ -932,61 +927,33 @@ const ASKS_ABOUT_PYTHON_UPDATES = new Set([ /** * Whether `ensureRunning` files Grail in: always into a database that has none, - * and an update only when the user says so. + * and an update unless `gemdb.reinstallPythonOnUpdate` is off. Never asked. + * + * From 1.7.0 to 1.9.0 an update was asked about instead, because filing a newer + * Grail in left much of what was committed under the old one no longer behaving + * as its type (measured on 1.6.0 -> 1.7.0: `install.gs` recreated every runtime + * class, and committed objects kept the old ones). Grail now upgrades in place: + * the Rowan load keeps its classes, and `install-grail.sh` rebuilds every + * deployed module into its committed instance and classes (Grail #1791; + * Persistent_Modules_and_Classes.md D7). Grail's upgrade test measures it + * nightly from 1.9.0's pin, c8cbc92, with stored dates, Decimals, tuples, None, + * enums and instances of user classes (Grail #1867). What it does not carry + * over is said by the module concerned, not by a question here: a class whose + * bases changed while instances of it are stored is refused on import, with the + * advice to run `gemdb.schema.rebase`. * - * Filing a newer Grail into a database that already has Python support leaves - * much of what was committed under the old one no longer behaving as its type - * (measured on 1.6.0 -> 1.7.0, and the same on a reinstall of the same build: - * install.gs rebuilds every runtime class, and committed objects keep the old - * ones). That is persistent and not undoable, so it is asked, never automated, - * whatever `gemdb.reinstallPythonOnUpdate` says. A "keep" is remembered per - * build, so a notebook's every cell does not ask again; the status view's row - * and Reinstall the Python Execution Engine take the update later. + * Off, the update waits for **Reinstall the Python Execution Engine**, which + * the status view's row offers. An engine upgrade reinstalls regardless: it + * forgets the stamp, so the database reads as having no Grail. */ -async function grailFileInAgreed(extensionPath: string, trigger: Trigger): Promise { +function grailFileInDue(extensionPath: string): boolean { if (!grailNeedsUpdate(extensionPath)) return false; - if (!grailInstalled()) return true; - const bundled = bundledGrailStamp(extensionPath); - if (!ASKS_ABOUT_PYTHON_UPDATES.has(trigger) || grailUpdateDeclined() === bundled) { - log( - `Python support ${grailLabel(bundled)} is not installed; the database keeps its current one.`, - ); - return false; - } - const agreed = await confirmGrailInstall( - `This GemDB update ships newer Python support (${grailLabel(bundled)}). Install it into your database?`, - ); - if (!agreed) { - recordGrailUpdateDeclined(extensionPath); - log(`Kept the current Python support; ${grailLabel(bundled)} was not installed.`); - } - return agreed; -} - -const INSTALL_PYTHON = 'Install Python Support'; - -/** - * The warning before Grail is filed into a database that already has Python - * support, for an update and for a reinstall on request alike. - */ -async function confirmGrailInstall(question: string): Promise { - const choice = await vscode.window.showWarningMessage( - question, - { - modal: true, - detail: - 'Data your programs committed with the Python support installed now stops working ' + - 'as its type: dates and times, decimals, tuples and sets, instances of your own ' + - 'classes, even a stored None no longer compare, calculate or pass isinstance. ' + - 'Dictionaries, lists, numbers and strings are unaffected.\n\n' + - 'If the database holds nothing you need, installing is safe. Otherwise keep the ' + - 'current Python support: GemDB will not ask again for this version, and ' + - 'Reinstall the Python Execution Engine installs it later.', - }, - INSTALL_PYTHON, - 'Keep Current', + if (!grailInstalled() || reinstallPythonOnUpdate()) return true; + log( + `Python support ${grailLabel(bundledGrailStamp(extensionPath))} is not installed: ` + + 'gemdb.reinstallPythonOnUpdate is off, so the database keeps its current one.', ); - return choice === INSTALL_PYTHON; + return false; } /** @@ -1307,7 +1274,11 @@ async function confirmForce(reason: string): Promise { return choice === 'Stop Anyway'; } -/** Reinstall Grail into the running database, on request. */ +/** + * Reinstall Grail into the running database, on request. Not asked again: the + * command is the request, and a reinstall keeps stored data working as an + * update does (see `grailFileInDue`). + */ export async function reinstallGrail(extensionPath: string): Promise { if (!isInstalled()) { void vscode.window.showErrorMessage('GemDB is not installed yet.'); @@ -1319,13 +1290,6 @@ export async function reinstallGrail(extensionPath: string): Promise { ); return; } - const label = grailLabel(bundledGrailStamp(extensionPath)); - if ( - grailInstalled() && - !(await confirmGrailInstall(`Reinstall Python support ${label} into your database?`)) - ) { - return; - } await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: 'Reinstalling Python support' }, async (progress) => { diff --git a/src/paths.ts b/src/paths.ts index 8a8024b..0bbc295 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -65,10 +65,9 @@ export function grailFailurePath(): string { } /** - * Record of the Grail build the user chose not to file into the database. - * - * Beside the database for the same reason as the failure record: the answer is - * about this database's data, so a fresh database starts without it. + * Record of the Grail build the user chose not to file into the database, + * written by 1.7.0 to 1.9.0, which asked before an update. Nothing writes it + * now; `recordGrailInstalled` removes one an earlier release left. */ export function grailDeclinedPath(): string { return path.join(databasePath(), '.gemdb-grail-declined'); diff --git a/src/statusView.ts b/src/statusView.ts index 7a6a0c5..192f9c7 100644 --- a/src/statusView.ts +++ b/src/statusView.ts @@ -8,6 +8,7 @@ import { keepStatisticsDays, mcpEnabled, mcpReadOnly, + reinstallPythonOnUpdate, statisticsIntervalSeconds, } from './config'; import { GrailInstallFailure, bundledGrailStamp, grailInstallFailure, grailLabel } from './grail'; @@ -126,8 +127,10 @@ export function pythonRow(facts: { bundled: string | undefined; failure: GrailInstallFailure | undefined; running: boolean; + /** `gemdb.reinstallPythonOnUpdate`: whether an update installs by itself. */ + installsUpdates: boolean; }): Row { - const { installed, bundled, failure, running } = facts; + const { installed, bundled, failure, running, installsUpdates } = facts; if (failure) { return { label: 'Python', @@ -158,9 +161,11 @@ export function pythonRow(facts: { ? `Python support ${grailLabel(bundled)} is ready to be added to the database. That happens ` + 'automatically the first time you open the GemDB Shell or run a notebook cell.' : outdated - ? `This GemDB update ships Python support ${grailLabel(bundled)}. GemDB asks before ` + - 'installing it, because data committed with the current one stops working as its ' + - 'type. Click to install it.' + ? `This GemDB update ships Python support ${grailLabel(bundled)}. ` + + (installsUpdates + ? 'GemDB installs it the next time the database starts or you run Python. ' + : 'gemdb.reinstallPythonOnUpdate is off, so the database keeps its current one. ') + + 'What you have stored keeps working across the update. Click to install it now.' : 'The Python implementation installed in your database.', icon: neverInstalled ? new vscode.ThemeIcon('symbol-namespace') @@ -446,6 +451,7 @@ export class StatusViewProvider implements vscode.TreeDataProvider { bundled: bundledGrailStamp(this.extensionPath), failure, running: state === 'running', + installsUpdates: reinstallPythonOnUpdate(), }), );