Files
next-tjwater-agent/src/product/operational-task-client.ts
T

523 lines
20 KiB
TypeScript

import { bindSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "./credential-refresh.js";
import { taskReportSchema } from "./investigation-report.js";
export { taskReportSchema } from "./investigation-report.js";
import { z } from "zod";
import { config } from "../config.js";
import {
proposedTaskActionSchema,
type ProposedTaskAction,
} from "./operational-task-contract.js";
import type { ProductCredential } from "./server-auth-client.js";
export { proposedTaskActionSchema } from "./operational-task-contract.js";
const jsonRecordSchema = z.record(z.unknown());
const taskDataSourceSchema = z.enum([
"workbench_runtime",
"scada_devices",
"scada_readings",
"peripheral_points",
"peripheral_work_orders",
"peripheral_observations",
"peripheral_test_data",
]);
const operationalTaskRunSchema = z.object({
run_id: z.string().uuid(),
task_id: z.string().uuid(),
generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]),
revision: z.number().int().positive(),
agent_session_id: z.string().min(1).nullable(),
lease_expires_at: z.string().datetime({ offset: true }).nullable(),
heartbeat_at: z.string().datetime({ offset: true }).nullable(),
started_at: z.string().datetime({ offset: true }).nullable(),
finished_at: z.string().datetime({ offset: true }).nullable(),
failure_code: z.string().min(1).nullable(),
failure_detail: z.string().min(1).nullable(),
dispatch_attempts: z.number().int().nonnegative(),
next_dispatch_at: z.string().datetime({ offset: true }),
created_at: z.string().datetime({ offset: true }),
updated_at: z.string().datetime({ offset: true }),
}).strict();
const operationalTaskSummarySchema = z.object({
task_id: z.string().uuid(),
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
workflow: z.string().min(1),
lifecycle_status: z.enum(["open", "waiting_human", "completed", "failed", "cancelled"]),
active_run: operationalTaskRunSchema.nullable(),
status: z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]),
revision: z.number().int().positive(),
execution_request_id: z.string().uuid(),
title: z.string().min(1),
summary: z.string().min(1),
}).passthrough();
const agentTaskContextSchema = z.object({
task: operationalTaskSummarySchema.extend({
latest_plan: z.unknown().nullable(),
latest_report: z.unknown().nullable(),
evidence: z.array(z.unknown()),
actions: z.array(z.unknown()),
permissions: z.record(z.boolean()),
}).passthrough(),
approved_data_scopes: z.array(taskDataSourceSchema),
selected_report: z.object({
revision: z.number().int().positive(), execution_generation: z.number().int().positive().nullish(),
report: z.unknown(),
}).passthrough().nullish(),
});
const receivedEvidenceSchema = z.object({
evidence_id: z.string().min(1),
kind: z.enum(["system_data", "external_feedback", "human_input", "synthetic"]),
summary: z.string().min(1),
source_label: z.string().min(1),
source_ref: z.string().min(1).nullable().optional(),
observed_at: z.string().datetime({ offset: true }),
}).passthrough();
const taskDataSnapshotSchema = z.object({
source: taskDataSourceSchema,
source_ref: z.string().min(1),
queried_at: z.string().datetime({ offset: true }),
origin: z.enum(["system_data", "synthetic"]),
data: z.unknown(),
truncated: z.boolean(),
});
const taskDataQueryResultSchema = z.object({
evidence: receivedEvidenceSchema,
snapshot: taskDataSnapshotSchema,
});
const taskArtifactsResultSchema = z.object({
task: operationalTaskSummarySchema,
plan_revision: z.number().int().positive().nullable(),
report_revision: z.number().int().positive().nullable(),
});
export const taskEvidenceSchema = z.object({
evidence_id: z.string().trim().min(1).max(128),
kind: z.literal("synthetic"),
summary: z.string().trim().min(1).max(4000),
source_label: z.string().trim().min(1).max(500),
source_ref: z.string().trim().max(500).optional(),
observed_at: z.string().datetime({ offset: true }),
});
export const operationalTaskDataQuerySchema = z.object({
source: taskDataSourceSchema,
device_ids: z.array(z.string().trim().min(1).max(160)).max(20).optional(),
point_id: z.string().trim().min(1).max(160).optional(),
start: z.string().datetime({ offset: true }).optional(),
end: z.string().datetime({ offset: true }).optional(),
}).superRefine((query, context) => {
const isSeries = query.source === "scada_readings" || query.source === "peripheral_observations";
if (isSeries && (!query.start || !query.end)) {
context.addIssue({ code: "custom", message: "time series queries require start and end" });
}
if (query.source === "scada_readings" && !query.device_ids?.length) {
context.addIssue({ code: "custom", message: "SCADA readings require device_ids" });
}
if (query.source === "peripheral_observations" && !query.point_id) {
context.addIssue({ code: "custom", message: "peripheral observations require point_id" });
}
if (query.start && query.end) {
const duration = Date.parse(query.end) - Date.parse(query.start);
if (duration <= 0 || duration > 86_400_000) {
context.addIssue({ code: "custom", message: "query window must be positive and at most 24 hours" });
}
}
});
export const humanTaskEvidenceCommandSchema = z.object({
request_id: z.string().uuid(),
evidence: z.object({
evidence_id: z.string().trim().min(1).max(128),
kind: z.literal("human_input"),
summary: z.string().trim().min(1).max(4000),
source_label: z.string().trim().min(1).max(500),
source_ref: z.string().trim().min(1).max(500).optional(),
observed_at: z.string().datetime({ offset: true }),
}),
});
export type TaskEvidence = z.infer<typeof taskEvidenceSchema>;
export type TaskReport = z.infer<typeof taskReportSchema>;
export type OperationalTaskDataQuery = z.infer<typeof operationalTaskDataQuerySchema>;
export const expressAccessRequestSchema = z.object({
requestId: z.string().uuid(),
nodeId: z.string().min(1),
scopes: z.array(taskDataSourceSchema).min(1).max(7),
reason: z.string().min(1),
});
export type HumanTaskEvidenceCommand = z.infer<typeof humanTaskEvidenceCommandSchema>;
export type OperationalTaskContext = Record<string, unknown>;
type TaskArtifactInput = {
requestId: string;
sourceSessionId: string;
evidence?: TaskEvidence[];
executionPlan?: Record<string, unknown>;
report?: TaskReport;
};
export const taskArtifactBridgeSchema = z.object({
request_id: z.string().uuid(),
evidence: z.array(taskEvidenceSchema).max(40).optional(),
execution_plan: jsonRecordSchema.optional(),
report: taskReportSchema.optional(),
}).refine(
(value) => value.evidence !== undefined || value.execution_plan !== undefined || value.report !== undefined,
{ message: "at least one task artifact is required" },
);
const taskRunStates = new Map<string, { runId: string; revision: number }>();
const taskRunMutationQueues = new Map<string, Promise<void>>();
export const bindOperationalTaskCredential = (
runtimeSessionId: string,
credential: ProductCredential | null,
) => {
bindSessionCredential(runtimeSessionId, credential);
};
export const releaseOperationalTaskCredential = (runtimeSessionId: string) => {
releaseSessionCredential(runtimeSessionId);
taskRunStates.delete(runtimeSessionId);
taskRunMutationQueues.delete(runtimeSessionId);
};
export const clearOperationalTaskLease = (runtimeSessionId: string) => {
taskRunStates.delete(runtimeSessionId);
};
export class OperationalTaskClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async getAgentContext(runtimeSessionId: string, taskId: string, reportRevision?: number) {
const query = reportRevision == null ? "" : `?report_revision=${reportRevision}`;
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/agent-context${query}`, {
method: "GET",
});
const result = agentTaskContextSchema.parse(raw);
rememberLeaseRevision(runtimeSessionId, result);
return result;
}
async claimTask(
runtimeSessionId: string,
taskId: string,
runId: string,
input: { requestId: string; expectedRevision: number; sourceSessionId: string },
) {
return this.mutateRun(runtimeSessionId, async () => {
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/claim`, {
method: "POST",
body: JSON.stringify({
request_id: input.requestId,
expected_revision: input.expectedRevision,
source_session_id: input.sourceSessionId,
}),
});
const result = operationalTaskRunSchema.parse(raw);
rememberLeaseRevision(runtimeSessionId, result);
return result;
});
}
async heartbeatTask(
runtimeSessionId: string,
taskId: string,
runId: string,
sourceSessionId: string,
) {
return this.mutateRun(runtimeSessionId, async () => {
const state = taskRunStates.get(runtimeSessionId);
if (!state || state.runId !== runId) throw new OperationalTaskClientError(409, "task run lease revision unavailable");
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/heartbeat`, {
method: "POST",
body: JSON.stringify({
request_id: crypto.randomUUID(),
expected_revision: state.revision,
source_session_id: sourceSessionId,
}),
});
const result = operationalTaskRunSchema.parse(raw);
rememberLeaseRevision(runtimeSessionId, result);
return result;
});
}
async publishArtifacts(runtimeSessionId: string, taskId: string, input: TaskArtifactInput) {
return this.mutateRun(runtimeSessionId, async () => {
const state = taskRunStates.get(runtimeSessionId);
if (!state) throw new OperationalTaskClientError(409, "task run context unavailable");
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/agent-artifacts`, {
method: "POST",
body: JSON.stringify({
request_id: input.requestId,
source_session_id: input.sourceSessionId,
run_id: state.runId,
...(input.evidence ? { evidence: input.evidence } : {}),
...(input.executionPlan ? { plan: toServerPlan(input.executionPlan) } : {}),
...(input.report ? { report: input.report } : {}),
}),
});
const result = taskArtifactsResultSchema.parse(raw);
rememberLeaseRevision(runtimeSessionId, result);
return result;
});
}
async completeTaskRun(runtimeSessionId: string, taskId: string, runId: string, sourceSessionId: string) {
return this.finishTaskRun(runtimeSessionId, taskId, runId, "complete", sourceSessionId);
}
async failTaskRun(
runtimeSessionId: string,
taskId: string,
runId: string,
sourceSessionId: string,
failureCode: string,
failureDetail: string,
) {
return this.finishTaskRun(runtimeSessionId, taskId, runId, "fail", sourceSessionId, {
failure_code: failureCode,
failure_detail: failureDetail,
});
}
private async finishTaskRun(
runtimeSessionId: string,
taskId: string,
runId: string,
action: "complete" | "fail",
sourceSessionId: string,
extra: Record<string, string> = {},
) {
return this.mutateRun(runtimeSessionId, async () => {
const state = taskRunStates.get(runtimeSessionId);
if (!state || state.runId !== runId) throw new OperationalTaskClientError(409, "task run lease revision unavailable");
const raw = await this.request(
runtimeSessionId,
`/api/v1/operational-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/${action}`,
{
method: "POST",
body: JSON.stringify({
request_id: crypto.randomUUID(),
expected_revision: state.revision,
source_session_id: sourceSessionId,
...extra,
}),
},
);
const result = operationalTaskSummarySchema.parse(raw);
rememberLeaseRevision(runtimeSessionId, result);
return result;
});
}
private async mutateRun<T>(runtimeSessionId: string, mutation: () => Promise<T>): Promise<T> {
const previous = taskRunMutationQueues.get(runtimeSessionId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(mutation);
const tail = result.then(() => undefined, () => undefined);
taskRunMutationQueues.set(runtimeSessionId, tail);
try {
return await result;
} finally {
if (taskRunMutationQueues.get(runtimeSessionId) === tail) {
taskRunMutationQueues.delete(runtimeSessionId);
}
}
}
async queryData(runtimeSessionId: string, taskId: string, query: OperationalTaskDataQuery) {
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/data-query`, {
method: "POST",
body: JSON.stringify(operationalTaskDataQuerySchema.parse(query)),
});
const combined = taskDataQueryResultSchema.safeParse(raw);
if (combined.success) {
return combined.data;
}
const evidence = receivedEvidenceSchema.parse(raw);
if (typeof evidence.evidence_id !== "string" || !evidence.evidence_id.trim()) {
throw new OperationalTaskClientError(502, "task data query returned no evidence id");
}
const snapshot = taskDataSnapshotSchema.parse(await this.request(
runtimeSessionId,
`/api/v1/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidence.evidence_id)}/snapshot`,
{ method: "GET" },
));
return { evidence, snapshot };
}
async requestDataAccess(runtimeSessionId: string, taskId: string, input: {
requestId: string; sourceSessionId: string; nodeId: string; scopes: OperationalTaskDataQuery["source"][]; reason: string;
}) {
return this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/access-requests`, {
method: "POST",
body: JSON.stringify({
request_id: input.requestId, source_session_id: input.sourceSessionId,
node_id: input.nodeId, scopes: input.scopes, reason: input.reason,
}),
});
}
async proposeAction(runtimeSessionId: string, taskId: string, input: ProposedTaskAction) {
const result = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/actions`, {
method: "POST",
body: JSON.stringify(proposedTaskActionSchema.parse(input)),
});
return z.object({
action_id: z.string().uuid(),
approval_status: z.literal("pending"),
execution_status: z.enum(["not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"]),
}).passthrough().parse(result);
}
async addHumanEvidence(runtimeSessionId: string, taskId: string, input: HumanTaskEvidenceCommand) {
const result = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/human-evidence`, {
method: "POST",
body: JSON.stringify(humanTaskEvidenceCommandSchema.parse(input)),
});
return operationalTaskSummarySchema.parse(result);
}
private async request(runtimeSessionId: string, path: string, init: RequestInit) {
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) {
const problem = readProblem(value);
throw new OperationalTaskClientError(response.status, problem.message, problem.code);
}
return jsonRecordSchema.parse(value);
}
}
export class OperationalTaskClientError extends Error {
constructor(
readonly status: number,
message: string,
readonly code?: string,
) {
super(message);
}
}
const parseJson = (text: string): unknown => {
if (!text.trim()) return {};
try {
return JSON.parse(text);
} catch {
throw new OperationalTaskClientError(502, "TJWater Server task API returned invalid JSON");
}
};
const readProblem = (value: unknown): { code?: string; message: string } => {
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const detail = record.detail;
const nested = detail && typeof detail === "object"
? detail as Record<string, unknown>
: null;
const code = typeof nested?.code === "string"
? nested.code
: typeof record.code === "string"
? record.code
: undefined;
const message = typeof nested?.message === "string"
? nested.message
: typeof detail === "string"
? detail
: typeof record.message === "string"
? record.message
: "TJWater Server rejected task request";
return { code, message };
}
return { message: "TJWater Server rejected task request" };
};
const rememberLeaseRevision = (runtimeSessionId: string, value: Record<string, unknown>) => {
const directRun = operationalTaskRunSchema.safeParse(value);
if (directRun.success) {
taskRunStates.set(runtimeSessionId, {
runId: directRun.data.run_id,
revision: directRun.data.revision,
});
return;
}
const task = value.task && typeof value.task === "object"
? value.task as Record<string, unknown>
: value;
const activeRun = task.active_run;
const parsed = operationalTaskRunSchema.safeParse(activeRun);
if (parsed.success) {
taskRunStates.set(runtimeSessionId, {
runId: parsed.data.run_id,
revision: parsed.data.revision,
});
}
};
export const operationalTaskClient = new OperationalTaskClient();
const toServerPlan = (input: Record<string, unknown>) => {
const nodes = Array.isArray(input.nodes) ? input.nodes : [];
const edges = Array.isArray(input.edges) ? input.edges : [];
return {
schema: input.schema === "execution-plan@2" ? "execution-plan@2" : "execution-plan@1",
title: input.title,
reason: input.reason,
nodes: nodes.map((value) => {
const node = value as Record<string, unknown>;
return {
id: node.id,
title: node.title,
owner: node.owner,
status: node.status,
detail: node.detail,
evidence_refs: Array.isArray(node.evidence) ? node.evidence : [],
action_refs: Array.isArray(node.action_refs) ? node.action_refs : [],
...(input.schema === "execution-plan@2" ? {
kind: node.kind,
selected_edge_id: node.selected_edge_id ?? null,
gate_ref: node.gate_ref ?? null,
revisits_node_id: node.revisits_node_id ?? null,
} : {}),
};
}),
edges: edges.map((value) => {
const edge = value as Record<string, unknown>;
return {
from: edge.from,
to: edge.to,
...(typeof edge.label === "string" && edge.label.trim() ? { condition: edge.label } : {}),
...(input.schema === "execution-plan@2" ? { id: edge.id } : {}),
};
}),
};
};