feat(new): track provider health state
This commit is contained in:
1 parent
3a0f80d9a7
commit
8a1389bcbc
5 files changed
+82
-13
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
export const providerHealthSchema = `
|
||||||
|
alter table providers add column auth_state text not null default 'unconfigured';
|
||||||
|
alter table providers add column health_state text not null default 'unchecked';
|
||||||
|
alter table providers add column last_checked_at text;
|
||||||
|
alter table providers add column last_error text;
|
||||||
|
`;
|
||||||
@@ -6,6 +6,7 @@ import { filesPinnedAndScheduledPosts } from "./migrations/004-files-posts";
|
|||||||
import { chatReadState } from "./migrations/005-chat-read-state";
|
import { chatReadState } from "./migrations/005-chat-read-state";
|
||||||
import { appStateSchema } from "./migrations/006-app-state";
|
import { appStateSchema } from "./migrations/006-app-state";
|
||||||
import { skillHardeningSchema } from "./migrations/007-skill-hardening";
|
import { skillHardeningSchema } from "./migrations/007-skill-hardening";
|
||||||
|
import { providerHealthSchema } from "./migrations/008-provider-health";
|
||||||
|
|
||||||
const MIGRATIONS: Array<{ version: number; sql: string }> = [
|
const MIGRATIONS: Array<{ version: number; sql: string }> = [
|
||||||
{ version: 1, sql: initialEnterpriseSchema },
|
{ version: 1, sql: initialEnterpriseSchema },
|
||||||
@@ -15,6 +16,7 @@ const MIGRATIONS: Array<{ version: number; sql: string }> = [
|
|||||||
{ version: 5, sql: chatReadState },
|
{ version: 5, sql: chatReadState },
|
||||||
{ version: 6, sql: appStateSchema },
|
{ version: 6, sql: appStateSchema },
|
||||||
{ version: 7, sql: skillHardeningSchema },
|
{ version: 7, sql: skillHardeningSchema },
|
||||||
|
{ version: 8, sql: providerHealthSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -15,24 +15,47 @@ export interface ProviderAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
const PROVIDER_REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
async function postJson(endpoint: string, headers: Record<string, string>, body: JsonRecord): Promise<JsonRecord> {
|
async function postJson(endpoint: string, headers: Record<string, string>, body: JsonRecord): Promise<JsonRecord> {
|
||||||
const response = await fetch(endpoint, {
|
const controller = new AbortController();
|
||||||
method: "POST",
|
const timeout = setTimeout(() => controller.abort(), PROVIDER_REQUEST_TIMEOUT_MS);
|
||||||
headers: {
|
let response: Response;
|
||||||
"content-type": "application/json",
|
try {
|
||||||
...headers,
|
response = await fetch(endpoint, {
|
||||||
},
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
headers: {
|
||||||
});
|
"content-type": "application/json",
|
||||||
const data = await response.json() as JsonRecord;
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
const raw = await response.text();
|
||||||
|
const data = parseProviderJson(raw, response.ok);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = readProviderError(data) ?? `Provider request failed with HTTP ${response.status}.`;
|
const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseProviderJson(raw: string, requireJson: boolean): JsonRecord {
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw) as unknown;
|
||||||
|
} catch (error) {
|
||||||
|
if (!requireJson) return {};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) throw new Error("Provider returned invalid JSON.");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function readProviderError(data: JsonRecord): string | null {
|
function readProviderError(data: JsonRecord): string | null {
|
||||||
const error = data.error;
|
const error = data.error;
|
||||||
if (typeof error === "string") return error;
|
if (typeof error === "string") return error;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { AuditService } from "./audit-service";
|
|||||||
import { SecretVault } from "./secret-vault";
|
import { SecretVault } from "./secret-vault";
|
||||||
import { providerAdapters } from "./provider-adapters";
|
import { providerAdapters } from "./provider-adapters";
|
||||||
|
|
||||||
|
const AUTH_STATE_CONFIGURED = "configured";
|
||||||
|
const HEALTH_STATE_HEALTHY = "healthy";
|
||||||
|
const HEALTH_STATE_FAILED = "failed";
|
||||||
|
const HEALTH_STATE_UNCHECKED = "unchecked";
|
||||||
|
|
||||||
function displayHint(secret: string): string {
|
function displayHint(secret: string): string {
|
||||||
const clean = secret.trim();
|
const clean = secret.trim();
|
||||||
if (clean.length <= 10) return `${clean.slice(0, 2)}...`;
|
if (clean.length <= 10) return `${clean.slice(0, 2)}...`;
|
||||||
@@ -43,8 +48,11 @@ export class ProviderService {
|
|||||||
displayHint(secret),
|
displayHint(secret),
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
this.db.prepare("update providers set credential_id = ?, model = ?, enabled = 1 where id = ? and org_id = ?")
|
this.db.prepare(`
|
||||||
.run(credentialId, input.model, input.providerId, input.orgId);
|
update providers
|
||||||
|
set credential_id = ?, model = ?, enabled = 1, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null
|
||||||
|
where id = ? and org_id = ?
|
||||||
|
`).run(credentialId, input.model, AUTH_STATE_CONFIGURED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
entityType: "provider",
|
entityType: "provider",
|
||||||
@@ -85,6 +93,7 @@ export class ProviderService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||||
|
this.attachNavigationPolicy(win, catalogEntry.signInUrl);
|
||||||
win.loadURL(catalogEntry.signInUrl);
|
win.loadURL(catalogEntry.signInUrl);
|
||||||
|
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
@@ -103,11 +112,12 @@ export class ProviderService {
|
|||||||
if (!prompt) throw new Error("Prompt is required.");
|
if (!prompt) throw new Error("Prompt is required.");
|
||||||
const provider = this.db.prepare(`
|
const provider = this.db.prepare(`
|
||||||
select id, provider_key, auth_type, model, credential_id, enabled
|
select id, provider_key, auth_type, model, credential_id, enabled
|
||||||
|
, auth_state
|
||||||
from providers
|
from providers
|
||||||
where id = ? and org_id = ?
|
where id = ? and org_id = ?
|
||||||
`).get(input.providerId, input.orgId) as ProviderRow | undefined;
|
`).get(input.providerId, input.orgId) as ProviderRow | undefined;
|
||||||
if (!provider) throw new Error("Provider was not found.");
|
if (!provider) throw new Error("Provider was not found.");
|
||||||
if (provider.enabled !== 1) throw new Error("Provider credential is required.");
|
if (provider.enabled !== 1 || provider.auth_state !== AUTH_STATE_CONFIGURED) throw new Error("Provider credential is required.");
|
||||||
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
|
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
|
||||||
throw new Error("Provider response execution requires an API key adapter.");
|
throw new Error("Provider response execution requires an API key adapter.");
|
||||||
}
|
}
|
||||||
@@ -155,6 +165,7 @@ export class ProviderService {
|
|||||||
set state = ?, response_text = ?, raw_response_json = ?, completed_at = ?
|
set state = ?, response_text = ?, raw_response_json = ?, completed_at = ?
|
||||||
where id = ? and org_id = ?
|
where id = ? and org_id = ?
|
||||||
`).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, new Date().toISOString(), requestId, input.orgId);
|
`).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, new Date().toISOString(), requestId, input.orgId);
|
||||||
|
this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_HEALTHY, null);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
entityType: "provider_request",
|
entityType: "provider_request",
|
||||||
@@ -171,9 +182,31 @@ export class ProviderService {
|
|||||||
set state = ?, error_message = ?, completed_at = ?
|
set state = ?, error_message = ?, completed_at = ?
|
||||||
where id = ? and org_id = ?
|
where id = ? and org_id = ?
|
||||||
`).run(PROVIDER_REQUEST_STATE.FAILED, message, new Date().toISOString(), requestId, input.orgId);
|
`).run(PROVIDER_REQUEST_STATE.FAILED, message, new Date().toISOString(), requestId, input.orgId);
|
||||||
|
this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_FAILED, message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void {
|
||||||
|
const allowedHost = new URL(signInUrl).hostname.toLowerCase();
|
||||||
|
const allow = (url: string) => {
|
||||||
|
const host = new URL(url).hostname.toLowerCase();
|
||||||
|
return host === allowedHost || host.endsWith(`.${allowedHost}`);
|
||||||
|
};
|
||||||
|
const blockOffHost = (event: Electron.Event, url: string) => {
|
||||||
|
if (!allow(url)) event.preventDefault();
|
||||||
|
};
|
||||||
|
win.webContents.on("will-navigate", blockOffHost);
|
||||||
|
win.webContents.on("will-redirect", blockOffHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordProviderHealth(providerId: string, orgId: string, healthState: string, error: string | null): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
update providers
|
||||||
|
set health_state = ?, last_error = ?, last_checked_at = ?
|
||||||
|
where id = ? and org_id = ?
|
||||||
|
`).run(healthState, error, new Date().toISOString(), providerId, orgId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderRow {
|
interface ProviderRow {
|
||||||
@@ -183,6 +216,7 @@ interface ProviderRow {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
credential_id: string | null;
|
credential_id: string | null;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
|
auth_state: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashPrompt(prompt: string): string {
|
function hashPrompt(prompt: string): string {
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ export interface ProviderRecord {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
credentialId: string | null;
|
credentialId: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
authState: string;
|
||||||
|
healthState: string;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user