340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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")}`;
|
|
}
|