feat: add aipod spec Artificer assembly

This commit is contained in:
Codex
2026-06-10 17:46:45 +08:00
parent 45df61bd02
commit 6989dc18ef
22 changed files with 2103 additions and 56 deletions
+194 -2
View File
@@ -10,7 +10,7 @@ import { runOnce } from "../../src/runner/run-once.js";
import { renderRunnerJobDryRun } from "../../src/runner/k8s-job.js";
import type { RunnerSessionPvcOptions } from "../../src/runner/k8s-job.js";
import { renderCodexProviderSecretPlan } from "./secret-render.js";
import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RunRecord, SessionSummary } from "../../src/common/types.js";
import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, RunRecord, SessionSummary } from "../../src/common/types.js";
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js";
@@ -57,6 +57,11 @@ async function dispatch(args: ParsedArgs): Promise<CliResult> {
if (group === "server" && command === "logs") return serverLogs(args);
if (group === "server" && command === "stop") return stopServer(args);
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
if ((group === "aipod-specs" || group === "aipods") && command === "list") return client(args).get("/api/v1/aipod-specs");
if ((group === "aipod-specs" || group === "aipods") && command === "show" && id) return client(args).get(`/api/v1/aipod-specs/${encodeURIComponent(id)}`);
if ((group === "aipod-specs" || group === "aipods") && command === "render" && id) return renderAipodSpecCli(args, id);
if ((group === "aipod-specs" || group === "aipods") && (command === "apply" || command === "set")) return applyAipodSpecCli(args, id ?? null);
if ((group === "aipod-specs" || group === "aipods") && (command === "delete" || command === "rm") && id) return client(args).delete(`/api/v1/aipod-specs/${encodeURIComponent(id)}`);
if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles");
if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`);
if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`);
@@ -64,6 +69,9 @@ async function dispatch(args: ParsedArgs): Promise<CliResult> {
if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id);
if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id);
if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id);
if (group === "tool-credentials" && command === "list") return client(args).get("/api/v1/tool-credentials");
if (group === "tool-credentials" && command === "show" && id) return client(args).get(`/api/v1/tool-credentials/${encodeURIComponent(id)}`);
if (group === "tool-credentials" && command === "set-github-ssh") return setGithubSshToolCredentialCli(args);
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
if (group === "sessions" && command === "ps") return listSessions(args);
if (group === "sessions" && command === "create") return sessionCreate(args, id ?? null);
@@ -689,6 +697,12 @@ function queueSubmitConfirmCommand(args: ParsedArgs): string {
return parts.join(" ");
}
function queueSubmitAipodConfirmCommand(args: ParsedArgs, aipod: string): string {
const parts = [`./scripts/agentrun queue submit --aipod ${aipod} --prompt-stdin`];
if (optionalFlag(args, "idempotency-key")) parts.push("--idempotency-key <idempotency-key>");
return parts.join(" ");
}
function queueDispatchConfirmCommand(args: ParsedArgs, taskId: string): string {
const parts = [`./scripts/agentrun queue dispatch ${taskId}`];
if (args.flags.get("json-stdin") === true || optionalFlag(args, "json-file")) parts.push("--json-stdin");
@@ -1004,6 +1018,8 @@ async function sessionStorageDelete(args: ParsedArgs, sessionId: string): Promis
}
async function sessionTurn(args: ParsedArgs, positionalSessionId: string | null): Promise<JsonRecord> {
const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec");
if (aipod) return sessionTurnWithAipod(args, positionalSessionId, aipod);
const body = await optionalJsonFile(args);
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
const requestedProfile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile") ?? (typeof body.backendProfile === "string" ? String(body.backendProfile) : "codex");
@@ -1082,7 +1098,122 @@ async function sessionRead(args: ParsedArgs, sessionId: string): Promise<JsonVal
return summarizeSessionMutationResult("session-read", sessionId, result, { read: true });
}
async function renderAipodSpecCli(args: ParsedArgs, name: string): Promise<JsonValue> {
const input = await aipodRenderInput(args, 3);
return client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(name)}/render`, input);
}
async function renderAipodForCommand(args: ParsedArgs, name: string, trailingPromptStart: number, overrides: RenderAipodInput = {}): Promise<RenderedAipodQueueTask> {
const input = await aipodRenderInput(args, trailingPromptStart, overrides);
return await client(args).post(`/api/v1/aipod-specs/${encodeURIComponent(name)}/render`, input) as RenderedAipodQueueTask;
}
async function aipodRenderInput(args: ParsedArgs, trailingPromptStart: number, overrides: RenderAipodInput = {}): Promise<RenderAipodInput> {
const input = await optionalJsonFile(args) as RenderAipodInput;
const prompt = await optionalPrompt(args, trailingPromptStart);
if (prompt) input.prompt = prompt;
copyOptionalFlag(args, input as JsonRecord, "tenant-id", "tenantId");
copyOptionalFlag(args, input as JsonRecord, "project-id", "projectId");
copyOptionalFlag(args, input as JsonRecord, "queue");
copyOptionalFlag(args, input as JsonRecord, "lane");
copyOptionalFlag(args, input as JsonRecord, "title");
copyOptionalFlag(args, input as JsonRecord, "provider-id", "providerId");
const profile = optionalFlag(args, "profile") ?? optionalFlag(args, "backend-profile");
if (profile) input.backendProfile = normalizeProfile(profile);
const priority = optionalFlag(args, "priority");
if (priority) input.priority = Number(priority);
copyOptionalFlag(args, input as JsonRecord, "idempotency-key", "idempotencyKey");
copyOptionalFlag(args, input as JsonRecord, "session-id", "sessionId");
const workspaceRef = jsonObjectFlag(args, "workspace-json");
if (workspaceRef) input.workspaceRef = workspaceRef as never;
return { ...input, ...overrides };
}
async function applyAipodSpecCli(args: ParsedArgs, name: string | null): Promise<JsonValue> {
const yaml = await aipodYamlInput(args);
const pathValue = name ? `/api/v1/aipod-specs/${encodeURIComponent(name)}` : "/api/v1/aipod-specs";
const method = name ? "PUT" : "POST";
if (args.flags.get("dry-run") === true) {
return { action: "aipod-spec-apply-plan", dryRun: true, mutation: false, request: { method, path: pathValue, yamlBytes: Buffer.byteLength(yaml, "utf8"), valuesPrinted: false }, next: { confirm: `./scripts/agentrun aipod-specs apply${name ? ` ${name}` : ""} --yaml-stdin` }, valuesPrinted: false };
}
return name ? client(args).put(pathValue, { yaml }) : client(args).post(pathValue, { yaml });
}
async function aipodYamlInput(args: ParsedArgs): Promise<string> {
if (args.flags.get("yaml-stdin") === true) return readStdinText();
const file = optionalFlag(args, "yaml-file");
if (!file) throw new AgentRunError("schema-invalid", "aipod-spec YAML input is required; use --yaml-stdin or --yaml-file <file>", { httpStatus: 2 });
return readFile(file, "utf8");
}
async function submitQueueTaskWithAipod(args: ParsedArgs, aipod: string): Promise<JsonValue> {
const rendered = await renderAipodForCommand(args, aipod, 2);
const body = rendered.queueTask as unknown as JsonRecord;
if (args.flags.get("dry-run") === true) {
return queueMutationDryRunPlan("queue-submit", null, "/api/v1/queue/tasks", body, "POST", queueSubmitAipodConfirmCommand(args, aipod), undefined, { source: "aipod-spec", aipod, preferred: "--aipod", valuesPrinted: false });
}
return client(args).post("/api/v1/queue/tasks", body);
}
async function sessionTurnWithAipod(args: ParsedArgs, positionalSessionId: string | null, aipod: string): Promise<JsonRecord> {
const sessionId = positionalSessionId ?? optionalFlag(args, "session-id") ?? newSessionId();
const rendered = await renderAipodForCommand(args, aipod, positionalSessionId ? 3 : 2, { sessionId });
const task = rendered.queueTask;
const profile = String(task.backendProfile);
if (positionalSessionId || optionalFlag(args, "session-id")) {
try {
await client(args).get(`/api/v1/sessions/${encodeURIComponent(sessionId)}/storage`);
} catch {
const expiresInDays = Number(optionalFlag(args, "expires-in-days") ?? 30);
await client(args).post("/api/v1/sessions", {
sessionId,
tenantId: task.tenantId,
projectId: task.projectId,
backendProfile: task.backendProfile,
expiresAt: new Date(Date.now() + Math.max(1, expiresInDays) * 24 * 60 * 60 * 1000).toISOString(),
});
}
}
const sessionRef = objectField(task as unknown as JsonRecord, "sessionRef", {});
const metadata = objectField(sessionRef, "metadata", {});
const title = optionalFlag(args, "title") ?? task.title;
if (title) metadata.title = title;
const runBody: JsonRecord = {
tenantId: task.tenantId,
projectId: task.projectId,
providerId: task.providerId ?? "G14",
backendProfile: task.backendProfile,
workspaceRef: task.workspaceRef ?? { kind: "opaque", path: "." },
sessionRef: { ...sessionRef, sessionId, metadata },
executionPolicy: task.executionPolicy,
resourceBundleRef: task.resourceBundleRef,
traceSink: { kind: "aipod-session", aipod, sessionId, valuesPrinted: false },
};
const run = await client(args).post("/api/v1/runs", runBody) as RunRecord;
const commandBody: JsonRecord = { type: "turn", payload: task.payload };
const commandIdempotencyKey = optionalFlag(args, "command-idempotency-key") ?? optionalFlag(args, "idempotency-key");
if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey;
const command = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/commands`, commandBody) as CommandRecord;
let runnerJob: JsonValue = null;
if (args.flags.get("no-runner-job") !== true) {
const runnerDefaults = jsonRecordValue(rendered.dispatchDefaults.runnerJob) ?? {};
const runnerOverrides = await optionalRunnerJsonFile(args);
const runnerBody = { ...runnerDefaults, ...runnerOverrides, commandId: command.id } as JsonRecord;
copyOptionalFlag(args, runnerBody, "image");
copyOptionalFlag(args, runnerBody, "namespace");
copyOptionalFlag(args, runnerBody, "attempt-id", "attemptId");
copyOptionalFlag(args, runnerBody, "runner-id", "runnerId");
copyOptionalFlag(args, runnerBody, "source-commit", "sourceCommit");
copyRunnerManagerUrlFlag(args, runnerBody);
copyOptionalFlag(args, runnerBody, "service-account-name", "serviceAccountName");
runnerJob = await client(args).post(`/api/v1/runs/${encodeURIComponent(run.id)}/runner-jobs`, runnerBody);
}
return { action: "session-turn", aipod, sessionId, profile, run, command, runnerJob, valuesPrinted: false, pollCommands: { ps: `./scripts/agentrun sessions ps --reader-id cli --profile ${profile}`, show: `./scripts/agentrun sessions show ${sessionId} --reader-id cli`, trace: `./scripts/agentrun sessions trace ${sessionId} --after-seq 0 --limit 100`, output: `./scripts/agentrun sessions output ${sessionId} --after-seq 0 --limit 100`, read: `./scripts/agentrun sessions read ${sessionId} --reader-id cli`, steer: `./scripts/agentrun sessions steer ${sessionId} --prompt-file <file>`, cancel: `./scripts/agentrun sessions cancel ${sessionId}` } };
}
async function submitQueueTask(args: ParsedArgs): Promise<JsonValue> {
const aipod = optionalFlag(args, "aipod") ?? optionalFlag(args, "aipod-spec");
if (aipod) return submitQueueTaskWithAipod(args, aipod);
const body = await jsonFile(args);
const idempotencyKey = optionalFlag(args, "idempotency-key");
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
@@ -1270,6 +1401,40 @@ async function renderCodexSecret(args: ParsedArgs): Promise<JsonRecord> {
return renderCodexProviderSecretPlan(options);
}
async function setGithubSshToolCredentialCli(args: ParsedArgs): Promise<JsonRecord> {
const privateKey = await textFromFileFlag(args, "private-key-file", "private key");
const knownHosts = await textFromFileFlag(args, "known-hosts-file", "known_hosts");
const configFile = optionalFlag(args, "config-file");
const body: JsonRecord = { privateKey, knownHosts };
if (configFile) body.config = await readFile(configFile, "utf8");
if (args.flags.get("dry-run") === true) {
return {
action: "tool-credential-github-ssh-plan",
mutation: false,
dryRun: true,
secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"], valuesPrinted: false },
inputs: {
privateKey: fileSummary("private-key-file", privateKey),
knownHosts: fileSummary("known-hosts-file", knownHosts),
config: configFile ? fileSummary("config-file", String(body.config)) : { provided: false, defaulted: true, valuesPrinted: false },
},
confirm: "./scripts/agentrun tool-credentials set-github-ssh --private-key-file <key> --known-hosts-file <known_hosts> [--config-file <ssh_config>]",
valuesPrinted: false,
};
}
return await client(args).put("/api/v1/tool-credentials/github-ssh/credential", body) as JsonRecord;
}
async function textFromFileFlag(args: ParsedArgs, flagName: string, label: string): Promise<string> {
const file = optionalFlag(args, flagName);
if (!file) throw new AgentRunError("schema-invalid", `tool-credentials set-github-ssh requires --${flagName} <file> for ${label}`, { httpStatus: 2 });
return await readFile(file, "utf8");
}
function fileSummary(flagName: string, text: string): JsonRecord {
return { flag: flagName, bytes: Buffer.byteLength(text, "utf8"), valuesPrinted: false };
}
async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
const profile = normalizeProfile(profileValue);
if (args.flags.get("key-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-key requires --key-stdin", { httpStatus: 2 });
@@ -1581,6 +1746,24 @@ async function readPrompt(args: ParsedArgs): Promise<string> {
throw new AgentRunError("schema-invalid", "prompt is required; use --prompt, --prompt-file, --prompt-stdin, or trailing text", { httpStatus: 2 });
}
async function optionalPrompt(args: ParsedArgs, trailingStart: number): Promise<string | undefined> {
const promptFlag = optionalFlag(args, "prompt");
if (promptFlag) return promptFlag;
const promptFile = optionalFlag(args, "prompt-file");
if (promptFile) {
const text = await readFile(promptFile, "utf8");
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "prompt file is empty", { httpStatus: 2 });
return text;
}
if (args.flags.get("prompt-stdin") === true) {
const text = await readStdinText();
if (text.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin prompt is empty", { httpStatus: 2 });
return text;
}
const inline = args.positional.slice(trailingStart).join(" ").trim();
return inline.length > 0 ? inline : undefined;
}
async function readStdinText(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -1687,7 +1870,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"sessions storage <sessionId>",
"sessions storage <sessionId> --delete",
"sessions show <sessionId> [--reader-id <reader>]",
"sessions turn [sessionId] [--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>]",
"sessions turn [sessionId] [--aipod <name>|--json-stdin|--json-file <run-base.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--runner-json-stdin|--runner-json-file <job.json>] [--no-runner-job]",
"sessions steer <sessionId> [--prompt-stdin|--prompt-file <file>|--prompt <text>]",
"sessions cancel <sessionId> [--reason <text>] [--full|--raw]",
"sessions trace <sessionId> [--after-seq <n>] [--limit <n>] [--run-id <runId>] [--summary-chars <n>] [--include-output] [--seq <n>|--event-id <id>|--item-id <id>] [--detail-scan-pages <n>] [--full|--raw]",
@@ -1703,6 +1886,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"runner jobs --run-id <runId> [--command-id <commandId>]",
"runner job-status [runnerJobId] --run-id <runId>",
"queue submit --json-stdin|--json-file <task.json> [--idempotency-key <key>] [--dry-run]",
"queue submit --aipod <name> [--prompt-stdin|--prompt-file <file>|--prompt <text>] [--idempotency-key <key>] [--dry-run]",
"queue list [--queue <queue>] [--state <state>] [--cursor <cursor>] [--limit <limit>] [--updated-after <version>] [--full|--raw]",
"queue show <taskId> [--full|--raw]",
"queue stats [--queue <queue>]",
@@ -1711,7 +1895,15 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
"queue cancel <taskId> [--reason <text>] [--dry-run] [--full|--raw]",
"queue dispatch <taskId> [--json-stdin|--json-file <dispatch.json>] [--idempotency-key <key>] [--image <image>] [--namespace <namespace>] [--dry-run] [--full|--raw]",
"queue refresh <taskId> [--dry-run] [--full|--raw]",
"aipod-specs list",
"aipod-specs show <name>",
"aipod-specs render <name> [--json-stdin|--json-file <input.json>] [--prompt-stdin|--prompt-file <file>|--prompt <text>]",
"aipod-specs apply [name] --yaml-stdin|--yaml-file <spec.yaml> [--dry-run]",
"aipod-specs delete <name>",
"secrets codex render --dry-run [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>] [--codex-home <dir>] [--model-catalog-file <file>] [--namespace agentrun-v01] [--secret-name <name>]",
"tool-credentials list",
"tool-credentials show github-ssh|unidesk-ssh",
"tool-credentials set-github-ssh --private-key-file <id_ed25519> --known-hosts-file <known_hosts> [--config-file <ssh_config>] [--dry-run]",
"provider-profiles list",
"provider-profiles show <profile>",
"provider-profiles config <profile>",