feat(agent): add controlled task execution
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
type HeaderReader = {
|
||||
header(name: string): string | undefined;
|
||||
};
|
||||
|
||||
export const isTrustedInternalBridgeRequest = (
|
||||
req: HeaderReader,
|
||||
expectedToken: string,
|
||||
) => {
|
||||
if (!expectedToken || req.header("x-agent-internal-token") !== expectedToken) {
|
||||
return false;
|
||||
}
|
||||
if (req.header("origin") || req.header("sec-fetch-site")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,502 @@
|
||||
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),
|
||||
});
|
||||
|
||||
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 taskReportSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
current_conclusion: z.string().trim().min(1).max(4000),
|
||||
conclusion_boundary: z.string().trim().min(1).max(2000),
|
||||
evidence_refs: z.array(z.string().trim().min(1).max(128)).max(100).default([]),
|
||||
recommendations: z.array(z.string().trim().min(1).max(1000)).max(30).default([]),
|
||||
pending_items: z.array(z.string().trim().min(1).max(500)).max(20).default([]),
|
||||
});
|
||||
|
||||
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 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 credentialVault = new Map<string, ProductCredential>();
|
||||
const taskRunStates = new Map<string, { runId: string; revision: number }>();
|
||||
const taskRunMutationQueues = new Map<string, Promise<void>>();
|
||||
|
||||
export const bindOperationalTaskCredential = (
|
||||
runtimeSessionId: string,
|
||||
credential: ProductCredential | null,
|
||||
) => {
|
||||
if (credential) credentialVault.set(runtimeSessionId, { ...credential });
|
||||
};
|
||||
|
||||
export const releaseOperationalTaskCredential = (runtimeSessionId: string) => {
|
||||
credentialVault.delete(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) {
|
||||
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/agent-context`, {
|
||||
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 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 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 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: "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 : [],
|
||||
};
|
||||
}),
|
||||
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 } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const jsonValueSchema: z.ZodType<unknown> = z.lazy(() => z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(jsonValueSchema),
|
||||
z.record(jsonValueSchema),
|
||||
]));
|
||||
|
||||
export const createWorkOrderPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
description: z.string().trim().min(1),
|
||||
work_order_type: z.string().trim().min(1),
|
||||
priority: z.enum(["normal", "urgent"]).default("normal"),
|
||||
scheduled_at: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
location: z.string().trim().min(1).nullable().optional(),
|
||||
assignee: z.string().trim().min(1).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export const issueCommandPayloadSchema = z.object({
|
||||
command_type: z.string().trim().min(1),
|
||||
target: z.object({
|
||||
kind: z.string().trim().min(1),
|
||||
id: z.string().trim().min(1),
|
||||
}).strict(),
|
||||
parameters: z.record(jsonValueSchema),
|
||||
impact_summary: z.string().trim().min(1),
|
||||
preconditions: z.array(z.string().trim().min(1)).min(1),
|
||||
expires_at: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
|
||||
const proposalFields = {
|
||||
request_id: z.string().uuid(),
|
||||
reason: z.string().trim().min(1).max(2000),
|
||||
evidence_refs: z.array(z.string().trim().min(1).max(128)).max(30).default([]),
|
||||
node_id: z.string().trim().min(1).max(64).optional(),
|
||||
};
|
||||
|
||||
export const proposedTaskActionSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
...proposalFields,
|
||||
kind: z.literal("create_work_order"),
|
||||
payload: createWorkOrderPayloadSchema,
|
||||
}).strict(),
|
||||
z.object({
|
||||
...proposalFields,
|
||||
kind: z.literal("issue_command"),
|
||||
payload: issueCommandPayloadSchema,
|
||||
}).strict(),
|
||||
]);
|
||||
|
||||
export type ProposedTaskAction = z.infer<typeof proposedTaskActionSchema>;
|
||||
Reference in New Issue
Block a user