feat: 补齐 HWLAB 手动调度能力

This commit is contained in:
Codex
2026-06-01 11:40:08 +08:00
parent eb5e0f57a6
commit 62846f6369
25 changed files with 1159 additions and 70 deletions
+55
View File
@@ -31,6 +31,14 @@ export interface WorkspaceRef extends JsonRecord {
branch?: string;
}
export interface SessionRef extends JsonRecord {
sessionId: string;
conversationId?: string;
threadId?: string;
expiresAt?: string;
metadata?: JsonRecord;
}
export interface SecretRef extends JsonRecord {
namespace?: string;
name: string;
@@ -38,6 +46,17 @@ export interface SecretRef extends JsonRecord {
mountPath?: string;
}
export interface ResourceBundleRef extends JsonRecord {
kind: "git";
repoUrl: string;
commitId: string;
subdir?: string;
sparsePaths?: string[];
submodules?: false;
lfs?: false;
credentialRef?: SecretRef;
}
export interface ExecutionPolicy extends JsonRecord {
sandbox: string;
approval: string;
@@ -56,6 +75,8 @@ export interface CreateRunInput extends JsonRecord {
tenantId: string;
projectId: string;
workspaceRef: WorkspaceRef;
sessionRef?: SessionRef | null;
resourceBundleRef?: ResourceBundleRef | null;
providerId: string;
backendProfile: BackendProfile;
executionPolicy: ExecutionPolicy;
@@ -64,6 +85,8 @@ export interface CreateRunInput extends JsonRecord {
export interface RunRecord extends CreateRunInput {
id: string;
sessionRef: SessionRef | null;
resourceBundleRef: ResourceBundleRef | null;
status: RunStatus;
terminalStatus: TerminalStatus | null;
failureKind: FailureKind | null;
@@ -113,6 +136,38 @@ export interface RunnerRecord extends JsonRecord {
heartbeatAt: string;
}
export interface SessionRecord extends JsonRecord {
sessionId: string;
tenantId: string;
projectId: string;
backendProfile: BackendProfile;
conversationId: string | null;
threadId: string | null;
metadata: JsonRecord;
createdAt: string;
updatedAt: string;
expiresAt: string | null;
}
export interface RunnerJobRecord extends JsonRecord {
id: string;
runId: string;
commandId: string;
idempotencyKey: string | null;
payloadHash: string;
attemptId: string;
runnerId: string;
namespace: string;
jobName: string;
managerUrl: string;
image: string;
sourceCommit: string;
serviceAccountName: string | null;
result: JsonRecord;
createdAt: string;
updatedAt: string;
}
export interface BackendEvent {
type: EventType;
payload: JsonRecord;
+65 -1
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import type { BackendProfile, CreateCommandInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue } from "./types.js";
import type { BackendProfile, CreateCommandInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue, ResourceBundleRef, SecretRef, SessionRef } from "./types.js";
import { AgentRunError } from "./errors.js";
import { backendProfileSpec, backendProfiles, isBackendProfile } from "./backend-profiles.js";
@@ -51,6 +51,8 @@ export function validateCreateRun(input: unknown): CreateRunInput {
tenantId,
projectId: requiredString(record, "projectId"),
workspaceRef: requiredRecord(record, "workspaceRef") as CreateRunInput["workspaceRef"],
sessionRef: validateSessionRef(record.sessionRef),
resourceBundleRef: validateResourceBundleRef(record.resourceBundleRef),
providerId: requiredString(record, "providerId"),
backendProfile,
executionPolicy,
@@ -58,6 +60,50 @@ export function validateCreateRun(input: unknown): CreateRunInput {
};
}
export function validateSessionRef(value: unknown): SessionRef | null {
if (value === undefined || value === null) return null;
const record = asRecord(value, "sessionRef");
const sessionId = requiredString(record, "sessionId");
const result: SessionRef = { sessionId };
const conversationId = optionalString(record.conversationId);
const threadId = optionalString(record.threadId);
const expiresAt = optionalString(record.expiresAt);
const metadata = record.metadata === undefined ? undefined : asRecord(record.metadata, "sessionRef.metadata");
if (conversationId) result.conversationId = conversationId;
if (threadId) result.threadId = threadId;
if (expiresAt) result.expiresAt = expiresAt;
if (metadata) result.metadata = metadata;
return result;
}
export function validateResourceBundleRef(value: unknown): ResourceBundleRef | null {
if (value === undefined || value === null) return null;
const record = asRecord(value, "resourceBundleRef");
const kind = requiredString(record, "kind");
if (kind !== "git") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be git in v0.1", { httpStatus: 400 });
const repoUrl = requiredString(record, "repoUrl");
const commitId = requiredString(record, "commitId");
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", "resourceBundleRef.commitId must be a full 40-character git commit sha", { httpStatus: 400 });
const result: ResourceBundleRef = { kind: "git", repoUrl, commitId };
const subdir = optionalString(record.subdir);
if (subdir) {
if (subdir.startsWith("/") || subdir.includes("..")) throw new AgentRunError("schema-invalid", "resourceBundleRef.subdir must stay within the checkout", { httpStatus: 400 });
result.subdir = subdir;
}
if (record.sparsePaths !== undefined) {
if (!Array.isArray(record.sparsePaths) || !record.sparsePaths.every((item) => typeof item === "string" && item.length > 0 && !item.startsWith("/") && !item.includes(".."))) {
throw new AgentRunError("schema-invalid", "resourceBundleRef.sparsePaths must be relative path strings", { httpStatus: 400 });
}
result.sparsePaths = record.sparsePaths as string[];
}
if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 });
if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 });
if (record.submodules === false) result.submodules = false;
if (record.lfs === false) result.lfs = false;
if (record.credentialRef !== undefined) result.credentialRef = validateSecretRef(asRecord(record.credentialRef, "resourceBundleRef.credentialRef"));
return result;
}
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 });
@@ -87,6 +133,24 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy {
};
}
function validateSecretRef(record: JsonRecord): SecretRef {
const name = requiredString(record, "name");
const result: SecretRef = { name };
const namespace = optionalString(record.namespace);
const mountPath = optionalString(record.mountPath);
if (namespace) result.namespace = namespace;
if (mountPath) result.mountPath = mountPath;
if (record.keys !== undefined) {
if (!Array.isArray(record.keys) || !record.keys.every((item) => typeof item === "string" && item.length > 0)) throw new AgentRunError("schema-invalid", "secretRef.keys must be non-empty strings", { httpStatus: 400 });
result.keys = record.keys as string[];
}
return result;
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function validateBackendSecretScope(backendProfile: BackendProfile, executionPolicy: ExecutionPolicy): void {
const credentials = executionPolicy.secretScope.providerCredentials ?? [];
const matching = credentials.filter((item) => item.profile === backendProfile);