fix(beehive): explain missing Databricks environment before setup

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-21 13:14:12 -04:00
parent 2a35dd67e9
commit 294ae0e972
3 changed files with 48 additions and 7 deletions

View File

@@ -11,19 +11,24 @@ export class ProviderDialog {
private title: TextRenderable;
private rows: TextRenderable[] = [];
private actions: TextRenderable;
private notice?: TextRenderable;
private index = 0;
private offset = 0;
private secretLength = 0;
private closed = false;
readonly done: Promise<DialogResult | undefined>;
private finish!: (value: DialogResult | undefined) => void;
constructor(private renderer: CliRenderer, private heading: string, readonly fields: DialogField[], private secret: (action: 'append' | 'backspace' | 'clear', value?: string) => void, private submitLabel = 'Save') {
constructor(private renderer: CliRenderer, private heading: string, readonly fields: DialogField[], private secret: (action: 'append' | 'backspace' | 'clear', value?: string) => void, private submitLabel = 'Save', private message?: string) {
this.done = new Promise(resolve => this.finish = resolve);
this.scrim = new BoxRenderable(renderer, { position: 'absolute', width: '100%', height: '100%', backgroundColor: palette.scrim });
this.surface = new BoxRenderable(renderer, { position: 'absolute', border: true, borderColor: palette.dialog, backgroundColor: palette.surface });
renderer.root.add(this.scrim); renderer.root.add(this.surface);
this.title = new TextRenderable(renderer, { position: 'absolute', top: 1, left: 2, height: 1, fg: palette.focus });
this.surface.add(this.title);
if (message) {
this.notice = new TextRenderable(renderer, { position: 'absolute', top: 3, left: 2, fg: palette.text, wrapMode: 'word', content: message });
this.surface.add(this.notice);
}
for (let i = 0; i < fields.length; i++) {
const row = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 2, fg: palette.text, onMouseDown: () => { this.index = i; this.paint(); } });
this.surface.add(row); this.rows.push(row);
@@ -67,10 +72,11 @@ export class ProviderDialog {
}
paint() {
if (this.closed) return;
const width = Math.min(68, this.renderer.width - 4), height = Math.min(this.renderer.height - 4, 7 + this.fields.length * 3);
const width = Math.min(68, this.renderer.width - 4), height = Math.min(this.renderer.height - 4, this.message ? 15 : 7 + this.fields.length * 3);
this.surface.width = width; this.surface.height = height;
this.surface.left = Math.floor((this.renderer.width - width) / 2); this.surface.top = Math.floor((this.renderer.height - height) / 2);
this.title.content = this.heading; this.title.width = width - 6;
if (this.notice) { this.notice.width = width - 6; this.notice.height = height - 7; }
const capacity = Math.max(1, Math.floor((height - 7) / 3));
const selected = Math.min(this.index, this.fields.length - 1);
if (selected < this.offset) this.offset = selected;
@@ -84,7 +90,7 @@ export class ProviderDialog {
row.fg = this.index === i ? palette.selectedText : palette.text;
});
this.actions.top = height - 4; this.actions.width = width - 6;
this.actions.content = ` ${this.submitLabel} \nTab next · Esc cancel · Ctrl-U clear`;
this.actions.content = this.message ? ` ${this.submitLabel} \nEnter or Esc close` : ` ${this.submitLabel} \nTab next · Esc cancel · Ctrl-U clear`;
this.actions.bg = this.index === this.fields.length ? palette.selected : palette.surface;
this.actions.fg = this.index === this.fields.length ? palette.selectedText : palette.text;
}

View File

@@ -12,7 +12,7 @@ const labels: Record<string, string> = { openai: 'OpenAI', anthropic: 'Anthropic
export function providerInventory(snapshot: ProviderSnapshot): ProviderRow[] {
return types.flatMap(type => {
const accounts = snapshot.rows.filter(row => row.type === type);
return accounts.length ? accounts : [{ id: `type:${type}`, name: labels[type]!, type: type as ProviderRow['type'], endpoint: '', state: type === 'databricks_v2' && !snapshot.databricksHost ? 'ENV MISSING' : 'NOT SET', detail: type === 'databricks_v2' ? 'Set DATABRICKS_HOST to the workspace HTTPS origin, then reload providers.' : 'Not configured. Add an account to use this provider.' }];
return accounts.length ? accounts : [{ id: `type:${type}`, name: labels[type]!, type: type as ProviderRow['type'], endpoint: '', state: type === 'databricks_v2' && !snapshot.databricksHost ? 'ENV MISSING' : 'NOT SET', detail: type === 'databricks_v2' ? 'Set DATABRICKS_HOST to the workspace HTTPS origin, then restart Beehive.' : 'Not configured. Add an account to use this provider.' }];
});
}
/** Providers presentation. All durable changes and secret state live in Node. */
@@ -94,9 +94,9 @@ export class ProviderPanel {
}
return false;
}
private async form(title: string, fields: DialogField[], submit = 'Save') {
private async form(title: string, fields: DialogField[], submit = 'Save', message?: string) {
this.client.secret('clear');
const dialog = new ProviderDialog(this.renderer, title, fields, (action, value) => this.client.secret(action, value), submit);
const dialog = new ProviderDialog(this.renderer, title, fields, (action, value) => this.client.secret(action, value), submit, message);
this.dialog = dialog;
const result = await dialog.done;
if (this.dialog === dialog) this.dialog = undefined;
@@ -110,8 +110,12 @@ export class ProviderPanel {
if (!result || generation !== this.generation) return;
type = result.Type as ProviderRow['type'];
}
if (type === 'databricks_v2' && !this.snapshot.databricksHost) {
await this.form('DATABRICKS_HOST REQUIRED', [], 'Close', 'Set DATABRICKS_HOST to your workspace HTTPS origin, for example:\nhttps://your-workspace.cloud.databricks.com\nThen restart Beehive from that environment.\nNo changes have been saved.');
return;
}
const fields: DialogField[] = [{ label: 'Name', value: row?.name ?? labels[type]! }];
if (type === 'databricks_v2') fields.push({ label: 'Workspace (DATABRICKS_HOST)', value: this.snapshot.databricksHost ?? 'Set DATABRICKS_HOST and reopen Beehive', choices: [this.snapshot.databricksHost ?? 'Set DATABRICKS_HOST and reopen Beehive'] });
if (type === 'databricks_v2') fields.push({ label: 'Workspace (DATABRICKS_HOST)', value: this.snapshot.databricksHost!, choices: [this.snapshot.databricksHost!] });
else {
if (type === 'openai-compat') fields.push({ label: 'Endpoint', value: row?.endpoint ?? 'https://' }, { label: 'Wire', value: row?.wire ?? 'auto', choices: ['auto', 'chat', 'responses'] });
fields.push({ label: row ? 'API key (blank keeps saved)' : 'API key', secret: true });

View File

@@ -104,3 +104,34 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`fresh Providers alway
assert.match(ui.captureCharFrame(), /API key/); assert.doesNotMatch(ui.captureCharFrame(), /Type ←/);
} finally { shell.close(); }
});
for (const [width, height] of [[120, 40], [60, 20]]) test(`Databricks prerequisite is non-committing and configured setup remains available at ${width}x${height}`, async () => {
const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture();
f.snapshot.rows = [];
const shell = new OpenTuiShell(ui.renderer, f.inventory, undefined, f.client);
try {
for (let i = 0; i < 3; i++) ui.mockInput.pressArrow('right');
ui.mockInput.pressEnter();
for (let i = 0; i < 4; i++) ui.mockInput.pressArrow('down');
const before = structuredClone(f.snapshot), requests = structuredClone(f.requests);
ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce();
const frame = ui.captureCharFrame();
assert.match(frame, /DATABRICKS_HOST REQUIRED/);
assert.match(frame.replace(/[│\s]+/g, ' '), /workspace HTTPS origin/);
assert.match(frame, /https:\/\/your-workspace.cloud.databricks.com/);
assert.match(frame, /Then restart Beehive from that environment/);
assert.match(frame, /No changes have been saved/);
assert.match(frame, /Close/); assert.doesNotMatch(frame, /Save|FAILED|Changes may already/);
ui.mockInput.pressEnter(); await ui.renderOnce();
assert.doesNotMatch(ui.captureCharFrame(), /DATABRICKS_HOST REQUIRED/);
assert.match(ui.captureCharFrame(), /ENV MISSING/);
assert.deepEqual(f.requests, requests); assert.deepEqual(f.snapshot, before);
f.snapshot.databricksHost = 'https://fixture.cloud.databricks.com'; f.emit();
ui.mockInput.pressEnter(); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /ADD PROVIDER/);
assert.match(ui.captureCharFrame(), /https:\/\/fixture.cloud.databricks.com/);
assert.match(ui.captureCharFrame(), /Save/);
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce();
assert.equal(f.requests.at(-1)?.action, 'save');
} finally { shell.close(); }
});