latticesql 4.3.7 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -69
- package/dist/cli.js +52911 -35716
- package/dist/desktop-entry.js +52495 -35437
- package/dist/gui-assets/force-graph.mjs +1 -0
- package/dist/index.cjs +41284 -24203
- package/dist/index.d.cts +2131 -509
- package/dist/index.d.ts +2131 -509
- package/dist/index.js +41170 -24101
- package/docs/api-reference.md +33 -2
- package/docs/assistant.md +28 -3
- package/docs/bugs/2026-07-05-db-source-import-then-wipe.md +71 -0
- package/docs/bugs/2026-07-06-workspace-switch-stale-settings-takeover.md +61 -0
- package/docs/bugs/2026-07-11-chat-active-context-w-routes.md +63 -0
- package/docs/bugs/2026-07-11-connector-table-unknown-table.md +55 -0
- package/docs/bugs/2026-07-12-image-vision-byo-key-auth.md +55 -0
- package/docs/bugs/2026-07-13-desktop-bulk-ingest-js-heap-oom.md +104 -0
- package/docs/computed-tables.md +200 -0
- package/docs/connectors.md +143 -147
- package/docs/desktop.md +48 -0
- package/docs/retrieval.md +41 -0
- package/package.json +6 -6
package/docs/api-reference.md
CHANGED
|
@@ -1122,10 +1122,41 @@ interface LatticeEntityDef {
|
|
|
1122
1122
|
interface LatticeConfig {
|
|
1123
1123
|
db: string;
|
|
1124
1124
|
entities: Record<string, LatticeEntityDef>;
|
|
1125
|
+
computed?: Record<string, ComputedTableDef>;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Computed tables — config-defined, read-only SQL projections (the `computed:`
|
|
1129
|
+
// section). See computed-tables.md for the full guide, the GUI builder, and
|
|
1130
|
+
// the /api/computed-tables HTTP surface.
|
|
1131
|
+
interface ComputedTableDef {
|
|
1132
|
+
base: string; // a declared entity or another computed table
|
|
1133
|
+
description?: string;
|
|
1134
|
+
fields: Record<string, ComputedFieldDef>; // projected in declaration order
|
|
1125
1135
|
}
|
|
1126
|
-
```
|
|
1127
1136
|
|
|
1128
|
-
|
|
1137
|
+
type ComputedFieldDef =
|
|
1138
|
+
| { kind: 'alias'; source: string }
|
|
1139
|
+
| { kind: 'calc'; expr: string; type?: LatticeFieldType }
|
|
1140
|
+
| {
|
|
1141
|
+
kind: 'ai_classify';
|
|
1142
|
+
input: string;
|
|
1143
|
+
prompt: string;
|
|
1144
|
+
labels: string[];
|
|
1145
|
+
model?: 'default' | 'cheapest';
|
|
1146
|
+
}
|
|
1147
|
+
| { kind: 'ai_transform'; inputs: string[]; prompt: string; model?: 'default' | 'cheapest' }
|
|
1148
|
+
| {
|
|
1149
|
+
kind: 'aggregate';
|
|
1150
|
+
via: string; // '<junctionTable>.<remoteRelation>'
|
|
1151
|
+
fn: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'concat';
|
|
1152
|
+
column?: string; // required for every fn except count
|
|
1153
|
+
};
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
See [Configuration Guide](./configuration.md) for the complete YAML reference,
|
|
1157
|
+
and [Computed tables](./computed-tables.md) for the computed-table guide (field
|
|
1158
|
+
kinds, the GUI builder, AI-value refresh, and the `/api/computed-tables` HTTP
|
|
1159
|
+
routes).
|
|
1129
1160
|
|
|
1130
1161
|
---
|
|
1131
1162
|
|
package/docs/assistant.md
CHANGED
|
@@ -22,11 +22,36 @@ Every `ANTHROPIC_OAUTH_*` value (authorize/token URL, client id, scopes,
|
|
|
22
22
|
redirect) can be overridden via the environment for a non-default deployment —
|
|
23
23
|
see [`.env.example`](../.env.example).
|
|
24
24
|
|
|
25
|
+
### Use an OpenAI-compatible model instead
|
|
26
|
+
|
|
27
|
+
The assistant is not tied to Anthropic. On the first-run connect screen, choose
|
|
28
|
+
**or connect an OpenAI-compatible model** to point it at any OpenAI-compatible
|
|
29
|
+
`chat/completions` endpoint — OpenAI, Azure, OpenRouter, a local vLLM / Ollama /
|
|
30
|
+
LM Studio server, your own gateway, or GitHub Copilot if you point it there. You
|
|
31
|
+
supply three things: a **base URL** (up to `/v1`), an **API key** (sent as a
|
|
32
|
+
Bearer token — leave blank for a keyless local server), and a **model** id
|
|
33
|
+
(e.g. `gpt-4o`). Lattice ships **no** provider-specific auth or header
|
|
34
|
+
impersonation; if your endpoint needs extra headers (an Azure `api-key`, a
|
|
35
|
+
gateway token) they can be supplied alongside the config.
|
|
36
|
+
|
|
37
|
+
Once connected it becomes the active backend and **every** assistant feature —
|
|
38
|
+
chat, auto-linking / ingestion, computed-table fills, as-of detection, HTML
|
|
39
|
+
authoring — runs on it. Switching backends and disconnecting are available via
|
|
40
|
+
the API (`PUT /api/assistant/provider`, `DELETE
|
|
41
|
+
/api/assistant/provider/openai-compat`); with nothing configured the assistant
|
|
42
|
+
resolves a connected Claude subscription exactly as before. Image / PDF vision
|
|
43
|
+
still uses a connected Claude subscription when one is available. Credentials are
|
|
44
|
+
stored encrypted in the machine-local assistant credential store, never returned
|
|
45
|
+
by any endpoint.
|
|
46
|
+
|
|
25
47
|
## Chat
|
|
26
48
|
|
|
27
|
-
The rail runs a Claude tool-calling loop
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
The rail runs a Claude tool-calling loop. Sending a message returns immediately
|
|
50
|
+
(the request is acknowledged, not held open); the turn runs in the background and
|
|
51
|
+
its text streams to the browser over the multiplexed event WebSocket, so closing
|
|
52
|
+
the panel, navigating away, or reloading never cancels an in-flight answer. The
|
|
53
|
+
model can list, read, **full-text search**, create, update, link, delete tables,
|
|
54
|
+
and revert in the active database. **Every edit goes through the same audited, undoable
|
|
30
55
|
mutation path as a manual edit** — it appears in the activity feed and the
|
|
31
56
|
version history and can be reverted.
|
|
32
57
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Connecting a database imports every row, then silently wipes it
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-05
|
|
4
|
+
**Area:** db-source connector connect route (`src/gui/db-sources-routes.ts`) + teardown (`src/connectors/teardown.ts`)
|
|
5
|
+
|
|
6
|
+
## Symptom
|
|
7
|
+
|
|
8
|
+
A user connected an external Postgres (≈20 tables, ~2,900 rows). Every table was
|
|
9
|
+
created and every row was imported — and then, ~50 seconds later, **all of it
|
|
10
|
+
disappeared**: the graph and object views showed "No objects with data yet", and
|
|
11
|
+
the connection was gone from the Databases list. From the user's side it looked
|
|
12
|
+
like nothing had ingested at all.
|
|
13
|
+
|
|
14
|
+
## Root cause
|
|
15
|
+
|
|
16
|
+
The connect handler ran the entire import inside one `try`:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
defineLate(each table) → enableConnectorRls → syncConnector → publishImportSummary
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
and its `catch` responded to **any** throw — including one that happens _after_
|
|
23
|
+
all the rows are already committed — by calling
|
|
24
|
+
`disconnectConnector(mode: 'hard')`. That teardown soft-deletes every imported
|
|
25
|
+
row (under one shared `deleted_at` timestamp) and hard-deletes the registry row.
|
|
26
|
+
|
|
27
|
+
So the handler conflated two very different failures:
|
|
28
|
+
|
|
29
|
+
1. **Pre-persistence** (bad creds, no reachable tables, a `defineLate`/RLS
|
|
30
|
+
failure) — nothing landed, so rolling back is correct.
|
|
31
|
+
2. **Post-persistence** (a late/derived/transient step throws after thousands of
|
|
32
|
+
rows are committed) — the rollback **destroys a fully successful import**.
|
|
33
|
+
|
|
34
|
+
The specific late throw is not even deterministic on a local SQLite workspace,
|
|
35
|
+
which is the tell: the defect isn't any single operation, it's the _all-or-nothing
|
|
36
|
+
rollback policy_. It also destroyed its own evidence — the only persisted error
|
|
37
|
+
trace (the registry's `last_error`) was hard-deleted by the same teardown, so the
|
|
38
|
+
failure surfaced as "nothing ingested" with no cause.
|
|
39
|
+
|
|
40
|
+
## Fix
|
|
41
|
+
|
|
42
|
+
Split the connect into two phases:
|
|
43
|
+
|
|
44
|
+
- **Setup** (`defineLate` + RLS) keeps the hard rollback — a failure here means
|
|
45
|
+
nothing landed, so no phantom entry is left behind.
|
|
46
|
+
- **Import** (`syncConnector`) no longer rolls back on failure. `syncConnector`'s
|
|
47
|
+
own catch has already stamped the registry row `status='error'` + `last_error`,
|
|
48
|
+
and `GET /api/db-sources` returns every status, so the connection stays visible
|
|
49
|
+
with its error, the imported rows stay live, the raw error is logged
|
|
50
|
+
server-side, and the user can **Refresh** to retry or **Disconnect** to remove.
|
|
51
|
+
|
|
52
|
+
This is the intended loud-failure behavior: surface the error, keep the data.
|
|
53
|
+
|
|
54
|
+
## Lessons
|
|
55
|
+
|
|
56
|
+
- An "atomic connect" must not treat a post-commit failure the same as a
|
|
57
|
+
pre-commit one — discarding already-persisted user data to satisfy atomicity is
|
|
58
|
+
worse than a visible partial import.
|
|
59
|
+
- A rollback that also deletes the error record makes the failure undiagnosable.
|
|
60
|
+
Log the raw error before any cleanup.
|
|
61
|
+
|
|
62
|
+
## Regression tests
|
|
63
|
+
|
|
64
|
+
`tests/integration/gui-db-sources-rollback.test.ts`:
|
|
65
|
+
|
|
66
|
+
- An import failure keeps an errored connection (registry row survives with
|
|
67
|
+
`status='error'`, shows in `/api/db-sources`, creds/descriptor retained) instead
|
|
68
|
+
of wiping everything.
|
|
69
|
+
- A **post-persistence** failure (rows imported for the first model, the second
|
|
70
|
+
model throws) keeps the already-imported rows **live** and the connection
|
|
71
|
+
present — the exact production data-loss path.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Switching workspaces left the Settings / Version-history takeover showing the old workspace
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-06
|
|
4
|
+
**Area:** GUI client — workspace switch routing (`src/gui/app/modules/search.ts`)
|
|
5
|
+
**Severity:** stale/misleading data (a workspace-scoped panel kept showing the previous workspace)
|
|
6
|
+
|
|
7
|
+
## Symptom
|
|
8
|
+
|
|
9
|
+
With the **Settings** drawer open (Workspace tab — the workspace name, database
|
|
10
|
+
connection, and data model), switching to a different workspace from the topbar
|
|
11
|
+
dropdown updated the topbar (it now named the new workspace) but the Settings panel
|
|
12
|
+
kept showing the **previous** workspace's data — the topbar named workspace B while the
|
|
13
|
+
Workspace Settings display name still showed workspace A. The same happened for any open
|
|
14
|
+
takeover: **Version history** kept showing the old workspace's history.
|
|
15
|
+
|
|
16
|
+
## Root cause
|
|
17
|
+
|
|
18
|
+
`reloadEverything()` is the single canonical workspace-switch path. To keep the user
|
|
19
|
+
in the same section across a switch it computes a `switchTarget` hash from the current
|
|
20
|
+
route and navigates there (or soft-re-renders if already on it). The mapping handled
|
|
21
|
+
Analytics / Graph / Tables / Objects but had **no case for `#/settings/*`**, so a
|
|
22
|
+
settings or version-history route fell through to the `#/folders` default.
|
|
23
|
+
|
|
24
|
+
The Settings drawer and Version history are **takeover overlays** that the route
|
|
25
|
+
dispatcher (re)renders from the hash: `#/settings/database → openSettingsDrawer('database')`,
|
|
26
|
+
etc. By routing the switch to `#/folders`, `reloadEverything()` both (a) failed to
|
|
27
|
+
re-invoke `openSettingsDrawer` for the new workspace and (b) never closed the drawer —
|
|
28
|
+
so the overlay stayed on screen still populated with the previous workspace's
|
|
29
|
+
`renderDatabaseSettings` / `renderHistory` output. The panel content is workspace-
|
|
30
|
+
specific (name, DB connection, data model, history), so it read as another workspace's
|
|
31
|
+
data leaking into the current view.
|
|
32
|
+
|
|
33
|
+
## Fix
|
|
34
|
+
|
|
35
|
+
Add a `#/settings/*` case to the `switchTarget` computation that **preserves the exact
|
|
36
|
+
route** (`cur`). When the target equals the current hash, `reloadEverything()` takes its
|
|
37
|
+
existing "already on target → `renderRoute({ soft: true })`" branch, which re-dispatches
|
|
38
|
+
the settings route → `openSettingsDrawer(section)` → `selectDrawerTab` → re-renders the
|
|
39
|
+
drawer body (`renderDatabaseSettings` / `renderLatticeSettings` / `renderUserConfig` /
|
|
40
|
+
`renderHistory`) against the now-active new workspace. The takeover stays open and shows
|
|
41
|
+
the new workspace's data. Version history (`#/settings/history`) is covered by the same
|
|
42
|
+
prefix.
|
|
43
|
+
|
|
44
|
+
## Lessons learned
|
|
45
|
+
|
|
46
|
+
- A "keep the user in the same section on switch" mapping must account for **every**
|
|
47
|
+
routed surface, including overlay takeovers — not just the main content sections. A
|
|
48
|
+
route that isn't in the mapping silently falls to the default and strands whatever
|
|
49
|
+
overlay was open.
|
|
50
|
+
- Overlays that render workspace-scoped data are a cross-workspace-leak risk on switch,
|
|
51
|
+
the same class as the Outputs-markdown leak: the switch path must refresh (or close)
|
|
52
|
+
every workspace-scoped surface, not only the ones rendered into `#content`.
|
|
53
|
+
|
|
54
|
+
## Regression tests
|
|
55
|
+
|
|
56
|
+
- `tests/unit/outputs-workspace-switch.test.ts` — "a switch while a Settings /
|
|
57
|
+
Version-history takeover is open re-renders it for the new workspace": drives the real
|
|
58
|
+
`reloadEverything()` in jsdom with `location.hash = '#/settings/database'` and asserts
|
|
59
|
+
the hash is **preserved** (not reset to `#/folders`) and `renderRoute({ soft: true })`
|
|
60
|
+
is called (which re-renders the drawer for the new workspace). Fails before the fix
|
|
61
|
+
(`#/folders`), passes after.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Chat lost the currently-open dashboard/record as context on the 5.0 Workspace routes
|
|
2
|
+
|
|
3
|
+
- **Date:** 2026-07-11
|
|
4
|
+
- **Area:** GUI chat context (`activeContext`) / client route detection
|
|
5
|
+
- **Severity:** High (core "assistant knows what you're looking at" feature silently no-ops)
|
|
6
|
+
|
|
7
|
+
## Symptom
|
|
8
|
+
|
|
9
|
+
With a dashboard open, asking the assistant "why is this dashboard blank?" produced a reply
|
|
10
|
+
asking the user to _open_ the dashboard they were already viewing (e.g. "I need to see the
|
|
11
|
+
dashboard to diagnose the issue. Could you open it so I can investigate?"). The same class of
|
|
12
|
+
failure applied to any open table record or file — the assistant never received the
|
|
13
|
+
currently-open surface as context, so "this dashboard / this row / this file" resolved to
|
|
14
|
+
nothing and the self-diagnosis (`investigate`) tool had no default target.
|
|
15
|
+
|
|
16
|
+
## Root cause
|
|
17
|
+
|
|
18
|
+
The client sends a `activeContext: { table, id }` hint with every chat message, computed by
|
|
19
|
+
`activeElement()` in `src/gui/app/modules/boot-interstitial.ts`. Its dashboard branch matched
|
|
20
|
+
**only** the retired route `#/analytics/<id>`. In the 5.0 single layout:
|
|
21
|
+
|
|
22
|
+
- the canonical open-dashboard route is `#/w/dash/<id>` (see `renderRoute` in
|
|
23
|
+
`analytics-view.ts`), and
|
|
24
|
+
- `normalizeLegacyHash` (`workspace-switch-progress.ts`) rewrites `#/analytics/<id>` →
|
|
25
|
+
`#/w/dash/<id>` on entry,
|
|
26
|
+
|
|
27
|
+
so a user viewing a dashboard is **always** on `#/w/dash/<id>` — a hash `activeElement()`
|
|
28
|
+
never recognized. It therefore returned `null`, the client POSTed `activeContext: null`, and
|
|
29
|
+
the entire (correct) server chain went idle: `parseActiveContext` → `undefined`,
|
|
30
|
+
`describeActiveView` emitted no note, `dispatch.activeDashboardId` was never set, and the
|
|
31
|
+
`investigate` tool returned "No dashboard is open… have them open it." The same gap dropped
|
|
32
|
+
open table records (`#/w/table/<name>/<rowId>`) and files (`#/w/file/<id>`) from chat context.
|
|
33
|
+
|
|
34
|
+
The server side of the feature was fully built and correct — it was starved of the one input
|
|
35
|
+
it needed because the client route detector was never migrated to the `#/w/…` scheme.
|
|
36
|
+
|
|
37
|
+
## Fix
|
|
38
|
+
|
|
39
|
+
Add a `#/w/(dash|table|file|md)/<first>[/<drill-in>…]` branch to `activeElement()` that mirrors
|
|
40
|
+
`renderRoute`'s parser:
|
|
41
|
+
|
|
42
|
+
- `dash` → `{ table: 'dashboards', id }`
|
|
43
|
+
- `file` → `{ table: 'files', id }`
|
|
44
|
+
- `table` / `md` → the deepest complete `table,id` pair (a bare collection yields `null`)
|
|
45
|
+
|
|
46
|
+
The legacy `#/analytics/<id>` branch is retained for back-compat.
|
|
47
|
+
|
|
48
|
+
## Lessons learned
|
|
49
|
+
|
|
50
|
+
- When a route scheme is migrated (`#/analytics` / `#/fs` → `#/w/…`), audit **every** consumer
|
|
51
|
+
of the hash, not just the renderers. A route producer (`renderRoute`) and a route _reader_
|
|
52
|
+
(`activeElement`) drifted apart, and only the reader was missed — a silent no-op with no error.
|
|
53
|
+
- Client-computed context that feeds the model needs a behavioral test at the client seam.
|
|
54
|
+
There was thorough coverage of the _server_ consuming a well-formed hint and of the
|
|
55
|
+
downstream tools once `activeDashboardId` was set, but nothing asserted the client actually
|
|
56
|
+
_produces_ the hint from the current route — so the regression slipped through green tests.
|
|
57
|
+
|
|
58
|
+
## Regression tests
|
|
59
|
+
|
|
60
|
+
- `tests/unit/gui-active-element-dashboard.test.ts` — jsdom test that evals the client segment
|
|
61
|
+
and asserts `activeElement()` returns the right `{ table, id }` for `#/w/dash/<id>` (incl.
|
|
62
|
+
percent-encoded ids), `#/w/file/<id>`, `#/w/table/<name>/<rowId>`, a drilled relation
|
|
63
|
+
(deepest pair), `null` for a bare collection, and the legacy `#/analytics/<id>` route.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Clicking a post-open registered table 404s "Unknown table"
|
|
2
|
+
|
|
3
|
+
- **Date:** 2026-07-11
|
|
4
|
+
- **Area:** GUI row/read routes — table-name validation vs. the live registry
|
|
5
|
+
- **Severity:** High (a table shown in the sidebar is unclickable — hard error)
|
|
6
|
+
|
|
7
|
+
## Symptom
|
|
8
|
+
|
|
9
|
+
After connecting a connector (e.g. a Gmail connector registering `gmail_labels`), the new
|
|
10
|
+
table appears in the sidebar TABLES list, but clicking it returns `Unknown table: gmail_labels`
|
|
11
|
+
and the collection never loads. A page reload does not help. The same applies to tables
|
|
12
|
+
registered by a connected external database (db-source).
|
|
13
|
+
|
|
14
|
+
## Root cause
|
|
15
|
+
|
|
16
|
+
The sidebar list is built from the **live** table registry (`db.getRegisteredTableNames()`,
|
|
17
|
+
via `entitiesSummary` / `registeredExtraTables`), but the row + read routes validated table
|
|
18
|
+
names against `active.validTables` — a **snapshot** built once when the workspace opened
|
|
19
|
+
(config tables ∪ registered non-internal tables at open time). A connector or db-source
|
|
20
|
+
registers its tables via `db.defineLate` **after** the workspace opened, so those tables are
|
|
21
|
+
in the live registry (hence listed) but absent from the snapshot (hence rejected). The list
|
|
22
|
+
source and the validation source diverge.
|
|
23
|
+
|
|
24
|
+
The gate appeared in several places — `tables-routes.ts` (row CRUD, the reported error),
|
|
25
|
+
`read-routes.ts` (provenance graph/row + the rows-markdown listing) and `server.ts`
|
|
26
|
+
(`/api/gui-meta`) — all using the bare `active.validTables.has(table)` snapshot check. Notably
|
|
27
|
+
the schema-op paths (`computed-ops.ts`, `schema-ops.ts`, `schema-routes.ts`) **already** used
|
|
28
|
+
the correct `validTables.has(x) || db.getRegisteredTableNames().includes(x)` idiom; the row/read
|
|
29
|
+
gates were simply never migrated to it.
|
|
30
|
+
|
|
31
|
+
## Fix
|
|
32
|
+
|
|
33
|
+
Add one shared predicate `isRegisteredTable(active, table)` in `active-db.ts`: true if the
|
|
34
|
+
table is in the `validTables` snapshot **or** in the live registry, with internal
|
|
35
|
+
(`__lattice*` / `_lattice*`) tables always excluded so the security boundary is unchanged.
|
|
36
|
+
Replace the bare `validTables.has` snapshot checks on the row/read/navigate gates
|
|
37
|
+
(`tables-routes.ts`, `read-routes.ts` ×3, `server.ts` gui-meta) with it. Genuinely-unknown
|
|
38
|
+
tables still 400/404 (they are in neither set).
|
|
39
|
+
|
|
40
|
+
## Lessons learned
|
|
41
|
+
|
|
42
|
+
- A "snapshot at open" set that a later runtime step (`defineLate`) can extend is a divergence
|
|
43
|
+
waiting to happen. When the same question ("is this a real table?") is answered against two
|
|
44
|
+
sources — a live registry for _listing_ and a snapshot for _validation_ — they will drift.
|
|
45
|
+
Answer it one way. Here the schema paths already had the right idiom; the fix is consistency.
|
|
46
|
+
- When adding a gate, prefer a single named predicate over inlining `set.has(x)` at each call
|
|
47
|
+
site, so a policy change lands in one place instead of ~40.
|
|
48
|
+
|
|
49
|
+
## Regression tests
|
|
50
|
+
|
|
51
|
+
- `tests/unit/gui-registered-table.test.ts` — pins `isRegisteredTable`: accepts a snapshot
|
|
52
|
+
table, accepts a table registered after open (absent from the snapshot — the regressed case),
|
|
53
|
+
rejects a table in neither, and never exposes internal `__lattice*` / `_lattice*` tables even
|
|
54
|
+
when live-registered. The existing integration test that a genuinely-unknown table still
|
|
55
|
+
returns "Unknown table" continues to guard the reject path.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Image descriptions never generate with a bring-your-own Claude API key
|
|
2
|
+
|
|
3
|
+
- **Date:** 2026-07-12
|
|
4
|
+
- **Area:** Ingest — image/PDF vision credential resolution
|
|
5
|
+
- **Severity:** High for BYO-key users (image files ingest with no description; the file view shows "No source text.")
|
|
6
|
+
|
|
7
|
+
## Symptom
|
|
8
|
+
|
|
9
|
+
Opening an image file record shows a body of exactly "No source text." instead of the
|
|
10
|
+
expected AI-generated description of the image. Chat and text enrichment work fine for the
|
|
11
|
+
same user — only image (and scanned-PDF) descriptions are missing.
|
|
12
|
+
|
|
13
|
+
## Root cause
|
|
14
|
+
|
|
15
|
+
It is an ingest-side credential gap, not a render bug. Image vision (`extractImage`) and the
|
|
16
|
+
scanned-PDF fallback resolve their Anthropic auth via `resolveClaudeAuth`, which is **narrower**
|
|
17
|
+
than the resolver chat + text enrichment use (`resolveLlmProvider`): `resolveClaudeAuth` returns
|
|
18
|
+
auth only for a managed env key or a connected Claude **subscription (OAuth)**, and never
|
|
19
|
+
consults the **bring-your-own Claude API key** configured as an API provider pointed at an
|
|
20
|
+
Anthropic host (`readOpenAiCompatConfig` + `isAnthropicEndpoint`).
|
|
21
|
+
|
|
22
|
+
So for a BYO-key user: `resolveClaudeAuth` returns null → `extractImage` returns null → the file
|
|
23
|
+
is written with `extracted_text=''` and `extraction_status='skipped'` (and, because the row is
|
|
24
|
+
`skip`ped, LLM enrichment — the only writer of a `description` — never runs either). The record
|
|
25
|
+
view faithfully reads `extracted_text` and, finding it empty, renders "No source text.".
|
|
26
|
+
|
|
27
|
+
The render side was correct throughout; there was never an AI description sitting in a field the
|
|
28
|
+
view ignored — the description was simply never generated.
|
|
29
|
+
|
|
30
|
+
## Fix
|
|
31
|
+
|
|
32
|
+
Add `resolveVisionAuth(db)` in `src/gui/ai/provider.ts` and use it at the two vision call sites
|
|
33
|
+
(`ingest-routes.ts` `extractImage` + the PDF fallback) instead of `resolveClaudeAuth`. It unifies
|
|
34
|
+
vision's credentials with the rest of the assistant — managed env key, connected subscription,
|
|
35
|
+
**and** a BYO Claude API key on an Anthropic host — following the same managed → active-provider
|
|
36
|
+
ordering as `resolveLlmProvider`. Living in `provider.ts` (which already imports
|
|
37
|
+
`resolveClaudeAuth` + the config helpers) avoids an import cycle with `assistant-routes.ts`.
|
|
38
|
+
|
|
39
|
+
Known limitation: `ClaudeAuth` carries no `baseURL`, so a BYO key against a **non-default**
|
|
40
|
+
Anthropic host (a gateway) is not honored for vision — the common `api.anthropic.com` key works;
|
|
41
|
+
a custom-host key falls through to a connected subscription, else no vision.
|
|
42
|
+
|
|
43
|
+
## Lessons learned
|
|
44
|
+
|
|
45
|
+
- Two credential resolvers for "the same" thing (chat vs. vision) drift. When a feature needs
|
|
46
|
+
Anthropic auth, it should resolve it through one shared path, not a hand-rolled narrower check.
|
|
47
|
+
- "A key is present" is a trap: the key was present for chat and invisible to vision. Test the
|
|
48
|
+
credential resolver directly at the exact configuration state that reproduces the gap.
|
|
49
|
+
|
|
50
|
+
## Regression tests
|
|
51
|
+
|
|
52
|
+
- `tests/unit/llm-provider.test.ts` — with only a BYO Claude API key on an Anthropic endpoint
|
|
53
|
+
(no OAuth, not managed): `resolveClaudeAuth` returns null (the root cause) while
|
|
54
|
+
`resolveVisionAuth` returns the usable `{ apiKey }`. Plus: a non-Anthropic OpenAI-compatible
|
|
55
|
+
endpoint yields no vision auth, and nothing-configured stays null.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Desktop app crashes with a JS-heap OOM during bulk document ingest
|
|
2
|
+
|
|
3
|
+
- **Date:** 2026-07-13
|
|
4
|
+
- **Area:** Ingest — desktop runtime memory ceiling + extraction concurrency
|
|
5
|
+
- **Severity:** Critical (the whole desktop app process aborts mid-batch; repeated, reproducible)
|
|
6
|
+
|
|
7
|
+
## Symptom
|
|
8
|
+
|
|
9
|
+
Ingesting a folder of large Office documents (~60 files, a few hundred MB total, individual
|
|
10
|
+
spreadsheets/decks up to ~35 MB) crashes the desktop app partway through the batch. The
|
|
11
|
+
macOS crash report shows `EXC_BREAKPOINT (SIGTRAP)` on the embedded runtime thread. Time to
|
|
12
|
+
crash varies wildly with what is being ingested — from ~70 seconds after launch to many
|
|
13
|
+
hours — and the same folder ingests fine through the CLI-launched GUI on the same machine.
|
|
14
|
+
|
|
15
|
+
Symbolicating the crash frames against the runtime binary identifies the abort precisely:
|
|
16
|
+
V8 `FatalProcessOutOfMemory` with the reason string **"Ineffective mark-compacts near heap
|
|
17
|
+
limit"** — the JS old-space heap hit its configured maximum and consecutive full GCs could
|
|
18
|
+
not reclaim anything. Notably, resident memory at death was only ~270 MB, which had
|
|
19
|
+
previously been read as evidence this was _not_ an OOM; it is — the heap limit itself was
|
|
20
|
+
tiny.
|
|
21
|
+
|
|
22
|
+
## Root cause
|
|
23
|
+
|
|
24
|
+
Two independent causes compound:
|
|
25
|
+
|
|
26
|
+
1. **The compiled desktop runtime ships a conservative default V8 heap limit.** Nothing in
|
|
27
|
+
the desktop build passed `--v8-flags`, and the packaged runtime's default old-space
|
|
28
|
+
ceiling is a few hundred MB — vs ~4 GB for the CLI (`deno run`) default on the same
|
|
29
|
+
machine. This is why the crash class is desktop-only and why every prior "bulk-ingest
|
|
30
|
+
crash" investigation on the desktop kept finding memory-shaped failures. There is no
|
|
31
|
+
runtime escape hatch in the packaged app (no env-var flag pass-through), so the limit
|
|
32
|
+
must be baked in at build time.
|
|
33
|
+
|
|
34
|
+
2. **Extraction transients are input-side, large, and were multiplied by concurrency.** The
|
|
35
|
+
folder-ingest pool runs 4 files at once and the browser upload path allows 3 more, and
|
|
36
|
+
each in-flight file can materialize:
|
|
37
|
+
- a full archive inflation of an Office/OpenDocument/EPUB file into memory
|
|
38
|
+
(`unzipSync` map, up to the 256 MB aggregate cap) plus full JS-string decodes of the
|
|
39
|
+
large XML parts;
|
|
40
|
+
- a pdf.js parse graph bounded only by a wall-clock timeout, not by memory;
|
|
41
|
+
- for scanned PDFs, a base64 copy of the whole file (~1.37×) inside the model request
|
|
42
|
+
body, which the SDK re-serializes and retains across 429/5xx retries — this leg had no
|
|
43
|
+
lock at all (only native image normalization was serialized);
|
|
44
|
+
- for browser uploads, the full request buffer (≤50 MB) retained by the handler for the
|
|
45
|
+
rest of its lifetime, alongside all of the above.
|
|
46
|
+
|
|
47
|
+
The 200 KB cap on _extracted text_ never bounded any of these input-side intermediates.
|
|
48
|
+
Count-based concurrency limits (4 + 3) cannot bound the heap when a single file's
|
|
49
|
+
transients run to hundreds of MB: concurrency × peak-transient is the number that has to
|
|
50
|
+
fit the heap, and it didn't.
|
|
51
|
+
|
|
52
|
+
## Fix
|
|
53
|
+
|
|
54
|
+
- **Bake a real heap ceiling into every desktop build.** All four `deno desktop` build
|
|
55
|
+
scripts now pass `--v8-flags=--max-old-space-size=4096` (matching the CLI default on a
|
|
56
|
+
16 GB machine and the ceiling CI already builds with). It is a ceiling, not a
|
|
57
|
+
reservation — the process still only commits what it allocates.
|
|
58
|
+
- **Serialize heavy extraction.** New `src/gui/ai/extract-gate.ts`: files ≥ 8 MB on disk
|
|
59
|
+
acquire a process-wide single-slot lane around the whole extraction (`extractSource`),
|
|
60
|
+
covering archive inflation, PDF parsing, and the scanned-PDF vision call for every ingest
|
|
61
|
+
door (folder pool, browser upload, local-path ingest, chat attachments). Smaller files —
|
|
62
|
+
the long tail — keep the pool's full concurrency. Lock order with the existing native
|
|
63
|
+
image lock is strictly lane → native, so the two cannot deadlock; the lane releases in
|
|
64
|
+
`finally`, so a throwing extraction never poisons it.
|
|
65
|
+
- **Release the upload buffer before extraction.** The browser-upload handler now hashes
|
|
66
|
+
and sizes the request bytes up front, keeps them only if an S3 push will need them, and
|
|
67
|
+
drops the reference before extraction begins — a large upload's in-memory copy no longer
|
|
68
|
+
coexists with the extraction transients.
|
|
69
|
+
- **Log the effective heap limit at startup** (`[desktop] … (V8 heap limit N MB)`), so a
|
|
70
|
+
memory-starved build is visible at a glance instead of only as a mid-ingest crash.
|
|
71
|
+
|
|
72
|
+
## Lessons learned
|
|
73
|
+
|
|
74
|
+
- **Output caps don't bound input transients.** The extracted-text cap suggested extraction
|
|
75
|
+
memory was bounded; the actual heap cost lives in the intermediates (inflation maps,
|
|
76
|
+
parse graphs, base64 request bodies) that exist before any cap applies.
|
|
77
|
+
- **A compiled runtime's V8 defaults are not the CLI's defaults — assert them, don't assume
|
|
78
|
+
them.** The startup heap-limit log and the build-script regression test exist so this
|
|
79
|
+
divergence can never be invisible again.
|
|
80
|
+
- **Low resident memory does not rule out an OOM.** A process can die of heap exhaustion at
|
|
81
|
+
~270 MB RSS when the configured limit is small. Symbolicate the abort reason before
|
|
82
|
+
classifying a crash.
|
|
83
|
+
- **Per-stage locks don't compose into a memory budget.** Serializing one native step
|
|
84
|
+
(image normalization) still left every JS-heavy leg concurrent. The budget that matters
|
|
85
|
+
is concurrency × peak-per-file-transient across the whole pipeline.
|
|
86
|
+
|
|
87
|
+
## Regression tests
|
|
88
|
+
|
|
89
|
+
- `tests/unit/extract-gate.test.ts` — heavy extractions serialize (`maxActive === 1`),
|
|
90
|
+
small files stay concurrent and are never blocked behind the lane, and a rejected heavy
|
|
91
|
+
extraction releases the lane for the next waiter.
|
|
92
|
+
- `tests/unit/desktop-heap-flags.test.ts` — every desktop build script carries
|
|
93
|
+
`--v8-flags=--max-old-space-size=4096`; fails loudly if the flag is ever dropped.
|
|
94
|
+
- The upload-buffer release is a reference-lifetime change with no cheap deterministic
|
|
95
|
+
test (observing collectability mid-handler requires GC instrumentation); it is covered by
|
|
96
|
+
review and the end-to-end bulk-ingest verification.
|
|
97
|
+
|
|
98
|
+
## Follow-ups (noted, not in this fix)
|
|
99
|
+
|
|
100
|
+
- `autoImportStructured` (spreadsheet re-import on upload) runs outside the lane,
|
|
101
|
+
post-extraction, upload-only — worth folding under the lane if it ever shows up in a
|
|
102
|
+
heap profile.
|
|
103
|
+
- pdf.js parsing remains timeout-bounded rather than memory-bounded; the lane serializes
|
|
104
|
+
big PDFs, which contains the risk, but a streaming/paged parse would bound it properly.
|