fix(agentrun): 简化 Artificer Target 派单
This commit is contained in:
@@ -171,6 +171,7 @@ export interface AgentRunLaneSpec {
|
||||
readonly nodeId: string;
|
||||
readonly nodeRoute: string;
|
||||
readonly nodeKubeRoute: string;
|
||||
readonly nodeUnideskWorkspace: string;
|
||||
readonly version: string;
|
||||
readonly source: {
|
||||
readonly statusMode: "host-worktree" | "k3s-git-mirror";
|
||||
@@ -362,6 +363,7 @@ interface AgentRunNodeSpec {
|
||||
readonly id: string;
|
||||
readonly route: string;
|
||||
readonly kubeRoute: string;
|
||||
readonly unideskWorkspace: string;
|
||||
}
|
||||
|
||||
interface AgentRunControlPlaneConfig {
|
||||
@@ -652,6 +654,7 @@ function parseNodes(input: Record<string, unknown>, configPath: string): Record<
|
||||
id,
|
||||
route: stringField(node, "route", `${configPath}.controlPlane.nodes.${id}`),
|
||||
kubeRoute: stringField(node, "kubeRoute", `${configPath}.controlPlane.nodes.${id}`),
|
||||
unideskWorkspace: absolutePathField(node, "unideskWorkspace", `${configPath}.controlPlane.nodes.${id}`),
|
||||
};
|
||||
}
|
||||
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.nodes must declare at least one node`);
|
||||
@@ -689,6 +692,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
|
||||
nodeId: node.id,
|
||||
nodeRoute: node.route,
|
||||
nodeKubeRoute: node.kubeRoute,
|
||||
nodeUnideskWorkspace: node.unideskWorkspace,
|
||||
version,
|
||||
source: {
|
||||
statusMode,
|
||||
|
||||
@@ -3,6 +3,13 @@ import { describe, expect, test } from "bun:test";
|
||||
import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, stripAgentRunResourceWrapperArgs } from "./agentrun";
|
||||
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { agentRunToolCredentialRefs } from "./agentrun/config";
|
||||
import { autoDispatchTargetTask, targetTaskDispatchDryRunPlan } from "./agentrun/rest-bridge";
|
||||
import {
|
||||
preflightTargetTask,
|
||||
targetTaskIdempotencyKey,
|
||||
targetTaskRequestFromArgs,
|
||||
type AgentRunTargetTaskPreflight,
|
||||
} from "./agentrun/target-task";
|
||||
import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests";
|
||||
import { collectLaneSecretSources, secretSyncScript } from "./agentrun/secrets";
|
||||
|
||||
@@ -163,6 +170,154 @@ describe("AgentRun render-only REST client config", () => {
|
||||
expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" transport: direct-http", " transport: ssh-bridge"), "config/agentrun.yaml")).toThrow("client.transport must be direct-http");
|
||||
expect(() => parseAgentRunClientConfigYaml(agentRunClientYaml.replace(" role: render-only", " role: proxy"), "config/agentrun.yaml")).toThrow("client.role must be render-only");
|
||||
});
|
||||
|
||||
test("parses YAML-first target-trans execution without code-owned Target paths", () => {
|
||||
const targetYaml = agentRunClientYaml.replace(
|
||||
" transport: direct-http",
|
||||
[
|
||||
" transport: target-trans",
|
||||
" targetExecution:",
|
||||
" markerEnv: UNIDESK_AGENTRUN_TARGET_EXECUTED",
|
||||
" executable: bun",
|
||||
" cliPath: scripts/cli.ts",
|
||||
].join("\n"),
|
||||
);
|
||||
const config = parseAgentRunClientConfigYaml(targetYaml, "config/agentrun.yaml");
|
||||
expect(config.client.transport).toBe("target-trans");
|
||||
expect(config.client.targetExecution).toEqual({
|
||||
markerEnv: "UNIDESK_AGENTRUN_TARGET_EXECUTED",
|
||||
executable: "bun",
|
||||
cliPath: "scripts/cli.ts",
|
||||
});
|
||||
expect(() => parseAgentRunClientConfigYaml(
|
||||
agentRunClientYaml.replace(" transport: direct-http", " transport: target-trans"),
|
||||
"config/agentrun.yaml",
|
||||
)).toThrow("client.targetExecution.markerEnv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Artificer Target one-command dispatch", () => {
|
||||
test("requires trans re-entry before Target filesystem preflight and derives a stable idempotency key", () => {
|
||||
const request = targetTaskRequestFromArgs([
|
||||
"--target", "NC01",
|
||||
"--target-workspace", "/root/.worktrees/selfmedia/r6-ocr-visual-planner",
|
||||
"--repo", "pikainc/selfmedia",
|
||||
"--ref", "feat/r6-ocr-visual-planner",
|
||||
"--mdtodo-id", "R6.3",
|
||||
]);
|
||||
expect(request).not.toBeNull();
|
||||
if (request === null) throw new Error("expected Target request");
|
||||
const markerEnv = "UNIDESK_AGENTRUN_TARGET_EXECUTED_TEST";
|
||||
const previous = process.env[markerEnv];
|
||||
delete process.env[markerEnv];
|
||||
try {
|
||||
expect(() => preflightTargetTask(request, markerEnv)).toThrow("只能在外层 trans 重入 NC01 后执行");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[markerEnv];
|
||||
else process.env[markerEnv] = previous;
|
||||
}
|
||||
const preflight = {
|
||||
...request,
|
||||
targetRoute: `${request.target}:${request.targetWorkspace}`,
|
||||
projectId: request.repository,
|
||||
workspaceRoot: request.targetWorkspace,
|
||||
currentBranch: request.ref,
|
||||
headCommit: "a".repeat(40),
|
||||
clean: true,
|
||||
originRepository: request.repository,
|
||||
verifiedCommit: "a".repeat(40),
|
||||
matchedRemoteRef: `refs/heads/${request.ref}`,
|
||||
refRelation: "branch-and-head-match-remote-ref",
|
||||
sourceAuthority: {
|
||||
kind: "target-workspace-origin",
|
||||
credentialScope: "target-owned-git-credential",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
} satisfies AgentRunTargetTaskPreflight;
|
||||
expect(targetTaskIdempotencyKey(preflight)).toMatch(/^artificer-target:[0-9a-f]{64}$/u);
|
||||
expect(targetTaskIdempotencyKey(preflight)).toBe(targetTaskIdempotencyKey(preflight));
|
||||
});
|
||||
|
||||
test("auto-dispatches pending submit exactly once and reuses existing attempt identity on retry", async () => {
|
||||
let dispatchRequests = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
expect(request.headers.get("authorization")).toBe("Bearer secret-value");
|
||||
if (request.method === "POST" && url.pathname === "/api/v1/queue/tasks/qt_target/dispatch") {
|
||||
dispatchRequests += 1;
|
||||
return Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
task: { id: "qt_target", state: "running" },
|
||||
run: { id: "run_target", sessionRef: { sessionId: "ses_target" } },
|
||||
command: { id: "cmd_target" },
|
||||
latestAttempt: {
|
||||
attemptId: "qat_target",
|
||||
runId: "run_target",
|
||||
commandId: "cmd_target",
|
||||
runnerJobId: "rjob_target",
|
||||
sessionId: "ses_target",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
const previousKey = process.env.HWLAB_API_KEY;
|
||||
const tempConfigPath = `/tmp/unidesk-agentrun-target-dispatch-test-${process.pid}-${Date.now()}.yaml`;
|
||||
try {
|
||||
process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath;
|
||||
process.env.HWLAB_API_KEY = "secret-value";
|
||||
await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, "")));
|
||||
const first = await autoDispatchTargetTask(
|
||||
{ ok: true, data: { id: "qt_target", state: "pending" } },
|
||||
{ mdtodoId: "R6.3" },
|
||||
{ aipod: "Artificer" },
|
||||
);
|
||||
expect(dispatchRequests).toBe(1);
|
||||
expect((first.data as Record<string, unknown>).runId).toBe("run_target");
|
||||
expect((first.data as Record<string, unknown>).sessionId).toBe("ses_target");
|
||||
expect(((first.autoDispatch as Record<string, unknown>).decision)).toBe("dispatched");
|
||||
|
||||
const replay = await autoDispatchTargetTask(
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
id: "qt_target",
|
||||
state: "running",
|
||||
latestAttempt: {
|
||||
attemptId: "qat_target",
|
||||
runId: "run_target",
|
||||
commandId: "cmd_target",
|
||||
runnerJobId: "rjob_target",
|
||||
sessionId: "ses_target",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ mdtodoId: "R6.3" },
|
||||
{ aipod: "Artificer" },
|
||||
);
|
||||
expect(dispatchRequests).toBe(1);
|
||||
expect(((replay.autoDispatch as Record<string, unknown>).decision)).toBe("idempotent-replay");
|
||||
expect(((replay.autoDispatch as Record<string, unknown>).duplicateDispatchPrevented)).toBe(true);
|
||||
|
||||
const plan = targetTaskDispatchDryRunPlan("artificer-target:test");
|
||||
expect(plan.sequence).toEqual(["queue-submit", "queue-dispatch"]);
|
||||
expect((plan.request as Record<string, unknown>).pathTemplate).toBe("/api/v1/queue/tasks/<submitted-task-id>/dispatch");
|
||||
expect(plan.mutation).toBe(false);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig;
|
||||
if (previousKey === undefined) delete process.env.HWLAB_API_KEY;
|
||||
else process.env.HWLAB_API_KEY = previousKey;
|
||||
rmSync(tempConfigPath, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRun default transport contract", () => {
|
||||
|
||||
@@ -74,7 +74,23 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/
|
||||
const role = stringFieldFromRecord(client, "role", "client");
|
||||
const transport = stringFieldFromRecord(client, "transport", "client");
|
||||
if (role !== "render-only") throw new Error(`${sourcePath}: client.role must be render-only`);
|
||||
if (transport !== "direct-http") throw new Error(`${sourcePath}: client.transport must be direct-http`);
|
||||
if (transport !== "direct-http" && transport !== "target-trans") {
|
||||
throw new Error(`${sourcePath}: client.transport must be direct-http or target-trans`);
|
||||
}
|
||||
const targetExecutionInput = record(client.targetExecution);
|
||||
const targetExecution = transport === "target-trans"
|
||||
? {
|
||||
markerEnv: environmentVariableField(
|
||||
stringFieldFromRecord(targetExecutionInput, "markerEnv", "client.targetExecution"),
|
||||
`${sourcePath}: client.targetExecution.markerEnv`,
|
||||
),
|
||||
executable: stringFieldFromRecord(targetExecutionInput, "executable", "client.targetExecution"),
|
||||
cliPath: relativePathField(
|
||||
stringFieldFromRecord(targetExecutionInput, "cliPath", "client.targetExecution"),
|
||||
`${sourcePath}: client.targetExecution.cliPath`,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const sessionPolicy = readAgentRunSessionPolicyConfig(client, sourcePath);
|
||||
const publicExposure = readAgentRunPublicExposureConfig(input.publicExposure, baseUrl.replace(/\/+$/u, "/"), sourcePath);
|
||||
return {
|
||||
@@ -92,6 +108,7 @@ export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/
|
||||
client: {
|
||||
role,
|
||||
transport,
|
||||
targetExecution,
|
||||
sessionPolicy,
|
||||
},
|
||||
publicExposure,
|
||||
@@ -754,6 +771,18 @@ export function booleanFieldFromRecord(obj: Record<string, unknown>, key: string
|
||||
return value;
|
||||
}
|
||||
|
||||
function environmentVariableField(value: string, pathValue: string): string {
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${pathValue} must be an uppercase environment variable name`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function relativePathField(value: string, pathValue: string): string {
|
||||
if (value.startsWith("/") || value.includes("..") || value.includes("\0")) {
|
||||
throw new Error(`${pathValue} must be a repository-relative path without ..`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function cloneJsonRecord(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -772,7 +801,12 @@ export interface AgentRunClientConfig {
|
||||
};
|
||||
client: {
|
||||
role: string;
|
||||
transport: string;
|
||||
transport: "direct-http" | "target-trans";
|
||||
targetExecution: {
|
||||
markerEnv: string;
|
||||
executable: string;
|
||||
cliPath: string;
|
||||
} | null;
|
||||
sessionPolicy: AgentRunSessionPolicyConfig;
|
||||
};
|
||||
publicExposure: AgentRunPublicExposure | null;
|
||||
|
||||
@@ -69,6 +69,7 @@ export function agentRunHelp(): unknown {
|
||||
"bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
|
||||
"bun scripts/cli.ts agentrun dispatch task/<taskId>",
|
||||
"bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --idempotency-key <key>",
|
||||
"bun scripts/cli.ts agentrun create task --aipod Artificer --target <Target> --target-workspace <absolute-task-worktree> --repo <owner/repo> --ref <branch> --mdtodo-id <R6.3> --prompt-stdin [--dry-run]",
|
||||
"bun scripts/cli.ts agentrun apply -f - --dry-run",
|
||||
"bun scripts/cli.ts agentrun send session/<sessionId> --aipod Artificer --prompt-stdin",
|
||||
"bun scripts/cli.ts agentrun explain task",
|
||||
@@ -99,8 +100,8 @@ export function agentRunHelp(): unknown {
|
||||
"bun scripts/cli.ts agentrun git-mirror legacy-ops --help",
|
||||
],
|
||||
resources: ["task/qt", "attempt", "run", "command/cmd", "runnerjob/rjob", "session/ses", "aipodspec/aps"],
|
||||
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and --node/--lane targets a YAML-declared runtime lane.",
|
||||
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain as compatibility groups backed by direct HTTP; new commander work should use get/describe/events/logs/result/ack/cancel/retry/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
|
||||
description: "Operate AgentRun through Kubernetes-style resource verbs. Human output is compact by default; -o json|yaml returns the UniDesk render-only client schema, --raw exposes the REST envelope, and YAML client.targetExecution makes resource verbs re-enter the selected Target through trans.",
|
||||
legacyCompatibility: "queue/runs/commands/runner/sessions/aipod-specs remain compatibility groups; new commander work should use get/describe/events/logs/result/ack/cancel/retry/dispatch/create/apply/send. sessions turn/steer are removed; use send only.",
|
||||
cicdBoundary: "NC01 PaC consumer 仅由 GitHub PR merge 自动触发;默认帮助只给 status/history。legacy 与平台维护写入口只在显式 scoped help 中展示。",
|
||||
};
|
||||
}
|
||||
@@ -316,7 +317,7 @@ export function agentRunHelpText(args: string[]): string {
|
||||
return "Usage: bun scripts/cli.ts agentrun dispatch task/<taskId>";
|
||||
}
|
||||
if (verb === "create") {
|
||||
return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin [--idempotency-key <key>] [--dry-run]";
|
||||
return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --target <Target> --target-workspace <task-worktree> --repo <owner/repo> --ref <branch> --mdtodo-id <R6.3> --prompt-stdin [--idempotency-key <key>] [--dry-run]";
|
||||
}
|
||||
if (verb === "apply") {
|
||||
return "Usage: bun scripts/cli.ts agentrun apply -f task.yaml|json|- [--dry-run]\nTask manifests use kind: Task and spec: <AgentRun task payload>.";
|
||||
@@ -411,11 +412,11 @@ export function agentRunHelpText(args: string[]): string {
|
||||
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
|
||||
" bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
|
||||
" bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
|
||||
" bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin",
|
||||
" bun scripts/cli.ts agentrun create task --aipod Artificer --target <Target> --target-workspace <task-worktree> --repo <owner/repo> --ref <branch> --mdtodo-id <R6.3> --prompt-stdin",
|
||||
"",
|
||||
"Machine/debug output:",
|
||||
" -o json|yaml emits a stable UniDesk resource object without the global JSON envelope.",
|
||||
" --raw emits the direct AgentRun REST envelope.",
|
||||
" --node/--lane targets a YAML-declared AgentRun runtime lane; default is the configured NC01 manager.",
|
||||
" --target/--node/--lane selects a YAML Target; resource commands re-enter it through target-trans.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -496,12 +496,83 @@ export function renderMutationSummary(command: string, raw: Record<string, unkno
|
||||
if (decision !== null) lines.push(`Decision: ${decision}`);
|
||||
if (internalCommandType !== null) lines.push(`InternalCommandType: ${internalCommandType}`);
|
||||
lines.push(...renderCancelLifecycleMutationLines(record(data.cancelLifecycle ?? raw.cancelLifecycle)));
|
||||
lines.push(...renderTargetTaskMutationLines(record(data.targetTask ?? raw.targetTask)));
|
||||
lines.push(...renderAipodBindingMutationLines(record(data.aipodBinding ?? raw.aipodBinding)));
|
||||
lines.push(...renderAutoDispatchMutationLines(record(data.autoDispatch ?? raw.autoDispatch)));
|
||||
lines.push(...renderDispatchPlanMutationLines(record(data.dispatchPlan ?? raw.dispatchPlan)));
|
||||
const next = record(raw.next ?? data.next);
|
||||
const nextLines = (overrideNextLines ?? Object.values(next).map(String)).filter((line) => line.length > 0).slice(0, 5);
|
||||
if (nextLines.length > 0) lines.push("", "Next:", ...nextLines.map((line) => ` ${line}`));
|
||||
return renderedCliResult(raw.ok !== false, command, lines.join("\n"));
|
||||
}
|
||||
|
||||
export function renderTargetTaskMutationLines(targetTask: Record<string, unknown>): string[] {
|
||||
if (Object.keys(targetTask).length === 0) return [];
|
||||
const workspace = record(targetTask.workspace);
|
||||
return [
|
||||
"",
|
||||
"TargetTaskPreflight:",
|
||||
` MDTODO: ${displayValue(targetTask.mdtodoId)} project=${displayValue(targetTask.projectId)}`,
|
||||
` Target: ${displayValue(targetTask.targetRoute)}`,
|
||||
` Source: ${displayValue(targetTask.repository)} ref=${displayValue(targetTask.ref)}`,
|
||||
` Commit: HEAD=${displayValue(workspace.headCommit)} remote=${displayValue(targetTask.verifiedCommit)}`,
|
||||
` Worktree: branch=${displayValue(workspace.branch)} clean=${displayValue(workspace.clean)} relation=${displayValue(targetTask.refRelation)}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderAipodBindingMutationLines(binding: Record<string, unknown>): string[] {
|
||||
if (Object.keys(binding).length === 0) return [];
|
||||
const workspaceRef = record(binding.workspaceRef);
|
||||
const resourceBundleRef = record(binding.resourceBundleRef);
|
||||
const session = record(binding.session);
|
||||
const providerCredentials = arrayRecords(binding.providerCredentials);
|
||||
const toolCredentials = arrayRecords(binding.toolCredentials);
|
||||
const lines = [
|
||||
"",
|
||||
"AipodBinding:",
|
||||
` Aipod: ${displayValue(binding.aipod)} target=${displayValue(binding.node)}/${displayValue(binding.lane)} namespace=${displayValue(binding.namespace)}`,
|
||||
` WorkspaceRef: kind=${displayValue(workspaceRef.kind)} path=${displayValue(workspaceRef.path)} valid=${displayValue(workspaceRef.valid)}`,
|
||||
` ResourceBundle: ${displayValue(resourceBundleRef.repoUrl)} ref=${displayValue(resourceBundleRef.ref)} inherited=${displayValue(resourceBundleRef.inheritedFromAipod)}`,
|
||||
` Session: ${displayValue(session.sessionId)} source=${displayValue(session.identitySource)}`,
|
||||
];
|
||||
for (const credential of providerCredentials.slice(0, 4)) {
|
||||
const secretRef = record(credential.secretRef);
|
||||
lines.push(` ProviderSecretRef: ${displayValue(secretRef.namespace)}/${displayValue(secretRef.name)} keys=${displayValue(secretRef.keys)} present=${displayValue(credential.bindingPresent)}`);
|
||||
}
|
||||
for (const credential of toolCredentials.slice(0, 6)) {
|
||||
const secretRef = record(credential.secretRef);
|
||||
lines.push(` ToolSecretRef: ${displayValue(credential.tool)} ${displayValue(secretRef.namespace)}/${displayValue(secretRef.name)} keys=${displayValue(secretRef.keys)} present=${displayValue(credential.bindingPresent)}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderAutoDispatchMutationLines(autoDispatch: Record<string, unknown>): string[] {
|
||||
if (Object.keys(autoDispatch).length === 0) return [];
|
||||
const identity = record(autoDispatch.identity);
|
||||
return [
|
||||
"",
|
||||
"AutoDispatch:",
|
||||
` Decision: ${displayValue(autoDispatch.decision)} mutation=${displayValue(autoDispatch.mutation)} duplicatePrevented=${displayValue(autoDispatch.duplicateDispatchPrevented)}`,
|
||||
` Task: ${displayValue(identity.taskId)} state=${displayValue(identity.taskState)} attempt=${displayValue(identity.attemptId)}`,
|
||||
` Run: ${displayValue(identity.runId)} command=${displayValue(identity.commandId)} runner=${displayValue(identity.runnerJobId)}`,
|
||||
` Session: ${displayValue(identity.sessionId)}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderDispatchPlanMutationLines(plan: Record<string, unknown>): string[] {
|
||||
if (Object.keys(plan).length === 0) return [];
|
||||
const request = record(plan.request);
|
||||
const sequence = Array.isArray(plan.sequence) ? plan.sequence.map(String).join(" -> ") : "-";
|
||||
return [
|
||||
"",
|
||||
"AutoDispatchPlan:",
|
||||
` Sequence: ${sequence}`,
|
||||
` Request: ${displayValue(request.method)} ${displayValue(request.pathTemplate)}`,
|
||||
` Condition: ${displayValue(plan.condition)}`,
|
||||
` Replay: ${displayValue(plan.replay)}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderCancelLifecycleMutationLines(lifecycle: Record<string, unknown>): string[] {
|
||||
if (Object.keys(lifecycle).length === 0) return [];
|
||||
const authority = record(lifecycle.authority);
|
||||
|
||||
@@ -41,6 +41,7 @@ import { agentRunExplain, arrayRecords, shortId } from "./options";
|
||||
import { agentRunCancelAuthorityDisclosure, agentRunCancelCascadeScope, agentRunCancelFencingDisclosure, agentRunCancelRunnerAbortDisclosure, compactAipodSpecDescriptionPayload, compactTaskDescriptionPayload, innerData, normalizeAipodSpecItems, normalizeAttemptResources, normalizeEventItems, normalizeLogItems, normalizeQueueAttemptItems, normalizeRunnerJobItems, normalizeSessionItems, normalizeSingleCommand, normalizeTaskItems, parseResourceKind, parseResourceRef, renderAipodSpecDescription, renderDescribe, renderEventLike, renderGenericDescription, renderMachine, renderMutationSummary, renderQueueAttemptList, renderQueueRetrySummary, renderResourceResult, renderResultSummary, renderTaskDescription, renderedCliResult, requiredContext, rerunWithoutDryRun, resolveAgentRunCancelPolicyTarget, resourceApply, resourceCreate, resourceDispatch, resourceSessionPromptCommand, taskInputDescriptionPayload, taskListState, unwrapTaskDetail } from "./render";
|
||||
import { AgentRunRestError, renderAgentRunRestError, resolveAgentRunRestTarget, runAgentRunRestCommand, withAgentRunRestTarget } from "./rest-bridge";
|
||||
import { renderSessionSendError } from "./session-send-render";
|
||||
import { runAgentRunResourceThroughTarget } from "./target-execution";
|
||||
import { runnerJobObservationScript, type RunnerJobObservationIdentity } from "./runner-observation";
|
||||
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, record, stringOrNull } from "./utils";
|
||||
|
||||
@@ -64,6 +65,7 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
|
||||
try {
|
||||
options = parseResourceOptions(resourceArgs);
|
||||
validateResourceOptionsForVerb(verb, options);
|
||||
validateResourceActionBeforeTarget(verb, action, actionArgs, options);
|
||||
} catch (error) {
|
||||
const validationError = error instanceof AgentRunRestError
|
||||
? error
|
||||
@@ -72,6 +74,8 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
|
||||
}
|
||||
const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs);
|
||||
try {
|
||||
const targetResult = await runAgentRunResourceThroughTarget(config, command, canonicalArgs, options);
|
||||
if (targetResult !== null) return targetResult;
|
||||
return await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
|
||||
if (verb === "explain") return renderedCliResult(true, command, agentRunExplain(action ?? "task", resourceArgs, options));
|
||||
if (verb === "get") return await resourceGet(config, command, action, bridgeActionArgs, options);
|
||||
@@ -98,6 +102,29 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
|
||||
return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`);
|
||||
}
|
||||
|
||||
function validateResourceActionBeforeTarget(
|
||||
verb: AgentRunResourceVerb,
|
||||
action: string | undefined,
|
||||
args: string[],
|
||||
options: AgentRunResourceOptions,
|
||||
): void {
|
||||
if (verb === "retry") {
|
||||
if (action === undefined || action.startsWith("-")) throw new AgentRunRestError("validation-failed", "retry requires task/<taskId>");
|
||||
let ref: AgentRunResourceRef;
|
||||
try {
|
||||
ref = parseResourceRef(action, args, "task");
|
||||
} catch {
|
||||
throw new AgentRunRestError("validation-failed", "retry requires task/<taskId>");
|
||||
}
|
||||
if (ref.kind !== "task") throw new AgentRunRestError("validation-failed", "retry supports task/<taskId>");
|
||||
if (options.idempotencyKey === null) throw new AgentRunRestError("validation-failed", "retry requires --idempotency-key <key>");
|
||||
if (options.reason === null) throw new AgentRunRestError("validation-failed", "retry requires --reason <text>");
|
||||
}
|
||||
if (verb === "get" && parseResourceKind(action) === "attempt" && options.taskId === null) {
|
||||
throw new AgentRunRestError("validation-failed", "get attempts requires --task <taskId>");
|
||||
}
|
||||
}
|
||||
|
||||
function resourceErrorOutputOptions(args: string[]): AgentRunResourceOptions {
|
||||
const options = parseResourceOptions([]);
|
||||
options.raw = args.includes("--raw");
|
||||
@@ -162,9 +189,14 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
promptStdin: false,
|
||||
node: null,
|
||||
lane: null,
|
||||
target: null,
|
||||
targetWorkspace: null,
|
||||
repository: null,
|
||||
ref: null,
|
||||
mdtodoId: null,
|
||||
passthroughArgs: [],
|
||||
};
|
||||
const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane"]);
|
||||
const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]);
|
||||
const booleanFlags = new Set(["--full", "--raw", "--debug", "--input", "--unread", "--dry-run", "--full-text", "--prompt-stdin", "--stdin"]);
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
@@ -186,6 +218,12 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (options.target !== null) {
|
||||
if (options.node !== null && options.node !== options.target) {
|
||||
throw new Error(`--target ${options.target} conflicts with --node ${options.node}`);
|
||||
}
|
||||
options.node = options.target;
|
||||
}
|
||||
if (options.taskInput && (options.full || options.raw)) {
|
||||
const conflictingOptions = ["--input", options.full ? "--full" : null, options.raw ? "--raw" : null]
|
||||
.filter((value): value is string => value !== null);
|
||||
@@ -278,6 +316,11 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri
|
||||
else if (flag === "--idempotency-key") options.idempotencyKey = requiredValue(value, flag);
|
||||
else if (flag === "--node") options.node = requiredValue(value, flag);
|
||||
else if (flag === "--lane") options.lane = requiredValue(value, flag);
|
||||
else if (flag === "--target") options.target = requiredValue(value, flag);
|
||||
else if (flag === "--target-workspace") options.targetWorkspace = requiredValue(value, flag);
|
||||
else if (flag === "--repo") options.repository = requiredValue(value, flag);
|
||||
else if (flag === "--ref") options.ref = requiredValue(value, flag);
|
||||
else if (flag === "--mdtodo-id") options.mdtodoId = requiredValue(value, flag);
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: string | null, flag: string): number {
|
||||
|
||||
@@ -43,6 +43,15 @@ import { arrayRecords, displayValue, isRecord, pickCompact, renderTable, truncat
|
||||
import { innerData, pathValue, renderMachine, renderedCliResult } from "./render";
|
||||
import { parseResourceOptions, stripAgentRunResourceWrapperArgs } from "./resource-actions";
|
||||
import { AGENTRUN_GIT_MIRROR_RETRY_MAX_ATTEMPTS, agentRunGitMirrorRetryDelayMs, agentRunGitMirrorRetrySummary, agentRunGitMirrorRetryableFailure, createYamlLaneJobScript, yamlLaneGitMirrorJobManifest, yamlLaneGitMirrorStatusScript, yamlLaneJobProbeScript } from "./secrets";
|
||||
import {
|
||||
AgentRunTargetTaskPreflightError,
|
||||
preflightTargetTask,
|
||||
targetTaskDisclosure,
|
||||
targetTaskIdempotencyKey,
|
||||
targetTaskPrompt,
|
||||
targetTaskRequestFromArgs,
|
||||
type AgentRunTargetTaskPreflight,
|
||||
} from "./target-task";
|
||||
import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, progressEvent, record, shQuote, sleep, stringOrNull } from "./utils";
|
||||
|
||||
export async function readGitMirrorStatus(config: UniDeskConfig, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown> & { result: SshCaptureResult; raw: string; summary: Record<string, unknown> }> {
|
||||
@@ -581,16 +590,45 @@ export async function runAgentRunAipodSpecsRest(action: string | undefined, id:
|
||||
export async function submitQueueTaskRest(args: string[]): Promise<Record<string, unknown>> {
|
||||
const aipod = agentRunOption(args, "aipod") ?? agentRunOption(args, "aipod-spec");
|
||||
if (aipod) {
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, 2));
|
||||
const targetTask = targetTaskPreflightFromArgs(args, aipod);
|
||||
const renderInput = await aipodRenderInputFromArgs(args, targetTask === null ? 2 : args.length);
|
||||
if (targetTask !== null) {
|
||||
renderInput.prompt = targetTaskPrompt(targetTask, stringOrNull(renderInput.prompt));
|
||||
if (agentRunOption(args, "project-id") === null) renderInput.projectId = targetTask.projectId;
|
||||
if (agentRunOption(args, "provider-id") === null) renderInput.providerId = targetTask.target;
|
||||
renderInput.metadata = {
|
||||
...record(renderInput.metadata),
|
||||
targetContext: targetTaskDisclosure(targetTask),
|
||||
};
|
||||
if (agentRunOption(args, "idempotency-key") === null) {
|
||||
renderInput.idempotencyKey = targetTaskIdempotencyKey(targetTask);
|
||||
}
|
||||
}
|
||||
const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput);
|
||||
const body = normalizeAipodRenderedQueueTask(record(record(innerData(rendered)).queueTask), args, aipod);
|
||||
if (Object.keys(body).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`);
|
||||
assertLegalAipodWorkspaceRef(body, aipod);
|
||||
const targetTaskOutput = targetTask === null ? null : targetTaskDisclosure(targetTask);
|
||||
const aipodBinding = agentRunAipodBindingDisclosure(body, aipod);
|
||||
if (agentRunHasFlag(args, "dry-run")) {
|
||||
return agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", body, queueSubmitConfirmCommand(args, aipod), "POST", {
|
||||
jsonInput: { source: "aipod-spec", aipod, valuesPrinted: false },
|
||||
aipodBinding: agentRunAipodBindingDisclosure(body, aipod),
|
||||
aipodBinding,
|
||||
...(targetTaskOutput === null ? {} : { targetTask: targetTaskOutput }),
|
||||
...(targetTask === null ? {} : {
|
||||
dispatchPlan: targetTaskDispatchDryRunPlan(stringOrNull(body.idempotencyKey) ?? targetTaskIdempotencyKey(targetTask)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
|
||||
const submitted = await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
|
||||
if (targetTask !== null) {
|
||||
return await autoDispatchTargetTask(submitted, targetTaskOutput ?? {}, aipodBinding);
|
||||
}
|
||||
return {
|
||||
...submitted,
|
||||
aipodBinding,
|
||||
...(targetTaskOutput === null ? {} : { targetTask: targetTaskOutput }),
|
||||
};
|
||||
}
|
||||
const body = await requiredJsonBody(args, "queue submit");
|
||||
const idempotencyKey = agentRunOption(args, "idempotency-key");
|
||||
@@ -599,6 +637,176 @@ export async function submitQueueTaskRest(args: string[]): Promise<Record<string
|
||||
return await agentRunRestRequest("agentrun queue submit", "POST", "/api/v1/queue/tasks", body);
|
||||
}
|
||||
|
||||
function targetTaskPreflightFromArgs(args: string[], aipod: string): AgentRunTargetTaskPreflight | null {
|
||||
try {
|
||||
const request = targetTaskRequestFromArgs(args);
|
||||
if (request === null) return null;
|
||||
if (aipod !== "Artificer") {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"aipod",
|
||||
`Target 上下文派单当前只支持 --aipod Artificer;收到 ${aipod}。`,
|
||||
{ aipod },
|
||||
);
|
||||
}
|
||||
const client = readAgentRunClientConfig();
|
||||
const execution = client.client.targetExecution;
|
||||
if (client.client.transport !== "target-trans" || execution === null) {
|
||||
throw new AgentRunTargetTaskPreflightError(
|
||||
"target-execution",
|
||||
`${client.sourcePath} 必须声明 client.transport=target-trans 和 client.targetExecution。`,
|
||||
{ configPath: client.sourcePath, transport: client.client.transport },
|
||||
);
|
||||
}
|
||||
return preflightTargetTask(request, execution.markerEnv);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AgentRunTargetTaskPreflightError)) throw error;
|
||||
throw new AgentRunRestError("validation-failed", error.message, {
|
||||
details: {
|
||||
stage: error.stage,
|
||||
...error.details,
|
||||
mutation: false,
|
||||
recoveryActions: [
|
||||
"修复错误后用同一条 create task 命令重新执行;失败预检不会创建 task。",
|
||||
"先保留 --dry-run 查看 Target、source、workspaceRef、resourceBundleRef、session 与 SecretRef 摘要。",
|
||||
],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertLegalAipodWorkspaceRef(task: Record<string, unknown>, aipod: string): void {
|
||||
const workspaceRef = record(task.workspaceRef);
|
||||
const keys = Object.keys(workspaceRef).sort();
|
||||
const illegalKeys = keys.filter((key) => key !== "kind" && key !== "path");
|
||||
if (typeof workspaceRef.kind !== "string" || typeof workspaceRef.path !== "string" || illegalKeys.length > 0) {
|
||||
throw new AgentRunRestError(
|
||||
"validation-failed",
|
||||
`AipodSpec ${aipod} rendered an illegal workspaceRef; only kind/path are accepted.`,
|
||||
{
|
||||
details: {
|
||||
stage: "aipod-workspace-ref",
|
||||
keys,
|
||||
illegalKeys,
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
recoveryActions: ["修复 AipodSpec 默认 workspaceRef 后重试;不要把 repo/ref 写入 workspaceRef。"],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function targetTaskDispatchDryRunPlan(idempotencyKey: string): Record<string, unknown> {
|
||||
return {
|
||||
enabled: true,
|
||||
sequence: ["queue-submit", "queue-dispatch"],
|
||||
request: {
|
||||
method: "POST",
|
||||
pathTemplate: "/api/v1/queue/tasks/<submitted-task-id>/dispatch",
|
||||
bodyKeys: [],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
condition: "dispatch only when the idempotent submit result is pending",
|
||||
replay: "running or terminal task returns its existing attempt/run/session without another dispatch",
|
||||
taskIdempotencyKey: idempotencyKey,
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function autoDispatchTargetTask(
|
||||
submitted: Record<string, unknown>,
|
||||
targetTask: Record<string, unknown>,
|
||||
aipodBinding: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const submittedTask = record(innerData(submitted));
|
||||
const taskId = stringOrNull(submittedTask.id);
|
||||
if (taskId === null) {
|
||||
throw new AgentRunRestError("schema-mismatch", "queue submit did not return a task id before auto-dispatch", {
|
||||
details: { stage: "queue-submit", mutation: true, dispatchMutation: false, valuesPrinted: false },
|
||||
});
|
||||
}
|
||||
if (stringOrNull(submittedTask.state) !== "pending") {
|
||||
return targetTaskDispatchResult(submitted, null, submittedTask, targetTask, aipodBinding, "idempotent-replay");
|
||||
}
|
||||
|
||||
try {
|
||||
const dispatched = await agentRunRestRequest(
|
||||
"agentrun queue auto-dispatch",
|
||||
"POST",
|
||||
`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`,
|
||||
{},
|
||||
);
|
||||
return targetTaskDispatchResult(submitted, dispatched, submittedTask, targetTask, aipodBinding, "dispatched");
|
||||
} catch (error) {
|
||||
const observed = await agentRunRestRequest(
|
||||
"agentrun queue auto-dispatch recovery",
|
||||
"GET",
|
||||
`/api/v1/queue/tasks/${encodeURIComponent(taskId)}`,
|
||||
);
|
||||
const observedTask = record(innerData(observed));
|
||||
if (stringOrNull(observedTask.state) === "pending") throw error;
|
||||
return targetTaskDispatchResult(submitted, null, observedTask, targetTask, aipodBinding, "concurrent-replay");
|
||||
}
|
||||
}
|
||||
|
||||
function targetTaskDispatchResult(
|
||||
submitted: Record<string, unknown>,
|
||||
dispatched: Record<string, unknown> | null,
|
||||
submittedTask: Record<string, unknown>,
|
||||
targetTask: Record<string, unknown>,
|
||||
aipodBinding: Record<string, unknown>,
|
||||
decision: "dispatched" | "idempotent-replay" | "concurrent-replay",
|
||||
): Record<string, unknown> {
|
||||
const dispatchData = dispatched === null ? {} : record(innerData(dispatched));
|
||||
const task = Object.keys(record(dispatchData.task)).length > 0 ? record(dispatchData.task) : submittedTask;
|
||||
const run = record(dispatchData.run);
|
||||
const command = record(dispatchData.command);
|
||||
const latestAttempt = Object.keys(record(dispatchData.latestAttempt)).length > 0
|
||||
? record(dispatchData.latestAttempt)
|
||||
: record(task.latestAttempt);
|
||||
const sessionRef = record(run.sessionRef);
|
||||
const taskId = stringOrNull(task.id) ?? stringOrNull(submittedTask.id);
|
||||
const identity = {
|
||||
taskId,
|
||||
taskState: task.state ?? submittedTask.state ?? null,
|
||||
attemptId: latestAttempt.attemptId ?? null,
|
||||
runId: run.id ?? latestAttempt.runId ?? null,
|
||||
commandId: command.id ?? latestAttempt.commandId ?? null,
|
||||
runnerJobId: latestAttempt.runnerJobId ?? null,
|
||||
sessionId: latestAttempt.sessionId ?? sessionRef.sessionId ?? null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
const autoDispatch = {
|
||||
enabled: true,
|
||||
decision,
|
||||
mutation: decision === "dispatched",
|
||||
duplicateDispatchPrevented: decision !== "dispatched",
|
||||
identity,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
action: "queue-submit-auto-dispatch",
|
||||
data: {
|
||||
...identity,
|
||||
mutation: decision === "dispatched",
|
||||
autoDispatch,
|
||||
task,
|
||||
...(Object.keys(run).length === 0 ? {} : { run }),
|
||||
...(Object.keys(command).length === 0 ? {} : { command }),
|
||||
latestAttempt,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
bridge: dispatched?.bridge ?? submitted.bridge,
|
||||
targetTask,
|
||||
aipodBinding,
|
||||
autoDispatch,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mutateQueueTaskRest(action: string, taskId: string, suffix: string, body: Record<string, unknown>, args: string[]): Promise<Record<string, unknown>> {
|
||||
const pathValue = `/api/v1/queue/tasks/${encodeURIComponent(taskId)}/${suffix}`;
|
||||
if (agentRunHasFlag(args, "dry-run")) return agentRunDryRunPlan(action, pathValue, body, `bun scripts/cli.ts agentrun queue ${suffix} ${taskId}`);
|
||||
@@ -727,30 +935,59 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
|
||||
const providerCredentials = arrayRecords(secretScope.providerCredentials).map((credential) => ({
|
||||
profile: credential.profile ?? null,
|
||||
secretRef: {
|
||||
namespace: policyTarget.spec.runtime.namespace,
|
||||
name: record(credential.secretRef).name ?? null,
|
||||
keys: Array.isArray(record(credential.secretRef).keys) ? record(credential.secretRef).keys : [],
|
||||
},
|
||||
bindingPresent: typeof record(credential.secretRef).name === "string",
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
const toolCredentials = arrayRecords(secretScope.toolCredentials).map((credential) => ({
|
||||
tool: credential.tool ?? null,
|
||||
purpose: credential.purpose ?? null,
|
||||
secretRef: {
|
||||
namespace: policyTarget.spec.runtime.namespace,
|
||||
name: record(credential.secretRef).name ?? null,
|
||||
keys: Array.isArray(record(credential.secretRef).keys) ? record(credential.secretRef).keys : [],
|
||||
},
|
||||
bindingPresent: typeof record(credential.secretRef).name === "string",
|
||||
projection: record(credential.projection),
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
const workspaceRef = record(task.workspaceRef);
|
||||
const resourceBundleRef = record(task.resourceBundleRef);
|
||||
const sessionRef = record(task.sessionRef);
|
||||
return {
|
||||
aipod,
|
||||
node: policyTarget.spec.nodeId,
|
||||
lane: policyTarget.spec.lane,
|
||||
namespace: policyTarget.spec.runtime.namespace,
|
||||
policySource: policyTarget.source,
|
||||
projectId: task.projectId ?? null,
|
||||
providerId: task.providerId ?? null,
|
||||
backendProfile: task.backendProfile ?? null,
|
||||
workspaceRef: task.workspaceRef ?? null,
|
||||
workspaceRef: {
|
||||
kind: workspaceRef.kind ?? null,
|
||||
path: workspaceRef.path ?? null,
|
||||
legalKeys: Object.keys(workspaceRef).sort(),
|
||||
schema: "kind/path",
|
||||
valid: typeof workspaceRef.kind === "string"
|
||||
&& typeof workspaceRef.path === "string"
|
||||
&& Object.keys(workspaceRef).every((key) => key === "kind" || key === "path"),
|
||||
},
|
||||
resourceBundleRef: {
|
||||
kind: resourceBundleRef.kind ?? null,
|
||||
repoUrl: safeRepositoryUrl(stringOrNull(resourceBundleRef.repoUrl)),
|
||||
ref: resourceBundleRef.ref ?? null,
|
||||
inheritedFromAipod: true,
|
||||
credentialRefPresent: Object.keys(record(resourceBundleRef.credentialRef)).length > 0,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
session: {
|
||||
sessionId: sessionRef.sessionId ?? null,
|
||||
identitySource: sessionRef.sessionId === undefined ? "aipod-render-default" : "aipod-rendered-sessionRef",
|
||||
valuesPrinted: false,
|
||||
},
|
||||
executionPolicy: pickCompact(executionPolicy, ["sandbox", "approval", "timeoutMs", "network"]),
|
||||
providerCredentials,
|
||||
toolCredentials,
|
||||
@@ -758,6 +995,11 @@ export function agentRunAipodBindingDisclosure(task: Record<string, unknown>, ai
|
||||
};
|
||||
}
|
||||
|
||||
function safeRepositoryUrl(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value.replace(/^(https?:\/\/)[^@/]+@/u, "$1[redacted]@");
|
||||
}
|
||||
|
||||
export function agentRunSessionRunPolicyDisclosure(runBody: Record<string, unknown>): Record<string, unknown> {
|
||||
const policyTarget = resolveAgentRunSessionPolicyTarget();
|
||||
const executionPolicy = record(runBody.executionPolicy);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { UniDeskConfig } from "../config";
|
||||
import type { RenderedCliResult } from "../output";
|
||||
import { resolveAgentRunLaneTarget } from "../agentrun-lanes";
|
||||
import { runSshCommandCapture } from "../ssh";
|
||||
import { readAgentRunClientConfig } from "./config";
|
||||
import { renderMachine, renderedCliResult } from "./render";
|
||||
import type { AgentRunResourceOptions } from "./utils";
|
||||
|
||||
export async function runAgentRunResourceThroughTarget(
|
||||
config: UniDeskConfig | null,
|
||||
command: string,
|
||||
canonicalArgs: string[],
|
||||
options: AgentRunResourceOptions,
|
||||
): Promise<RenderedCliResult | null> {
|
||||
const client = readAgentRunClientConfig();
|
||||
if (client.client.transport !== "target-trans") return null;
|
||||
if (config === null) return null;
|
||||
const execution = client.client.targetExecution;
|
||||
if (execution === null) throw new Error(`${client.sourcePath}: client.targetExecution is required for target-trans`);
|
||||
const { configPath, spec } = resolveAgentRunLaneTarget({ node: options.node, lane: options.lane });
|
||||
const activeTarget = process.env[execution.markerEnv] ?? null;
|
||||
if (activeTarget === spec.nodeId) return null;
|
||||
if (activeTarget !== null) {
|
||||
throw new Error(`${execution.markerEnv}=${activeTarget} cannot re-enter a second Target ${spec.nodeId}`);
|
||||
}
|
||||
const route = `${spec.nodeRoute}:${spec.nodeUnideskWorkspace}`;
|
||||
const targetArgs = [...canonicalArgs];
|
||||
if (!hasOption(targetArgs, "target")) targetArgs.push("--target", spec.nodeId);
|
||||
if (!hasOption(targetArgs, "lane")) targetArgs.push("--lane", spec.lane);
|
||||
const stdin = targetExecutionStdin(targetArgs);
|
||||
const capture = await runSshCommandCapture(config, route, [
|
||||
"argv",
|
||||
"env",
|
||||
`${execution.markerEnv}=${spec.nodeId}`,
|
||||
execution.executable,
|
||||
execution.cliPath,
|
||||
"agentrun",
|
||||
...targetArgs,
|
||||
], stdin);
|
||||
const disclosure = {
|
||||
transport: "target-trans",
|
||||
target: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
route,
|
||||
configPath,
|
||||
unideskWorkspace: spec.nodeUnideskWorkspace,
|
||||
markerEnv: execution.markerEnv,
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
if (capture.exitCode !== 0) {
|
||||
const detail = boundedRemoteFailure(capture.stderr || capture.stdout);
|
||||
return renderedCliResult(
|
||||
false,
|
||||
command,
|
||||
[
|
||||
`ERROR target-trans/${spec.nodeId}`,
|
||||
`route: ${route}`,
|
||||
`exitCode: ${capture.exitCode}`,
|
||||
`message: ${detail || "Target CLI did not return output"}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const output = capture.stdout.trimEnd();
|
||||
if (options.raw || options.output === "json") {
|
||||
const payload = parseTargetMachineOutput(output, "json");
|
||||
return renderMachine(command, attachTargetExecution(payload, disclosure), "json", machineOutputOk(payload));
|
||||
}
|
||||
if (options.output === "yaml") {
|
||||
const payload = parseTargetMachineOutput(output, "yaml");
|
||||
return renderMachine(command, attachTargetExecution(payload, disclosure), "yaml", machineOutputOk(payload));
|
||||
}
|
||||
return renderedCliResult(
|
||||
true,
|
||||
command,
|
||||
[`TargetExecution: trans ${route} lane=${spec.lane}`, output].filter(Boolean).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function hasOption(args: string[], name: string): boolean {
|
||||
const flag = `--${name}`;
|
||||
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
||||
}
|
||||
|
||||
function targetExecutionStdin(args: string[]): string | undefined {
|
||||
const stdinFlag = args.some((arg) => arg === "--prompt-stdin" || arg === "--stdin"
|
||||
|| arg === "--json-stdin" || arg === "--yaml-stdin");
|
||||
const stdinFile = optionIsStdin(args, "prompt-file") || optionIsStdin(args, "json-file")
|
||||
|| optionIsStdin(args, "yaml-file") || optionIsStdin(args, "file") || optionIsStdin(args, "filename")
|
||||
|| args.some((arg, index) => arg === "-f" && args[index + 1] === "-");
|
||||
return stdinFlag || stdinFile ? readFileSync(0, "utf8") : undefined;
|
||||
}
|
||||
|
||||
function optionIsStdin(args: string[], name: string): boolean {
|
||||
const flag = `--${name}`;
|
||||
return args.some((arg, index) => (arg === flag && args[index + 1] === "-") || arg === `${flag}=-`);
|
||||
}
|
||||
|
||||
function parseTargetMachineOutput(output: string, mode: "json" | "yaml"): unknown {
|
||||
if (output.length === 0) throw new Error(`Target CLI returned empty ${mode} output`);
|
||||
try {
|
||||
return mode === "json" ? JSON.parse(output) : Bun.YAML.parse(output);
|
||||
} catch (error) {
|
||||
throw new Error(`Target CLI returned invalid ${mode} output: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function attachTargetExecution(value: unknown, disclosure: Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return { ...(value as Record<string, unknown>), targetExecution: disclosure };
|
||||
}
|
||||
return { data: value, targetExecution: disclosure };
|
||||
}
|
||||
|
||||
function machineOutputOk(value: unknown): boolean {
|
||||
return !(typeof value === "object" && value !== null && (value as Record<string, unknown>).ok === false);
|
||||
}
|
||||
|
||||
function boundedRemoteFailure(value: string): string {
|
||||
return value
|
||||
.replace(/https?:\/\/[^@\s]+@/gu, "https://[redacted]@")
|
||||
.replace(/(token|password|authorization)[=:][^\s]+/giu, "$1=[redacted]")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, 600);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -87,6 +87,11 @@ export interface AgentRunResourceOptions {
|
||||
promptStdin: boolean;
|
||||
node: string | null;
|
||||
lane: string | null;
|
||||
target: string | null;
|
||||
targetWorkspace: string | null;
|
||||
repository: string | null;
|
||||
ref: string | null;
|
||||
mdtodoId: string | null;
|
||||
passthroughArgs: string[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user