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; export type TaskReport = z.infer; export type OperationalTaskDataQuery = z.infer; 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; export type OperationalTaskContext = Record; type TaskArtifactInput = { requestId: string; sourceSessionId: string; evidence?: TaskEvidence[]; executionPlan?: Record; 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(); const taskRunMutationQueues = new Map>(); 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 = {}, ) { 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(runtimeSessionId: string, mutation: () => Promise): Promise { 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; const detail = record.detail; const nested = detail && typeof detail === "object" ? detail as Record : 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) => { 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 : 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) => { 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; 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; 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 } : {}), }; }), }; };