feat(agent): migrate OpenCode 2 runtime and sandbox tools
Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore session-scoped learning, credential renewal, typed project queries and controlled frontend actions from the legacy Agent. Replace host Shell execution with an approved Landlock/seccomp executor and persistent per-session workspaces. Add typed Bocha search and Tianditu geocoding bridges using the owning session credential. Require a working kernel sandbox before startup and persist workspace files in deployments. Validation completed before this commit: TypeScript checks, 150 Bun tests, Docker build and UID 10001 sandbox execution, native Shell approval and session recovery. Tianditu succeeded; Bocha currently reports exhausted account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { type ProductCredential } from "./server-auth-client.js";
|
||||
|
||||
export type CredentialRefreshReason =
|
||||
| "access_token_expired"
|
||||
| "access_token_rejected";
|
||||
|
||||
export type CredentialRefreshEvent =
|
||||
| {
|
||||
type: "credential_refresh_required";
|
||||
requestId: string;
|
||||
reason: CredentialRefreshReason;
|
||||
timeoutMs: number;
|
||||
}
|
||||
| {
|
||||
type: "credential_refreshed";
|
||||
requestId: string;
|
||||
}
|
||||
| {
|
||||
type: "credential_refresh_failed";
|
||||
requestId: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type PendingRefresh = {
|
||||
deadlineAt: number;
|
||||
promise: Promise<ProductCredential>;
|
||||
reason: CredentialRefreshReason;
|
||||
reject: (error: Error) => void;
|
||||
requestId: string;
|
||||
resolve: (context: ProductCredential) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type CredentialRefreshListener = (event: CredentialRefreshEvent) => void;
|
||||
|
||||
export class CredentialRefreshError extends Error {
|
||||
override readonly name = "CredentialRefreshError";
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "cancelled" | "failed" | "timeout" | "unavailable" = "failed",
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_EXPIRY_SKEW_MS = 30_000;
|
||||
|
||||
export const isRuntimeCredentialExpired = (
|
||||
context: ProductCredential,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
if (!context.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = Date.parse(context.expiresAt);
|
||||
return Number.isFinite(expiresAt) && now >= expiresAt - AUTH_EXPIRY_SKEW_MS;
|
||||
};
|
||||
|
||||
export class CredentialRefreshCoordinator {
|
||||
private readonly listeners = new Map<
|
||||
string,
|
||||
Set<CredentialRefreshListener>
|
||||
>();
|
||||
private readonly background = new Set<string>();
|
||||
enableBackground(sessionId: string) { this.background.add(sessionId); }
|
||||
disableBackground(sessionId: string) { this.background.delete(sessionId); }
|
||||
notifyFailure(sessionId: string, message: string) {
|
||||
this.emit(sessionId, { type: "credential_refresh_failed", requestId: `credential-${randomUUID()}`, message });
|
||||
}
|
||||
private readonly pending = new Map<string, PendingRefresh>();
|
||||
|
||||
constructor(private readonly timeoutMs = 30_000) {}
|
||||
|
||||
subscribe(sessionId: string, listener: CredentialRefreshListener) {
|
||||
const listeners =
|
||||
this.listeners.get(sessionId) ?? new Set<CredentialRefreshListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(sessionId, listeners);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.listeners.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
request(sessionId: string, reason: CredentialRefreshReason) {
|
||||
const existing = this.pending.get(sessionId);
|
||||
if (existing) {
|
||||
return existing.promise;
|
||||
}
|
||||
if (!this.listeners.get(sessionId)?.size && !this.background.has(sessionId)) {
|
||||
return Promise.reject(
|
||||
new CredentialRefreshError(
|
||||
"credential refresh channel is unavailable",
|
||||
"unavailable",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const requestId = `credential-${randomUUID()}`;
|
||||
let resolvePromise!: (context: ProductCredential) => void;
|
||||
let rejectPromise!: (error: Error) => void;
|
||||
const promise = new Promise<ProductCredential>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
this.fail(sessionId, requestId, "credential refresh timed out", "timeout");
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(sessionId, {
|
||||
deadlineAt: Date.now() + this.timeoutMs,
|
||||
promise,
|
||||
reason,
|
||||
reject: rejectPromise,
|
||||
requestId,
|
||||
resolve: resolvePromise,
|
||||
timer,
|
||||
});
|
||||
this.emit(sessionId, {
|
||||
type: "credential_refresh_required",
|
||||
requestId,
|
||||
reason,
|
||||
timeoutMs: this.timeoutMs,
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
resolve(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
context: ProductCredential,
|
||||
) {
|
||||
const pending = this.pending.get(sessionId);
|
||||
if (!pending || pending.requestId !== requestId) {
|
||||
return false;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(sessionId);
|
||||
pending.resolve(context);
|
||||
this.emit(sessionId, {
|
||||
type: "credential_refreshed",
|
||||
requestId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
message: string,
|
||||
code: CredentialRefreshError["code"] = "failed",
|
||||
emitFailureEvent = true,
|
||||
) {
|
||||
const pending = this.pending.get(sessionId);
|
||||
if (!pending || pending.requestId !== requestId) {
|
||||
return false;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(sessionId);
|
||||
pending.reject(new CredentialRefreshError(message, code));
|
||||
if (emitFailureEvent) {
|
||||
this.emit(sessionId, {
|
||||
type: "credential_refresh_failed",
|
||||
requestId,
|
||||
message,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelSession(sessionId: string, message = "credential refresh cancelled") {
|
||||
const pending = this.pending.get(sessionId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
return this.fail(
|
||||
sessionId,
|
||||
pending.requestId,
|
||||
message,
|
||||
"cancelled",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
getPendingRequestId(sessionId: string) {
|
||||
return this.pending.get(sessionId)?.requestId;
|
||||
}
|
||||
|
||||
getPendingEvent(
|
||||
sessionId: string,
|
||||
): Extract<CredentialRefreshEvent, { type: "credential_refresh_required" }> | null {
|
||||
const pending = this.pending.get(sessionId);
|
||||
if (!pending) return null;
|
||||
return {
|
||||
type: "credential_refresh_required",
|
||||
requestId: pending.requestId,
|
||||
reason: pending.reason,
|
||||
timeoutMs: Math.max(0, pending.deadlineAt - Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
private emit(sessionId: string, event: CredentialRefreshEvent) {
|
||||
for (const listener of this.listeners.get(sessionId) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const runWithCredentialRefresh = async <T extends { status: number }>(
|
||||
coordinator: CredentialRefreshCoordinator,
|
||||
sessionId: string,
|
||||
context: ProductCredential,
|
||||
execute: (context: ProductCredential) => Promise<T>,
|
||||
getCurrent?: () => ProductCredential | undefined,
|
||||
) => {
|
||||
let activeContext = getCurrent?.() ?? context;
|
||||
let refreshed = false;
|
||||
if (isRuntimeCredentialExpired(activeContext)) {
|
||||
activeContext = await coordinator.request(
|
||||
sessionId,
|
||||
"access_token_expired",
|
||||
);
|
||||
refreshed = true;
|
||||
}
|
||||
|
||||
let result = await execute(activeContext);
|
||||
if (result.status !== 401 || refreshed) {
|
||||
return result;
|
||||
}
|
||||
if (result instanceof Response) await result.body?.cancel();
|
||||
const latest = getCurrent?.();
|
||||
activeContext = latest && latest.authorization !== activeContext.authorization
|
||||
? latest : await coordinator.request(sessionId, "access_token_rejected");
|
||||
result = await execute(activeContext);
|
||||
return result;
|
||||
};
|
||||
|
||||
const credentialVault = new Map<string, ProductCredential>();
|
||||
const credentialFailures = new Set<string>();
|
||||
export const credentialRefreshCoordinator = new CredentialRefreshCoordinator(45_000);
|
||||
export const bindSessionCredential = (sessionId: string, credential: ProductCredential | null) => {
|
||||
if (credential) { credentialVault.set(sessionId, { ...credential }); credentialFailures.delete(sessionId); }
|
||||
};
|
||||
export const releaseSessionCredential = (sessionId: string) => {
|
||||
credentialRefreshCoordinator.cancelSession(sessionId);
|
||||
credentialRefreshCoordinator.disableBackground(sessionId);
|
||||
credentialVault.delete(sessionId);
|
||||
credentialFailures.delete(sessionId);
|
||||
};
|
||||
export const refreshSessionCredential = (sessionId: string, credential: ProductCredential, requestId?: string) => {
|
||||
const existing = credentialVault.get(sessionId);
|
||||
if (!existing || existing.projectId !== credential.projectId) return false;
|
||||
const pendingId = credentialRefreshCoordinator.getPendingRequestId(sessionId);
|
||||
if (requestId && requestId !== pendingId) return false;
|
||||
bindSessionCredential(sessionId, credential);
|
||||
if (pendingId) credentialRefreshCoordinator.resolve(sessionId, pendingId, credential);
|
||||
return true;
|
||||
};
|
||||
export const requestWithSessionCredential = async (
|
||||
sessionId: string, execute: (credential: ProductCredential) => Promise<Response>,
|
||||
) => {
|
||||
const credential = credentialVault.get(sessionId);
|
||||
if (!credential) throw Object.assign(new Error("task access requires a current product credential"), { status: 401 });
|
||||
if (credentialFailures.has(sessionId)) throw Object.assign(new CredentialRefreshError("credential renewal failed"), { status: 401 });
|
||||
try {
|
||||
const response = await runWithCredentialRefresh(credentialRefreshCoordinator, sessionId, credential, execute, () => credentialVault.get(sessionId));
|
||||
if (response.status === 401) {
|
||||
credentialFailures.add(sessionId);
|
||||
credentialRefreshCoordinator.notifyFailure(sessionId, "renewed credential was rejected");
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialRefreshError && error.code !== "cancelled") {
|
||||
credentialFailures.add(sessionId);
|
||||
if (error.code === "unavailable") credentialRefreshCoordinator.notifyFailure(sessionId, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
import { config } from "../config.js";
|
||||
import { requestWithSessionCredential } from "./credential-refresh.js";
|
||||
import type { components } from "../generated/server-api.js";
|
||||
|
||||
const domain = z.string().trim().min(1).max(253).regex(/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/);
|
||||
export const webSearchInput = z.object({
|
||||
query: z.string().trim().min(1).max(2000),
|
||||
freshness: z.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]).default("no_limit"),
|
||||
summary: z.boolean().default(true),
|
||||
count: z.number().int().min(1).max(50).default(10),
|
||||
include: z.array(domain).max(20).optional(),
|
||||
exclude: z.array(domain).max(20).optional(),
|
||||
}).strict();
|
||||
export const geocodeInput = z.object({ keyword: z.string().trim().min(1).max(500) }).strict();
|
||||
const freshness = { no_limit: "noLimit", one_day: "oneDay", one_week: "oneWeek", one_month: "oneMonth", one_year: "oneYear" } as const;
|
||||
|
||||
export class ExternalInformationClient {
|
||||
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
|
||||
|
||||
async webSearch(sessionId: string, input: z.infer<typeof webSearchInput>, signal?: AbortSignal) {
|
||||
const parsed = webSearchInput.parse(input);
|
||||
const body = { ...parsed, freshness: freshness[parsed.freshness] } satisfies components["schemas"]["WebSearchRequest"];
|
||||
return this.request(sessionId, "/api/v1/web-searches", body, signal);
|
||||
}
|
||||
|
||||
async geocode(sessionId: string, input: z.infer<typeof geocodeInput>, signal?: AbortSignal) {
|
||||
const body = geocodeInput.parse(input) satisfies components["schemas"]["TiandituGeocodeRequest"];
|
||||
const response = await this.request(sessionId, "/api/v1/geocoding-requests", body, signal);
|
||||
if (!response || typeof response !== "object") throw Object.assign(new Error("geocoding returned no valid coordinate"), { status: 502 });
|
||||
const data = response as Record<string, unknown>;
|
||||
const location = data.location as { lon?: unknown; lat?: unknown } | undefined;
|
||||
const coordinate = (value: unknown) => typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
|
||||
const lon = coordinate(location?.lon);
|
||||
const lat = coordinate(location?.lat);
|
||||
if (String(data.status) !== "0" || !Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lon) > 180 || Math.abs(lat) > 90) {
|
||||
throw Object.assign(new Error("geocoding returned no valid coordinate"), { status: 502 });
|
||||
}
|
||||
return { ...data, location: { ...location, lon, lat }, source_crs: "EPSG:4326", center: [lon, lat] };
|
||||
}
|
||||
|
||||
private async request(sessionId: string, path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
|
||||
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
||||
}));
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = await response.json() as { detail?: unknown };
|
||||
if (typeof data.detail === "string") detail = data.detail;
|
||||
else if (data.detail && typeof data.detail === "object") {
|
||||
const message = (data.detail as { message?: unknown; msg?: unknown }).message ?? (data.detail as { msg?: unknown }).msg;
|
||||
if (typeof message === "string") detail = message;
|
||||
}
|
||||
} catch { /* Preserve the HTTP status if the upstream error has no JSON. */ }
|
||||
throw Object.assign(new Error(`external information query failed (${response.status})${detail ? `: ${detail.slice(0, 500)}` : ""}`), { status: response.status });
|
||||
}
|
||||
try { return await response.json(); }
|
||||
catch { throw Object.assign(new Error("external information returned invalid JSON"), { status: 502 }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const historyResponse = z.object({ series: z.array(z.object({
|
||||
element_id: z.string(), metric: z.string(), source: z.string(), source_unit: z.string(),
|
||||
device_id: z.string().nullable().optional(),
|
||||
points: z.array(z.object({ time: z.string(), value: z.number().finite().nullable() })),
|
||||
})) });
|
||||
export const historyChart = (value: unknown, elementId: string) => {
|
||||
const series = historyResponse.parse(value).series.filter((item) => item.element_id === elementId && item.points.some((point) => point.value !== null));
|
||||
const first = series[0];
|
||||
if (!first) throw new Error("NO_HISTORY_DATA");
|
||||
const compatible = series.filter((item) => item.metric === first.metric && item.source_unit === first.source_unit).slice(0, 8);
|
||||
const points = first.points.filter((point) => point.value !== null).slice(0, 100);
|
||||
const aligned = compatible.map((item) => ({ item, values: new Map(item.points.map((point) => [point.time, point.value])) }))
|
||||
.filter(({ values }) => points.every((point) => typeof values.get(point.time) === "number"));
|
||||
return { title: `${elementId} ${first.metric} (${first.source_unit}) · 最多100点`, chart_type: "line" as const,
|
||||
x_data: points.map((point) => point.time), series: aligned.map(({ item, values }) => ({ name: `${item.device_id ?? elementId} ${item.source}`, data: points.map((point) => values.get(point.time) as number) })) };
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "./credential-refresh.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { config } from "../config.js";
|
||||
@@ -179,7 +180,6 @@ export const taskArtifactBridgeSchema = z.object({
|
||||
{ message: "at least one task artifact is required" },
|
||||
);
|
||||
|
||||
const credentialVault = new Map<string, ProductCredential>();
|
||||
const taskRunStates = new Map<string, { runId: string; revision: number }>();
|
||||
const taskRunMutationQueues = new Map<string, Promise<void>>();
|
||||
|
||||
@@ -187,11 +187,11 @@ export const bindOperationalTaskCredential = (
|
||||
runtimeSessionId: string,
|
||||
credential: ProductCredential | null,
|
||||
) => {
|
||||
if (credential) credentialVault.set(runtimeSessionId, { ...credential });
|
||||
bindSessionCredential(runtimeSessionId, credential);
|
||||
};
|
||||
|
||||
export const releaseOperationalTaskCredential = (runtimeSessionId: string) => {
|
||||
credentialVault.delete(runtimeSessionId);
|
||||
releaseSessionCredential(runtimeSessionId);
|
||||
taskRunStates.delete(runtimeSessionId);
|
||||
taskRunMutationQueues.delete(runtimeSessionId);
|
||||
};
|
||||
@@ -393,25 +393,22 @@ export class OperationalTaskClient {
|
||||
}
|
||||
|
||||
private async request(runtimeSessionId: string, path: string, init: RequestInit) {
|
||||
const credential = credentialVault.get(runtimeSessionId);
|
||||
if (!credential) {
|
||||
throw new OperationalTaskClientError(401, "task access requires a current product credential");
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, this.baseUrl), {
|
||||
...init,
|
||||
signal: init.signal ?? AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: credential.authorization,
|
||||
"Content-Type": "application/json",
|
||||
"X-Project-Id": credential.projectId,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new OperationalTaskClientError(503, "TJWater Server task API unavailable");
|
||||
}
|
||||
const response = await requestWithSessionCredential(runtimeSessionId, async (credential) => {
|
||||
try {
|
||||
return await fetch(new URL(path, this.baseUrl), {
|
||||
...init,
|
||||
signal: init.signal ?? AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: credential.authorization,
|
||||
"Content-Type": "application/json",
|
||||
"X-Project-Id": credential.projectId,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new OperationalTaskClientError(503, "TJWater Server task API unavailable");
|
||||
}
|
||||
});
|
||||
const text = await response.text();
|
||||
const value = parseJson(text);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
import { config } from "../config.js";
|
||||
import { requestWithSessionCredential } from "./credential-refresh.js";
|
||||
import type { components } from "../generated/server-api.js";
|
||||
|
||||
const id = z.string().trim().min(1).max(128);
|
||||
const windowFields = { start: z.string().datetime({ offset: true }), end: z.string().datetime({ offset: true }) };
|
||||
export const projectDataQuerySchema = z.discriminatedUnion("source", [
|
||||
z.object({ source: z.literal("element_properties"), element_type: z.enum(["junction", "pipe", "valve", "pump", "tank", "reservoir"]), element_id: id }).strict(),
|
||||
z.object({ source: z.literal("scada_devices") }).strict(),
|
||||
z.object({ source: z.literal("scada_history"), device_ids: z.array(id).min(1).max(20), field: z.enum(["monitored_value", "cleaned_value"]), ...windowFields }).strict(),
|
||||
z.object({ source: z.literal("element_history"), element_type: z.enum(["junction", "pipe"]), element_id: id, ...windowFields }).strict(),
|
||||
z.object({ source: z.literal("analysis_runs"), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
|
||||
z.object({ source: z.literal("analysis_run"), run_id: z.string().uuid() }).strict(),
|
||||
z.object({ source: z.literal("analysis_results"), run_id: z.string().uuid(), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
|
||||
]).superRefine((query, context) => {
|
||||
if (!("start" in query)) return;
|
||||
const duration = Date.parse(query.end) - Date.parse(query.start);
|
||||
if (duration <= 0 || duration > 24 * 60 * 60 * 1000) context.addIssue({ code: z.ZodIssueCode.custom, message: "time window must be positive and at most 24 hours" });
|
||||
});
|
||||
export type ProjectDataQuery = z.infer<typeof projectDataQuerySchema>;
|
||||
|
||||
export class ProjectDataClient {
|
||||
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
|
||||
|
||||
async query(sessionId: string, input: ProjectDataQuery) {
|
||||
const query = projectDataQuerySchema.parse(input);
|
||||
let path: string;
|
||||
let init: RequestInit = { method: "GET" };
|
||||
switch (query.source) {
|
||||
case "element_properties":
|
||||
path = `/api/v1/${query.element_type}s/properties?${new URLSearchParams({ [query.element_type]: query.element_id })}`;
|
||||
break;
|
||||
case "scada_devices": path = "/api/v1/scada-devices"; break;
|
||||
case "scada_history":
|
||||
path = `/api/v1/timeseries/scada-readings/fields?${new URLSearchParams({ device_ids: query.device_ids.join(","), field: query.field, start_time: query.start, end_time: query.end })}`;
|
||||
break;
|
||||
case "element_history": {
|
||||
const body = { start_time: query.start, end_time: query.end, mode: "observed", elements: [{ element_type: query.element_type, element_id: query.element_id }] } satisfies components["schemas"]["ElementHistoryQuery"];
|
||||
path = "/api/v1/timeseries/views/element-history/query";
|
||||
init = { method: "POST", body: JSON.stringify(body) };
|
||||
break;
|
||||
}
|
||||
case "analysis_runs": path = `/api/v1/analysis/runs?limit=${query.limit}&offset=${query.offset}`; break;
|
||||
case "analysis_run": path = `/api/v1/analysis/runs/${query.run_id}`; break;
|
||||
case "analysis_results": path = `/api/v1/analysis/runs/${query.run_id}/results?limit=${query.limit}&offset=${query.offset}`; break;
|
||||
}
|
||||
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
|
||||
}));
|
||||
if (!response.ok) throw Object.assign(new Error(`project data query rejected (${response.status})`), { status: response.status });
|
||||
return response.json() as Promise<unknown>;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { components } from "../generated/server-api.js";
|
||||
export type ProductCredential = {
|
||||
authorization: string;
|
||||
projectId: string;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export type AgentAuthContext = components["schemas"]["AgentAuthContextResponse"];
|
||||
|
||||
Reference in New Issue
Block a user