feat: add v0.1 runtime skeleton
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { FailureKind, JsonRecord } from "./types.js";
|
||||
|
||||
export class AgentRunError extends Error {
|
||||
readonly failureKind: FailureKind;
|
||||
readonly details: JsonRecord | null;
|
||||
readonly httpStatus: number;
|
||||
|
||||
constructor(failureKind: FailureKind, message: string, options: { httpStatus?: number; details?: JsonRecord } = {}) {
|
||||
super(message);
|
||||
this.name = "AgentRunError";
|
||||
this.failureKind = failureKind;
|
||||
this.httpStatus = options.httpStatus ?? 400;
|
||||
this.details = options.details ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function errorToJson(error: unknown): JsonRecord {
|
||||
if (error instanceof AgentRunError) {
|
||||
return {
|
||||
name: error.name,
|
||||
failureKind: error.failureKind,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? null };
|
||||
return { name: "UnknownError", message: String(error) };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function redactText(value: string): string {
|
||||
return value
|
||||
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[A-Za-z0-9._~+/=-]+/giu, "$1$2REDACTED")
|
||||
.replace(/((?:api[_-]?key|token|password|secret)\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/giu, "$1REDACTED")
|
||||
.replace(/(postgres(?:ql)?:\/\/[^:\s/@]+:)[^@\s]+(@)/giu, "$1REDACTED$2")
|
||||
.replace(/(https?:\/\/[^:\s/@]+:)[^@\s]+(@)/giu, "$1REDACTED$2");
|
||||
}
|
||||
|
||||
export function redactJson<T>(value: T): T {
|
||||
if (typeof value === "string") return redactText(value) as T;
|
||||
if (Array.isArray(value)) return value.map((item) => redactJson(item)) as T;
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (/auth|authorization|api[_-]?key|token|password|secret|credential|dsn/iu.test(key)) {
|
||||
result[key] = typeof entry === "boolean" ? entry : "REDACTED";
|
||||
continue;
|
||||
}
|
||||
result[key] = redactJson(entry);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonRecord = { [key: string]: JsonValue };
|
||||
|
||||
export type FailureKind =
|
||||
| "schema-invalid"
|
||||
| "tenant-policy-denied"
|
||||
| "secret-unavailable"
|
||||
| "runner-lease-conflict"
|
||||
| "backend-failed"
|
||||
| "backend-protocol-error"
|
||||
| "backend-timeout"
|
||||
| "provider-auth-failed"
|
||||
| "provider-rate-limited"
|
||||
| "infra-failed"
|
||||
| "cancelled";
|
||||
|
||||
export type RunStatus = "pending" | "claimed" | "running" | "completed" | "failed" | "blocked" | "cancelled";
|
||||
export type CommandState = "pending" | "acknowledged" | "completed" | "failed" | "cancelled";
|
||||
export type TerminalStatus = "completed" | "failed" | "blocked" | "cancelled";
|
||||
export type BackendProfile = "codex";
|
||||
|
||||
export interface WorkspaceRef extends JsonRecord {
|
||||
kind: "git-worktree" | "host-path" | "kubernetes-pvc" | "opaque";
|
||||
path?: string;
|
||||
repo?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface SecretRef extends JsonRecord {
|
||||
namespace?: string;
|
||||
name: string;
|
||||
keys?: string[];
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionPolicy extends JsonRecord {
|
||||
sandbox: string;
|
||||
approval: string;
|
||||
timeoutMs: number;
|
||||
network: string;
|
||||
secretScope: {
|
||||
providerCredentials?: Array<{
|
||||
profile: BackendProfile | string;
|
||||
secretRef: SecretRef;
|
||||
}>;
|
||||
allowCredentialEcho?: false;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateRunInput extends JsonRecord {
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
workspaceRef: WorkspaceRef;
|
||||
providerId: string;
|
||||
backendProfile: BackendProfile;
|
||||
executionPolicy: ExecutionPolicy;
|
||||
traceSink: JsonValue;
|
||||
}
|
||||
|
||||
export interface RunRecord extends CreateRunInput {
|
||||
id: string;
|
||||
status: RunStatus;
|
||||
terminalStatus: TerminalStatus | null;
|
||||
failureKind: FailureKind | null;
|
||||
failureMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
claimedBy: string | null;
|
||||
leaseExpiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCommandInput extends JsonRecord {
|
||||
type: "turn" | "interrupt";
|
||||
payload: JsonRecord;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface CommandRecord extends CreateCommandInput {
|
||||
id: string;
|
||||
runId: string;
|
||||
seq: number;
|
||||
state: CommandState;
|
||||
payloadHash: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
acknowledgedAt: string | null;
|
||||
}
|
||||
|
||||
export type EventType = "backend_status" | "assistant_message" | "tool_call" | "command_output" | "diff" | "error" | "terminal_status";
|
||||
|
||||
export interface RunEvent extends JsonRecord {
|
||||
id: string;
|
||||
runId: string;
|
||||
seq: number;
|
||||
type: EventType;
|
||||
payload: JsonRecord;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RunnerRecord extends JsonRecord {
|
||||
id: string;
|
||||
runId?: string;
|
||||
attemptId?: string;
|
||||
backendProfile?: BackendProfile;
|
||||
placement?: string;
|
||||
sourceCommit?: string;
|
||||
registeredAt: string;
|
||||
heartbeatAt: string;
|
||||
}
|
||||
|
||||
export interface BackendEvent {
|
||||
type: EventType;
|
||||
payload: JsonRecord;
|
||||
}
|
||||
|
||||
export interface BackendTurnResult {
|
||||
terminalStatus: TerminalStatus;
|
||||
failureKind: FailureKind | null;
|
||||
failureMessage: string | null;
|
||||
events: BackendEvent[];
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody extends JsonRecord {
|
||||
ok: false;
|
||||
failureKind: FailureKind;
|
||||
message: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface ApiOkBody<T extends JsonValue = JsonValue> extends JsonRecord {
|
||||
ok: true;
|
||||
data: T;
|
||||
traceId: string;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { BackendProfile, CreateCommandInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue } from "./types.js";
|
||||
import { AgentRunError } from "./errors.js";
|
||||
|
||||
const allowedTenants = new Set(["unidesk", "hwlab"]);
|
||||
const allowedBackends = new Set<BackendProfile>(["codex"]);
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function newId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/gu, "")}`;
|
||||
}
|
||||
|
||||
export function stableHash(value: JsonValue): string {
|
||||
return createHash("sha256").update(JSON.stringify(sortJson(value))).digest("hex");
|
||||
}
|
||||
|
||||
function sortJson(value: JsonValue): JsonValue {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, sortJson(entry)]));
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown, fieldName: string): JsonRecord {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
|
||||
throw new AgentRunError("schema-invalid", `${fieldName} must be an object`, { httpStatus: 400 });
|
||||
}
|
||||
|
||||
function requiredString(record: JsonRecord, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredRecord(record: JsonRecord, key: string): JsonRecord {
|
||||
return asRecord(record[key], key);
|
||||
}
|
||||
|
||||
export function validateCreateRun(input: unknown): CreateRunInput {
|
||||
const record = asRecord(input, "run");
|
||||
const tenantId = requiredString(record, "tenantId");
|
||||
if (!allowedTenants.has(tenantId)) throw new AgentRunError("tenant-policy-denied", `tenantId ${tenantId} is not allowed`, { httpStatus: 403 });
|
||||
const backendProfile = requiredString(record, "backendProfile") as BackendProfile;
|
||||
if (!allowedBackends.has(backendProfile)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfile} is not supported in v0.1`, { httpStatus: 400 });
|
||||
const executionPolicy = validateExecutionPolicy(requiredRecord(record, "executionPolicy"));
|
||||
return {
|
||||
tenantId,
|
||||
projectId: requiredString(record, "projectId"),
|
||||
workspaceRef: requiredRecord(record, "workspaceRef") as CreateRunInput["workspaceRef"],
|
||||
providerId: requiredString(record, "providerId"),
|
||||
backendProfile,
|
||||
executionPolicy,
|
||||
traceSink: record.traceSink ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy {
|
||||
const timeout = record.timeoutMs;
|
||||
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) throw new AgentRunError("schema-invalid", "executionPolicy.timeoutMs must be a positive number", { httpStatus: 400 });
|
||||
const secretScope = asRecord(record.secretScope ?? {}, "executionPolicy.secretScope");
|
||||
if (secretScope.allowCredentialEcho !== undefined && secretScope.allowCredentialEcho !== false) throw new AgentRunError("tenant-policy-denied", "allowCredentialEcho must be false", { httpStatus: 403 });
|
||||
const providerCredentials = Array.isArray(secretScope.providerCredentials) ? secretScope.providerCredentials : [];
|
||||
for (const credential of providerCredentials) {
|
||||
const item = asRecord(credential, "providerCredential");
|
||||
const secretRef = asRecord(item.secretRef, "providerCredential.secretRef");
|
||||
if (typeof secretRef.name !== "string" || secretRef.name.length === 0) throw new AgentRunError("schema-invalid", "provider credential secretRef.name is required", { httpStatus: 400 });
|
||||
}
|
||||
const secretScopeResult: ExecutionPolicy["secretScope"] = { allowCredentialEcho: false };
|
||||
if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable<ExecutionPolicy["secretScope"]["providerCredentials"]>;
|
||||
return {
|
||||
sandbox: requiredString(record, "sandbox"),
|
||||
approval: requiredString(record, "approval"),
|
||||
timeoutMs: timeout,
|
||||
network: requiredString(record, "network"),
|
||||
secretScope: secretScopeResult,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCreateCommand(input: unknown): CreateCommandInput {
|
||||
const record = asRecord(input, "command");
|
||||
const type = requiredString(record, "type");
|
||||
if (type !== "turn" && type !== "interrupt") throw new AgentRunError("schema-invalid", `command type ${type} is not supported`, { httpStatus: 400 });
|
||||
const payload = asRecord(record.payload ?? {}, "payload");
|
||||
const idempotencyKey = typeof record.idempotencyKey === "string" && record.idempotencyKey.trim().length > 0 ? record.idempotencyKey.trim() : undefined;
|
||||
return { type, payload, ...(idempotencyKey ? { idempotencyKey } : {}) };
|
||||
}
|
||||
Reference in New Issue
Block a user