fix(agentrun): 简化 Artificer Target 派单
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpathSync, statSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export interface AgentRunTargetTaskRequest {
|
||||
readonly target: string;
|
||||
readonly targetWorkspace: string;
|
||||
readonly repository: string;
|
||||
readonly ref: string;
|
||||
readonly mdtodoId: string;
|
||||
}
|
||||
|
||||
export interface AgentRunTargetTaskPreflight extends AgentRunTargetTaskRequest {
|
||||
readonly targetRoute: string;
|
||||
readonly projectId: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly currentBranch: string;
|
||||
readonly headCommit: string;
|
||||
readonly clean: true;
|
||||
readonly originRepository: string;
|
||||
readonly verifiedCommit: string;
|
||||
readonly matchedRemoteRef: string;
|
||||
readonly refRelation: "branch-and-head-match-remote-ref";
|
||||
readonly sourceAuthority: {
|
||||
readonly kind: "target-workspace-origin";
|
||||
readonly credentialScope: "target-owned-git-credential";
|
||||
readonly valuesPrinted: false;
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentRunTargetTaskPreflightError extends Error {
|
||||
readonly stage: string;
|
||||
readonly details: Record<string, unknown>;
|
||||
|
||||
constructor(stage: string, message: string, details: Record<string, unknown> = {}) {
|
||||
super(message);
|
||||
this.name = "AgentRunTargetTaskPreflightError";
|
||||
this.stage = stage;
|
||||
this.details = { ...details, mutation: false, valuesPrinted: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function targetTaskRequestFromArgs(args: string[]): AgentRunTargetTaskRequest | null {
|
||||
const raw = {
|
||||
target: option(args, "target"),
|
||||
targetWorkspace: option(args, "target-workspace"),
|
||||
repository: option(args, "repo"),
|
||||
ref: option(args, "ref"),
|
||||
mdtodoId: option(args, "mdtodo-id"),
|
||||
};
|
||||
const targetMode = raw.targetWorkspace !== null || raw.repository !== null || raw.ref !== null || raw.mdtodoId !== null;
|
||||
if (!targetMode) return null;
|
||||
const missing = Object.entries(raw).filter(([, value]) => value === null).map(([key]) => key);
|
||||
if (missing.length > 0) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"target-context",
|
||||
`Artificer Target 派单缺少 ${missing.join(", ")};请同时提供 --target、--target-workspace、--repo、--ref 和 --mdtodo-id。`,
|
||||
{ missing },
|
||||
);
|
||||
}
|
||||
return {
|
||||
target: validateTarget(raw.target as string),
|
||||
targetWorkspace: validateTargetWorkspace(raw.targetWorkspace as string),
|
||||
repository: normalizeRepository(raw.repository as string),
|
||||
ref: validateRef(raw.ref as string),
|
||||
mdtodoId: validateMdtodoId(raw.mdtodoId as string),
|
||||
};
|
||||
}
|
||||
|
||||
export function preflightTargetTask(request: AgentRunTargetTaskRequest, markerEnv: string): AgentRunTargetTaskPreflight {
|
||||
if (process.env[markerEnv] !== request.target) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"target-execution",
|
||||
`Target 预检只能在外层 trans 重入 ${request.target} 后执行;当前缺少受控执行标记 ${markerEnv}。`,
|
||||
{ target: request.target, markerEnv, targetRoute: `${request.target}:${request.targetWorkspace}` },
|
||||
);
|
||||
}
|
||||
let workspaceRoot: string;
|
||||
try {
|
||||
if (!statSync(request.targetWorkspace).isDirectory()) throw new Error("not a directory");
|
||||
workspaceRoot = realpathSync(request.targetWorkspace);
|
||||
} catch {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"workspace",
|
||||
`Target 工作区不存在或不是目录:${request.target}:${request.targetWorkspace}`,
|
||||
{ target: request.target, targetWorkspace: request.targetWorkspace },
|
||||
);
|
||||
}
|
||||
|
||||
const gitRoot = git(request, ["rev-parse", "--show-toplevel"], "workspace-git-root").trim();
|
||||
let observedRoot: string;
|
||||
try {
|
||||
observedRoot = realpathSync(gitRoot);
|
||||
} catch {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"workspace-git-root",
|
||||
`Target Git 返回了无效工作区根目录:${gitRoot}`,
|
||||
{ targetWorkspace: request.targetWorkspace },
|
||||
);
|
||||
}
|
||||
if (observedRoot !== workspaceRoot) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"workspace-git-root",
|
||||
`--target-workspace 必须指向 Git 工作区根目录;当前根目录是 ${gitRoot}`,
|
||||
{ targetWorkspace: request.targetWorkspace, observedWorkspaceRoot: gitRoot },
|
||||
);
|
||||
}
|
||||
|
||||
const originUrl = git(request, ["remote", "get-url", "origin"], "origin").trim();
|
||||
const originRepository = repositoryFromRemote(originUrl);
|
||||
if (originRepository === null || originRepository.toLowerCase() !== request.repository.toLowerCase()) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"origin",
|
||||
`Target 工作区 origin 与 --repo 不一致;期望 ${request.repository}。`,
|
||||
{
|
||||
expectedRepository: request.repository,
|
||||
observedRepository: originRepository,
|
||||
observedRemoteFingerprint: sha256(originUrl),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const status = git(request, ["status", "--porcelain=v1", "--untracked-files=normal"], "workspace-status");
|
||||
const dirtyEntryCount = status.split(/\r?\n/u).filter(Boolean).length;
|
||||
if (dirtyEntryCount > 0) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"workspace-status",
|
||||
"Target 固定工作区存在未提交修改;请先保存并按语义提交并行改动,再重新派单。",
|
||||
{ targetWorkspace: request.targetWorkspace, dirtyEntryCount },
|
||||
);
|
||||
}
|
||||
|
||||
const currentBranch = git(request, ["branch", "--show-current"], "workspace-branch").trim() || "(detached)";
|
||||
const headCommit = git(request, ["rev-parse", "HEAD"], "workspace-head").trim().toLowerCase();
|
||||
const remote = git(request, ["ls-remote", "--exit-code", "origin", request.ref], "remote-ref");
|
||||
const refs = remote.split(/\r?\n/u)
|
||||
.map((line) => line.trim().split(/\s+/u))
|
||||
.filter((parts) => /^[0-9a-f]{40}$/u.test(parts[0] ?? "") && (parts[1] ?? "").length > 0)
|
||||
.map((parts) => ({ commit: parts[0] as string, ref: parts[1] as string }));
|
||||
const selected = refs.find((item) => item.ref === `refs/heads/${request.ref}`) ?? refs[0];
|
||||
if (selected === undefined) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"remote-ref",
|
||||
`Target 工作区 origin 中找不到 --ref ${request.ref}。`,
|
||||
{ repository: request.repository, ref: request.ref },
|
||||
);
|
||||
}
|
||||
if (currentBranch !== request.ref || headCommit !== selected.commit.toLowerCase()) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"workspace-ref-relation",
|
||||
"Target 任务 worktree 必须检出 --ref,且 HEAD 必须等于 origin 远端提交;请先完成分支切换、提交和推送。",
|
||||
{
|
||||
targetWorkspace: request.targetWorkspace,
|
||||
currentBranch,
|
||||
expectedBranch: request.ref,
|
||||
headCommit,
|
||||
remoteCommit: selected.commit,
|
||||
matchedRemoteRef: selected.ref,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...request,
|
||||
targetRoute: `${request.target}:${request.targetWorkspace}`,
|
||||
projectId: request.repository,
|
||||
workspaceRoot,
|
||||
currentBranch,
|
||||
headCommit,
|
||||
clean: true,
|
||||
originRepository,
|
||||
verifiedCommit: selected.commit,
|
||||
matchedRemoteRef: selected.ref,
|
||||
refRelation: "branch-and-head-match-remote-ref",
|
||||
sourceAuthority: {
|
||||
kind: "target-workspace-origin",
|
||||
credentialScope: "target-owned-git-credential",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function targetTaskPrompt(preflight: AgentRunTargetTaskPreflight, userPrompt: string | null): string {
|
||||
const context = [
|
||||
"任务目标上下文(由 UniDesk YAML-first CLI 在创建 task 前经 Target trans 只读核验):",
|
||||
`- MDTODO ID: ${preflight.mdtodoId}`,
|
||||
`- Target: ${preflight.target}`,
|
||||
`- targetWorkspace: ${preflight.targetWorkspace}`,
|
||||
`- targetRoute: ${preflight.targetRoute}`,
|
||||
`- repository: ${preflight.repository}`,
|
||||
`- ref: ${preflight.ref}`,
|
||||
`- verifiedCommit: ${preflight.verifiedCommit}`,
|
||||
`- refRelation: ${preflight.refRelation}`,
|
||||
"- sourceAuthority: target-workspace-origin(凭据值不进入 task、prompt 或日志)",
|
||||
"",
|
||||
"执行边界:",
|
||||
`- 目标源码的探测、Git、编辑、验证和运行面观察全部使用 \`trans ${preflight.target}:${preflight.targetWorkspace} <operation>\` 或对应 k3s route。`,
|
||||
"- runner primary workspace 只承载 Artificer 默认受控 resource bundle、工具与 skill;不得把它当作目标源码,也不得用容器本地结果替代 Target 原入口证据。",
|
||||
`- 最终报告必须回链 MDTODO ${preflight.mdtodoId}。`,
|
||||
].join("\n");
|
||||
return userPrompt === null || userPrompt.trim().length === 0 ? context : `${context}\n\n${userPrompt}`;
|
||||
}
|
||||
|
||||
export function targetTaskDisclosure(preflight: AgentRunTargetTaskPreflight): Record<string, unknown> {
|
||||
return {
|
||||
mdtodoId: preflight.mdtodoId,
|
||||
target: preflight.target,
|
||||
targetWorkspace: preflight.targetWorkspace,
|
||||
targetRoute: preflight.targetRoute,
|
||||
repository: preflight.repository,
|
||||
ref: preflight.ref,
|
||||
projectId: preflight.projectId,
|
||||
verifiedCommit: preflight.verifiedCommit,
|
||||
matchedRemoteRef: preflight.matchedRemoteRef,
|
||||
refRelation: preflight.refRelation,
|
||||
workspace: {
|
||||
root: preflight.workspaceRoot,
|
||||
branch: preflight.currentBranch,
|
||||
headCommit: preflight.headCommit,
|
||||
clean: preflight.clean,
|
||||
},
|
||||
sourceAuthority: preflight.sourceAuthority,
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function targetTaskIdempotencyKey(preflight: AgentRunTargetTaskPreflight): string {
|
||||
const identity = [
|
||||
preflight.target,
|
||||
preflight.targetWorkspace,
|
||||
preflight.repository,
|
||||
preflight.ref,
|
||||
preflight.mdtodoId,
|
||||
preflight.verifiedCommit,
|
||||
].join("\n");
|
||||
return `artificer-target:${createHash("sha256").update(identity).digest("hex")}`;
|
||||
}
|
||||
|
||||
function git(request: AgentRunTargetTaskRequest, args: string[], stage: string): string {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: request.targetWorkspace,
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (result.error !== undefined || result.status !== 0) {
|
||||
const stderr = String(result.stderr ?? "").trim().split(/\r?\n/u)[0] ?? "";
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
stage,
|
||||
`Target Git 只读预检失败(${stage});请先在 ${request.target}:${request.targetWorkspace} 修复 origin/ref/凭据可达性。`,
|
||||
{
|
||||
target: request.target,
|
||||
targetWorkspace: request.targetWorkspace,
|
||||
repository: request.repository,
|
||||
ref: request.ref,
|
||||
exitCode: result.status,
|
||||
timedOut: result.error?.name === "ETIMEDOUT",
|
||||
stderrSummary: redactGitError(stderr),
|
||||
},
|
||||
);
|
||||
}
|
||||
return String(result.stdout ?? "");
|
||||
}
|
||||
|
||||
function option(args: string[], name: string): string | null {
|
||||
const flag = `--${name}`;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (arg === flag) {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
throw new AgentRunTargetTaskPreflightError("target-context", `${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (arg.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateTarget(value: string): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(value)) {
|
||||
throw new AgentRunTargetTaskPreflightError("target-context", "--target 必须是 YAML 声明的 Target ID");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateTargetWorkspace(value: string): string {
|
||||
if (!value.startsWith("/") || value.includes("\0")) {
|
||||
throw new AgentRunTargetTaskPreflightError("target-context", "--target-workspace 必须是 Target 上的绝对路径");
|
||||
}
|
||||
return value.replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
|
||||
function normalizeRepository(value: string): string {
|
||||
const normalized = value
|
||||
.replace(/^https:\/\/github\.com\//u, "")
|
||||
.replace(/^git@github\.com:/u, "")
|
||||
.replace(/\.git$/u, "");
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(normalized)) {
|
||||
throw new AgentRunTargetTaskPreflightError("target-context", "--repo 必须使用 owner/repo 或等价 GitHub URL");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateRef(value: string): string {
|
||||
const disallowed = /[\u0000-\u0020~^:?*\\\[]/u;
|
||||
if (value.length === 0 || value.length > 255 || value.startsWith("-") || disallowed.test(value)
|
||||
|| value.includes("..") || value.endsWith(".") || value.endsWith("/") || value.includes("//")) {
|
||||
throw new AgentRunTargetTaskPreflightError("target-context", "--ref 不是安全的 Git ref");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateMdtodoId(value: string): string {
|
||||
if (!/^R[1-9][0-9]*(?:\.[1-9][0-9]*)*$/u.test(value)) {
|
||||
throw new AgentRunTargetTaskPreflightError("target-context", "--mdtodo-id 必须使用 R1 或 R1.1 这类 MDTODO ID");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function repositoryFromRemote(value: string): string | null {
|
||||
const normalized = value.replace(/\.git$/u, "");
|
||||
const match = normalized.match(/^(?:git@github\.com:|https:\/\/github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/u);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function redactGitError(value: string): string {
|
||||
return value
|
||||
.replace(/https?:\/\/[^@\s]+@/gu, "https://[redacted]@")
|
||||
.replace(/(token|password|authorization)[=:][^\s]+/giu, "$1=[redacted]")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user