feat: 聚合 AgentRun 多轮复用验收

This commit is contained in:
pikastech
2026-07-21 09:56:45 +02:00
parent 32a171bd1d
commit aef54d0dda
9 changed files with 273 additions and 7 deletions
+11
View File
@@ -29,6 +29,7 @@ bun scripts/cli.ts agentrun describe task/<taskId> --input -o json
bun scripts/cli.ts agentrun describe aipodspec/<name>
bun scripts/cli.ts agentrun events "修复 HWLAB 的 cicd" --after-seq 0 --expect 10 --timeout 120s --limit 20
bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq> -o json
bun scripts/cli.ts agentrun events run/<runId> --aggregate reuse --native-development -o json
bun scripts/cli.ts agentrun logs "修复 HWLAB 的 cicd" --tail 120
bun scripts/cli.ts agentrun result "修复 HWLAB 的 cicd"
bun scripts/cli.ts agentrun get attempts --task <taskId>
@@ -65,6 +66,16 @@ bun scripts/cli.ts agentrun result run/<runId> --command <commandId> --native-de
或固定 HTTPS 公共入口完成内部业务回归;
- Workbench smoke 必须通过产品 `--over-api` 路径提交,并只从
`hwlab.event.v1` 的 Kafka retention replay 与 live SSE 判断进展和终态;
- Workbench 多轮复用验收使用
`agentrun events run/<runId> --aggregate reuse`
- CLI 自动读取全部有界 event page,不得逐页人工查询后拼接;
- 一个 session 只允许一个 run 和一个 runner Job
- 首个 turn 只允许一次 source fetch 和 resource bundle materialization
- 后续每个 turn 必须各有一个 `codex-app-server:reused`
`thread/resume:completed`
- command created、backend turn started、command terminal 和 terminal status
的数量必须一致;
- 该摘要只用于执行复用诊断,不替代 Workbench Kafka/SSE 产品终态权威;
- AgentRun L1 CLI 问题按即时修复规则处理:
- 适用于 manager、资源原语和 Workbench `--over-api` CLI
- 问题范围包括 parser、lifecycle、窄查询、输出预算、错误码和可见性;
+11 -2
View File
@@ -79,6 +79,11 @@ description: >-
- 任务结束前必须重新读取 lifecycle status,不能凭启动返回值推断服务仍在运行;
- 多服务 L1 必须优先使用项目 CLI 的聚合 status,一次返回 API、Worker 和 Web 状态,禁止由调用方逐个查询后人工拼接结论;
- 项目暂缺聚合 status 时,应在当前任务内补齐该 CLI 能力,不能把逐服务查询固化为长期验收流程。
- 多轮 session 的 L1 复用验收也必须使用项目 CLI 聚合查询:
- 一个 session 只允许一个 run 和一个 runner Job
- N 个 turn 只允许首轮 source fetch 和物化资源;
- 后续 turn 必须复用 backend process 并完成 thread resume
- 禁止逐条查询 run、command、runner 和 event 后人工拼接结论。
- CLI 显式使用项目 native `--over-api` transport,经 native API 调用 Worker。
- Web 使用 `$unidesk-webdev` 的受控入口访问 owning YAML 固定端口上的 native Web。
- 微服务项目只启动当前微服务的前端、API、Worker及必要依赖。
@@ -93,8 +98,12 @@ description: >-
- 任务 worktree 可以承载短反馈迭代,但不得成为 L1 完成运行面;
- 宣称 L1 完成前,已验收语义必须以边界明确的提交合入 owning YAML 选定的固定 L1 workspace
- 合入后必须通过项目 lifecycle CLI 重载受影响进程,并从固定端口重新回归;
- lifecycle status 必须披露运行进程选中的源码 workspace 和 commit,并与固定 L1 workspace 一致;
- 源码 provenance 缺失或不一致时不得用页面结果判定修复通过,应先修复受控 CLI 可见性或按 owning YAML 重新选择源码
- lifecycle status 必须披露运行进程选中的源码 workspace、runtime commit
和远端可获取的 runner source commit
- runtime workspace 与 runner source commit 的 provenance 漂移只记录
非阻塞 warning,不得阻断核心业务;
- runner source Git 对象不存在或无法获取时仍是业务 blocker,必须从
owning YAML 声明的 remote/source branch 修正,禁止改用本地独有 `HEAD`
- 仍有已验收语义只存在于任务 worktree、未提交 diff 或临时产物时,L1 必须保持未完成;
- 详细收口合同以 `docs/reference/dev-environment.md#l1-固定工作区收口` 为唯一权威;
- CLI 必须通过 native `--over-api` 完成真实业务操作;
+16
View File
@@ -274,6 +274,22 @@ Workbench 浏览器回归需求以 UniDesk OA [PJ2026-010401 Web工作台](../..
- Provider catalog 成功且当前选择明确为 `configured=false` 时,前端选择首个 configured profile 并持久化;
- Provider catalog 请求失败或没有 configured profile 时保留当前选择,不禁用 Composer、不增加前置门禁;真实提交若被 provider 拒绝,按原业务错误投影。
- Workbench L1 runner 源码与多轮复用:
- API、Worker 和 Web 可以从 YAML 选中的固定 L1 workspace 热加载;
- runner Git bundle 的 source commit 必须解析自 owning YAML 声明的
`sourceWorkspace.git.remoteName``sourceBranch`
- 禁止把固定 workspace 的本地独有或未推送 `HEAD` 作为 runner fetch ref
因为 Gitea 无法获取该对象;
- runtime workspace 与 runner source 的 provenance 漂移只记录
非阻塞 warning,不得阻塞核心业务;
- runner source Git 对象不存在或无法获取仍是阻塞错误;
- 多轮回归使用
`agentrun events run/<runId> --aggregate reuse --native-development`
一次证明一个 run、一个 runner Job、首轮一次源码获取和物化、后续 turn
的 app-server/thread resume 复用,以及全部 command 终态;
- AgentRun 复用摘要只证明执行资源复用,Workbench 用户终态仍以
Kafka retention replay 与纯 SSE 投影为唯一权威。
- Workbench Cloud 内部统一密钥认证:
- 认证 authority
- Workbench API 发往 Cloud API 的 `Authorization` 与 Cloud API internal dispatch 校验必须消费同一个 YAML SecretRef
@@ -0,0 +1,83 @@
import { rmSync } from "node:fs";
import { expect, test } from "bun:test";
import { runAgentRunCommand } from "./agentrun";
const RUN_ID = "run_reuse_summary_fixture";
test("AgentRun reuse summary paginates all events and reports one runner across turns", async () => {
const commandIds = Array.from({ length: 5 }, (_, index) => `cmd_${index + 1}`);
const events: Array<Record<string, unknown>> = [];
let seq = 1;
const append = (type: string, payload: Record<string, unknown>): void => {
events.push({ id: `evt_${seq}`, seq, type, payload });
seq += 1;
};
for (let index = 0; index < 485; index += 1) append("backend_status", { phase: `noise-${index}` });
append("backend_status", { phase: "runner-job-created", commandId: commandIds[0], runnerJobId: "rjob_one", runnerId: "runner_one", jobName: "runner-job-one" });
append("backend_status", { phase: "runner-source-fetch-started", commandId: commandIds[0] });
append("backend_status", { phase: "resource-bundle-materialized", commandId: commandIds[0] });
for (const [index, commandId] of commandIds.entries()) {
append("backend_status", { phase: "command-created", commandId });
if (index > 0) {
append("backend_status", { phase: "codex-app-server:reused", commandId });
append("backend_status", { phase: "thread/resume:completed", commandId });
}
append("backend_status", { phase: "backend-turn-started", commandId });
append("backend_status", { phase: "command-terminal", commandId, terminalStatus: "completed" });
append("terminal_status", { commandId, terminalStatus: "completed" });
}
const requestedAfterSeq: number[] = [];
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url);
const afterSeq = Number(url.searchParams.get("afterSeq") ?? "0");
const limit = Number(url.searchParams.get("limit") ?? "500");
requestedAfterSeq.push(afterSeq);
return Response.json({ ok: true, data: { items: events.filter((event) => Number(event.seq) > afterSeq).slice(0, limit) } });
},
});
const configPath = `/tmp/unidesk-agentrun-reuse-summary-${process.pid}-${Date.now()}.yaml`;
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
const previousKey = process.env.HWLAB_API_KEY;
process.env.AGENTRUN_CLIENT_CONFIG = configPath;
process.env.HWLAB_API_KEY = "fixture-secret";
await Bun.write(configPath, `version: 1\nkind: AgentRunConfig\nmetadata:\n name: agentrun\nmanager:\n baseUrl: ${server.url.href}\n timeoutMs: 15000\nauth:\n env: HWLAB_API_KEY\n file: /tmp/hwlab-api-key.env\n header: Authorization\n scheme: Bearer\nclient:\n role: render-only\n transport: direct-http\n sessionPolicy:\n tenantId: unidesk\n projectId: test\n providerId: NC01\n backendProfile: codex\n workspaceRef:\n kind: opaque\n executionPolicy:\n sandbox: workspace-write\n approval: never\n timeoutMs: 600000\n network: enabled\n secretScope:\n allowCredentialEcho: false\n`);
try {
const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--aggregate", "reuse", "-o", "json"]);
if (!result.ok) throw new Error("renderedText" in result ? result.renderedText : "AgentRun CLI failed");
const payload = JSON.parse("renderedText" in result ? result.renderedText : "{}") as Record<string, unknown>;
expect(payload).toMatchObject({
kind: "AgentRunReuseSummary",
runId: RUN_ID,
eventCount: events.length,
pageCount: 2,
commandCount: 5,
runnerJobCreated: 1,
runnerSourceFetchStarted: 1,
resourceBundleMaterialized: 1,
codexAppServerReused: 4,
threadResumeCompleted: 4,
backendTurnStarted: 5,
commandTerminal: 5,
reusePassed: true,
valuesPrinted: false,
});
expect(payload.commandIds).toEqual(commandIds);
expect((payload.terminalStatuses as unknown[]).length).toBe(5);
expect(requestedAfterSeq).toEqual([0, 500]);
const invalid = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--aggregate", "reuse", "--after-seq", "1"]);
expect(invalid.ok).toBe(false);
expect("renderedText" in invalid ? invalid.renderedText : "").toContain("--aggregate reuse cannot be combined with --after-seq");
} finally {
server.stop(true);
rmSync(configPath, { force: 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;
}
});
+2
View File
@@ -298,9 +298,11 @@ export function agentRunHelpText(args: string[]): string {
return [
`Usage: bun scripts/cli.ts agentrun events run/<runId> [--after-seq N] [--expect N [--timeout 120s]] [--limit 100] [-o json|yaml] [--full|--raw] ${AGENTRUN_RESOURCE_TRANSPORT_SCOPE}`,
` bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq> [-o json|yaml] ${AGENTRUN_RESOURCE_TRANSPORT_SCOPE}`,
` bun scripts/cli.ts agentrun events run/<runId> --aggregate reuse [-o json|yaml] ${AGENTRUN_RESOURCE_TRANSPORT_SCOPE}`,
"",
"--expect waits in one manager-held request until N new events, target completion, or timeout; --timeout defaults to 120s. --limit remains an independent output budget.",
"--detail-seq returns one bounded Secret-safe EventDetail projection. Use the separate --after-seq <seq-1> --limit 1 --full|--raw path only for explicit complete disclosure.",
"--aggregate reuse automatically reads bounded pages and reports one-run/one-runner multi-turn reuse without raw event disclosure.",
].join("\n");
}
if (verb === "logs") {
+132 -1
View File
@@ -188,6 +188,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
expect: null,
timeoutMs: 120_000,
eventDetailSeq: null,
aggregate: null,
tail: null,
fullText: false,
reason: null,
@@ -207,7 +208,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
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", "--expect", "--timeout", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--title", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]);
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", "--expect", "--timeout", "--detail-seq", "--aggregate", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--title", "--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", "--native-development"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
@@ -273,6 +274,23 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
);
}
}
if (options.aggregate !== null) {
const conflictingOptions = ["--after-seq", "--expect", "--timeout", "--detail-seq", "--limit", "--full", "--raw"]
.filter((flag) => resourceOptionPresent(args, flag));
if (conflictingOptions.length > 0) {
throw new AgentRunRestError(
"validation-failed",
`--aggregate ${options.aggregate} cannot be combined with ${conflictingOptions.join(", ")}.`,
{
details: {
conflictingOptions: ["--aggregate", ...conflictingOptions],
recoveryActions: ["Use --aggregate reuse by itself; the CLI reads all bounded event pages and emits only the reuse summary."],
valuesPrinted: false,
},
},
);
}
}
if (resourceOptionPresent(args, "--timeout") && options.expect === null) {
throw new AgentRunRestError(
"validation-failed",
@@ -309,6 +327,9 @@ function validateResourceOptionsForVerb(verb: AgentRunResourceVerb, options: Age
},
);
}
if (options.aggregate !== null && verb !== "events") {
throw new AgentRunRestError("validation-failed", "--aggregate is supported only by agentrun events run/<runId>.");
}
if (options.expect !== null && verb !== "events") {
throw new AgentRunRestError(
"validation-failed",
@@ -342,6 +363,10 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri
else if (flag === "--full-text") options.fullText = true;
else if (flag === "--prompt-stdin" || flag === "--stdin") options.promptStdin = true;
else if (flag === "--native-development") options.nativeDevelopment = true;
else if (flag === "--aggregate") {
if (value !== "reuse") throw new Error("--aggregate must be reuse");
options.aggregate = value;
}
else if (flag === "--limit") options.limit = parseNonNegativeInt(value, "--limit", 20, 500);
else if (flag === "--cursor") options.cursor = parseNonNegativeInt(value, "--cursor", 0, Number.MAX_SAFE_INTEGER);
else if (flag === "--queue") options.queue = requiredValue(value, flag);
@@ -644,6 +669,11 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri
const runId = titleContext === null
? ref?.kind === "run" ? ref.name : options.runId ?? requiredContext("events", "--run <runId>")
: requiredTitleResource(titleContext, "runId", "events");
if (options.aggregate === "reuse") {
const summary = await aggregateAgentRunReuseEvents(config, runId);
if (options.output === "json" || options.output === "yaml") return renderMachine(command, summary, options.output, summary.reusePassed === true);
return renderedCliResult(summary.reusePassed === true, command, renderAgentRunReuseSummary(summary));
}
if (options.eventDetailSeq !== null) {
const detailSeq = options.eventDetailSeq;
const result = await runAgentRunRestCommand(config, "runs", ["events", runId, "--after-seq", String(detailSeq - 1), "--limit", "1"]);
@@ -721,6 +751,107 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri
});
}
const AGENTRUN_REUSE_EVENT_PAGE_SIZE = 500;
const AGENTRUN_REUSE_EVENT_MAX = 10_000;
export async function aggregateAgentRunReuseEvents(config: UniDeskConfig | null, runId: string): Promise<Record<string, unknown>> {
const events: Record<string, unknown>[] = [];
let afterSeq = 0;
let pageCount = 0;
while (events.length < AGENTRUN_REUSE_EVENT_MAX) {
const result = await runAgentRunRestCommand(config, "runs", ["events", runId, "--after-seq", String(afterSeq), "--limit", String(AGENTRUN_REUSE_EVENT_PAGE_SIZE)]);
const page = arrayRecords(record(innerData(result)).items ?? innerData(result));
pageCount += 1;
events.push(...page);
if (page.length < AGENTRUN_REUSE_EVENT_PAGE_SIZE) break;
const nextAfterSeq = nonNegativeIntegerOrNull(page.at(-1)?.seq);
if (nextAfterSeq === null || nextAfterSeq <= afterSeq) {
throw new AgentRunRestError("schema-mismatch", `AgentRun event pagination did not advance after seq ${afterSeq}.`);
}
afterSeq = nextAfterSeq;
}
if (events.length >= AGENTRUN_REUSE_EVENT_MAX) {
throw new AgentRunRestError("schema-mismatch", `run/${runId} reuse aggregation exceeded the ${AGENTRUN_REUSE_EVENT_MAX} event safety bound.`);
}
const phases = events.map((event) => record(event.payload)).map((payload) => stringOrNull(payload.phase));
const phaseCount = (phase: string): number => phases.filter((value) => value === phase).length;
const commandIds = events
.filter((event) => record(event.payload).phase === "command-created")
.map((event) => stringOrNull(record(event.payload).commandId))
.filter((value): value is string => value !== null)
.filter((value, index, values) => values.indexOf(value) === index);
const terminalStatuses = events
.filter((event) => event.type === "terminal_status")
.map((event) => {
const payload = record(event.payload);
return {
commandId: stringOrNull(payload.commandId),
status: stringOrNull(payload.terminalStatus) ?? stringOrNull(payload.status) ?? stringOrNull(payload.state),
};
});
const runnerJobEvent = events.find((event) => record(event.payload).phase === "runner-job-created");
const runnerJobPayload = record(runnerJobEvent?.payload);
const runnerJobCreated = phaseCount("runner-job-created");
const runnerSourceFetchStarted = phaseCount("runner-source-fetch-started");
const resourceBundleMaterialized = phaseCount("resource-bundle-materialized");
const codexAppServerReused = phaseCount("codex-app-server:reused");
const threadResumeCompleted = phaseCount("thread/resume:completed");
const backendTurnStarted = phaseCount("backend-turn-started");
const commandTerminal = phaseCount("command-terminal");
const expectedReuseCount = Math.max(0, commandIds.length - 1);
const reusePassed = commandIds.length > 0
&& runnerJobCreated === 1
&& runnerSourceFetchStarted === 1
&& resourceBundleMaterialized === 1
&& codexAppServerReused === expectedReuseCount
&& threadResumeCompleted === expectedReuseCount
&& backendTurnStarted === commandIds.length
&& commandTerminal === commandIds.length
&& terminalStatuses.length === commandIds.length;
return {
kind: "AgentRunReuseSummary",
runId,
eventCount: events.length,
pageCount,
commandCount: commandIds.length,
commandIds,
runnerJobCreated,
runnerJob: {
id: stringOrNull(runnerJobPayload.runnerJobId),
jobName: stringOrNull(runnerJobPayload.jobName),
runnerId: stringOrNull(runnerJobPayload.runnerId),
},
runnerSourceFetchStarted,
resourceBundleMaterialized,
codexAppServerReused,
threadResumeCompleted,
backendTurnStarted,
commandTerminal,
terminalStatuses,
reusePassed,
valuesPrinted: false,
};
}
function renderAgentRunReuseSummary(summary: Record<string, unknown>): string {
const runnerJob = record(summary.runnerJob);
const commandIds = Array.isArray(summary.commandIds)
? summary.commandIds.filter((value): value is string => typeof value === "string")
: [];
return [
`AgentRun reuse ${summary.reusePassed === true ? "passed" : "failed"}`,
`Run: ${String(summary.runId)}`,
`Events: ${String(summary.eventCount)} across ${String(summary.pageCount)} page(s)`,
`Commands: ${String(summary.commandCount)} (${commandIds.join(", ")})`,
`Runner Job: created=${String(summary.runnerJobCreated)} jobName=${stringOrNull(runnerJob.jobName) ?? "-"}`,
`Assembly: sourceFetch=${String(summary.runnerSourceFetchStarted)} materialized=${String(summary.resourceBundleMaterialized)}`,
`Reuse: appServer=${String(summary.codexAppServerReused)} threadResume=${String(summary.threadResumeCompleted)}`,
`Turns: started=${String(summary.backendTurnStarted)} terminal=${String(summary.commandTerminal)} terminalStatus=${Array.isArray(summary.terminalStatuses) ? summary.terminalStatuses.length : 0}`,
].join("\n");
}
export async function resourceLogs(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise<RenderedCliResult> {
const titleContext = await resolveTaskTitleContext(config, action);
const ref = titleContext === null
+1
View File
@@ -79,6 +79,7 @@ export interface AgentRunResourceOptions {
expect: number | null;
timeoutMs: number;
eventDetailSeq: number | null;
aggregate: "reuse" | null;
tail: number | null;
fullText: boolean;
reason: string | null;
+7 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { hwlabHwpodNativeDevelopmentEnvironment, hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp } from "./hwlab-native-development";
import { hwlabHwpodNativeDevelopmentEnvironment, hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp, hwlabNativeDevelopmentSourceCommitRef } from "./hwlab-native-development";
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { resolveConfigRef } from "./ops/config-refs";
@@ -56,6 +56,12 @@ test("L1 Workbench exposure uses YAML HTTPS origin and separate bind/probe addre
expect(hwlabNativeDevelopmentHelp()).toMatchObject({ ok: true, command: "hwlab nodes native-development workbench" });
});
test("L1 Workbench runner source follows the YAML-declared remote branch", () => {
const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
expect(hwlabNativeDevelopmentSourceCommitRef(spec)).toBe(`refs/remotes/${spec.sourceWorkspace!.git!.remoteName}/${spec.sourceBranch}`);
expect(hwlabNativeDevelopmentSourceCommitRef(spec)).toBe("refs/remotes/origin/v0.3");
});
test("L1 HWPOD spec registry resolves PostgreSQL authority and frozen built-ins from owning YAML", () => {
const native = hwlabRuntimeLaneSpecForNode("v03", "NC01").nativeDevelopment?.hwpod;
expect(native).toBeDefined();
+10 -3
View File
@@ -141,18 +141,25 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record<string,
}
function workbenchEnvironment(spec: HwlabRuntimeLaneSpec, native: HwlabRuntimeNativeDevelopmentSpec["workbench"]): NodeJS.ProcessEnv {
const sourceCommitResult = runCommand(["git", "rev-parse", "HEAD"], spec.workspace, { timeoutMs: 5_000 });
if (sourceCommitResult.exitCode !== 0 || !/^[0-9a-f]{40}$/iu.test(sourceCommitResult.stdout.trim())) throw new Error(`cannot resolve current HWLAB source commit from ${spec.workspace}`);
const sourceRef = hwlabNativeDevelopmentSourceCommitRef(spec);
const sourceCommitResult = runCommand(["git", "rev-parse", "--verify", `${sourceRef}^{commit}`], spec.workspace, { timeoutMs: 5_000 });
if (sourceCommitResult.exitCode !== 0 || !/^[0-9a-f]{40}$/iu.test(sourceCommitResult.stdout.trim())) throw new Error(`cannot resolve HWLAB L1 runner source commit from ${sourceRef} in ${spec.workspace}`);
return hwlabNativeDevelopmentEnvironment(native, spec, sourceCommitResult.stdout.trim());
}
export function hwlabNativeDevelopmentSourceCommitRef(spec: HwlabRuntimeLaneSpec): string {
const remoteName = spec.sourceWorkspace?.git?.remoteName;
if (!remoteName) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.sourceWorkspace.git.remoteName is required for L1 runner source authority`);
return `refs/remotes/${remoteName}/${spec.sourceBranch}`;
}
export function hwlabNativeDevelopmentEnvironment(native: HwlabRuntimeNativeDevelopmentSpec["workbench"], spec: HwlabRuntimeLaneSpec, sourceCommit: string): NodeJS.ProcessEnv {
const codeAgent = spec.codeAgentRuntime;
if (codeAgent === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.codeAgentRuntime is required`);
const kafkaBridge = codeAgent.kafkaEventBridge;
if (kafkaBridge === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.${native.kafka.configRef} is required`);
if (native.kafka.hwlabEventConsumerGroupId === kafkaBridge.hwlabEventConsumerGroupId) throw new Error("native Workbench Kafka consumer group must be distinct from the Cloud API live SSE consumer group");
if (!/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error("sourceCommit must be the current HWLAB workspace 40-hex git SHA");
if (!/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error("sourceCommit must be the HWLAB L1 runner source authority 40-hex git SHA");
const providerSecretNames = codeAgent.providerSecretNames;
if (providerSecretNames === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.codeAgentRuntime.providerSecretNames is required`);
const defaultProviderKey = codeAgent.defaultProviderProfile.toLowerCase().replace(/[^a-z0-9]+/gu, "-");