diff --git a/.agents/skills/unidesk-agentrun/SKILL.md b/.agents/skills/unidesk-agentrun/SKILL.md index 768011cb..fcdc0c9b 100644 --- a/.agents/skills/unidesk-agentrun/SKILL.md +++ b/.agents/skills/unidesk-agentrun/SKILL.md @@ -29,6 +29,7 @@ bun scripts/cli.ts agentrun describe task/ --input -o json bun scripts/cli.ts agentrun describe aipodspec/ bun scripts/cli.ts agentrun events "修复 HWLAB 的 cicd" --after-seq 0 --expect 10 --timeout 120s --limit 20 bun scripts/cli.ts agentrun events run/ --detail-seq -o json +bun scripts/cli.ts agentrun events run/ --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 @@ -65,6 +66,16 @@ bun scripts/cli.ts agentrun result run/ --command --native-de 或固定 HTTPS 公共入口完成内部业务回归; - Workbench smoke 必须通过产品 `--over-api` 路径提交,并只从 `hwlab.event.v1` 的 Kafka retention replay 与 live SSE 判断进展和终态; +- Workbench 多轮复用验收使用 + `agentrun events run/ --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、窄查询、输出预算、错误码和可见性; diff --git a/.agents/skills/unidesk-devlevel/SKILL.md b/.agents/skills/unidesk-devlevel/SKILL.md index 733ad657..a5cc0845 100644 --- a/.agents/skills/unidesk-devlevel/SKILL.md +++ b/.agents/skills/unidesk-devlevel/SKILL.md @@ -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` 完成真实业务操作; diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index 11d74812..969b6191 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -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/ --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; diff --git a/scripts/src/agentrun-events-reuse-summary.test.ts b/scripts/src/agentrun-events-reuse-summary.test.ts new file mode 100644 index 00000000..5ae8d869 --- /dev/null +++ b/scripts/src/agentrun-events-reuse-summary.test.ts @@ -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> = []; + let seq = 1; + const append = (type: string, payload: Record): 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; + 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; + } +}); diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index 7041e48f..9828d210 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -298,9 +298,11 @@ export function agentRunHelpText(args: string[]): string { return [ `Usage: bun scripts/cli.ts agentrun events run/ [--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/ --detail-seq [-o json|yaml] ${AGENTRUN_RESOURCE_TRANSPORT_SCOPE}`, + ` bun scripts/cli.ts agentrun events run/ --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 --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") { diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index 0579ea44..d9e345bc 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -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/."); + } 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 ") : 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> { + const events: Record[] = []; + 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 { + 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 { const titleContext = await resolveTaskTitleContext(config, action); const ref = titleContext === null diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index 4f9d5ebd..b4184212 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -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; diff --git a/scripts/src/hwlab-native-development.test.ts b/scripts/src/hwlab-native-development.test.ts index 4a204f36..d162b384 100644 --- a/scripts/src/hwlab-native-development.test.ts +++ b/scripts/src/hwlab-native-development.test.ts @@ -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(); diff --git a/scripts/src/hwlab-native-development.ts b/scripts/src/hwlab-native-development.ts index 558c9327..40c78bf3 100644 --- a/scripts/src/hwlab-native-development.ts +++ b/scripts/src/hwlab-native-development.ts @@ -141,18 +141,25 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record