fix: resolve gitbundle source from repo ref

This commit is contained in:
Codex
2026-06-08 15:04:08 +08:00
parent e753b853ce
commit 32318ea881
11 changed files with 161 additions and 66 deletions
+3 -1
View File
@@ -60,6 +60,7 @@ export interface GitBundleItemRef extends JsonRecord {
name?: string;
repoUrl?: string;
commitId?: string;
ref?: string;
subpath: string;
targetPath: string;
}
@@ -67,7 +68,8 @@ export interface GitBundleItemRef extends JsonRecord {
export interface ResourceBundleRef extends JsonRecord {
kind: "gitbundle";
repoUrl: string;
commitId: string;
commitId?: string;
ref?: string;
bundles: GitBundleItemRef[];
promptRefs?: Array<{
name: string;
+18 -7
View File
@@ -85,10 +85,11 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
const kind = requiredString(record, "kind");
if (kind !== "gitbundle") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be gitbundle", { httpStatus: 400 });
const repoUrl = requiredString(record, "repoUrl");
const commitId = requiredString(record, "commitId");
validateCommitId(commitId, "resourceBundleRef.commitId");
const commitId = optionalString(record.commitId);
if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId");
const ref = validateGitRef(record.ref, "resourceBundleRef.ref");
rejectLegacyResourceBundleFields(record);
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, commitId, bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId) };
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) };
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 });
if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 });
@@ -104,7 +105,7 @@ function rejectLegacyResourceBundleFields(record: JsonRecord): void {
}
}
function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId: string): ResourceBundleRef["bundles"] {
function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId?: string, defaultRef?: string): ResourceBundleRef["bundles"] {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must be an array", { httpStatus: 400 });
if (value.length === 0) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at least one entry", { httpStatus: 400 });
if (value.length > 64) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at most 64 entries", { httpStatus: 400 });
@@ -115,15 +116,16 @@ function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defa
if (name) validateResourceName(name, `resourceBundleRef.bundles[${index}].name`);
const repoUrl = optionalString(record.repoUrl) ?? defaultRepoUrl;
const commitId = optionalString(record.commitId) ?? defaultCommitId;
validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`);
if (commitId) validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`);
const ref = validateGitRef(record.ref, `resourceBundleRef.bundles[${index}].ref`) ?? (commitId ? undefined : defaultRef);
const subpath = validateBundleSubpath(requiredString(record, "subpath"), `resourceBundleRef.bundles[${index}].subpath`);
const rawTargetPath = typeof record.targetPath === "string" ? record.targetPath : record.target_path;
if (typeof rawTargetPath !== "string" || rawTargetPath.trim().length === 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path is required`, { httpStatus: 400 });
const targetPath = validateWorkspaceRelativePath(rawTargetPath.trim(), `resourceBundleRef.bundles[${index}].target_path`);
const identity = `${repoUrl}\0${commitId}\0${subpath}\0${targetPath}`;
const identity = `${repoUrl}\0${commitId ?? ""}\0${ref ?? ""}\0${subpath}\0${targetPath}`;
if (seen.has(identity)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] duplicates an earlier bundle target`, { httpStatus: 400 });
seen.add(identity);
return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId === defaultCommitId ? {} : { commitId }), subpath, targetPath };
return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId && commitId !== defaultCommitId ? { commitId } : {}), ...(ref && ref !== defaultRef ? { ref } : {}), subpath, targetPath };
});
}
@@ -131,6 +133,15 @@ function validateCommitId(commitId: string, fieldName: string): void {
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", `${fieldName} must be a full 40-character git commit sha`, { httpStatus: 400 });
}
function validateGitRef(value: unknown, fieldName: string): string | undefined {
const ref = optionalString(value);
if (!ref) return undefined;
if (ref.length > 200 || ref.startsWith("-") || ref.includes("..") || ref.includes("@{") || ref.endsWith("/") || ref.endsWith(".") || /[\s~^:?*[\\\x00-\x1f\x7f]/u.test(ref)) {
throw new AgentRunError("schema-invalid", `${fieldName} must be a bounded git ref name`, { httpStatus: 400 });
}
return ref;
}
function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleRef["promptRefs"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must be an array", { httpStatus: 400 });
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must contain at most 16 entries", { httpStatus: 400 });
+3 -2
View File
@@ -171,10 +171,11 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
return {
kind: run.resourceBundleRef.kind,
repoUrl: run.resourceBundleRef.repoUrl,
commitId: run.resourceBundleRef.commitId,
commitId: run.resourceBundleRef.commitId ?? null,
ref: run.resourceBundleRef.ref ?? null,
bundles: {
count: run.resourceBundleRef.bundles.length,
items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, ref: item.ref ?? run.resourceBundleRef?.ref ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
valuesPrinted: false,
},
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
+3 -2
View File
@@ -731,10 +731,11 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
return {
kind: resourceBundleRef.kind,
repoUrl: resourceBundleRef.repoUrl,
commitId: resourceBundleRef.commitId,
commitId: resourceBundleRef.commitId ?? null,
ref: resourceBundleRef.ref ?? null,
bundles: {
count: resourceBundleRef.bundles.length,
items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, commitId: item.commitId ?? resourceBundleRef.commitId, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, ref: item.ref ?? resourceBundleRef.ref ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
valuesPrinted: false,
},
promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
+65 -18
View File
@@ -43,14 +43,24 @@ interface MaterializedSkillRef {
interface GitCheckout {
repoUrl: string;
commitId: string;
requestedCommitId?: string;
requestedRef?: string;
checkoutPath: string;
treeId: string;
}
interface GitBundleSource {
repoUrl: string;
commitId?: string;
ref?: string;
}
interface MaterializedGitBundle {
name: string | null;
repoUrl: string;
commitId: string;
requestedCommitId: string | null;
requestedRef: string | null;
subpath: string;
targetPath: string;
sourceKind: "file" | "directory";
@@ -67,18 +77,19 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
await rm(assemblyRoot, { recursive: true, force: true });
await mkdir(checkoutRoot, { recursive: true });
await mkdir(workspacePath, { recursive: true });
const defaultSource = defaultGitBundleSource(resourceBundleRef, env);
const checkoutCache = new Map<string, Promise<GitCheckout>>();
const checkoutFor = (repoUrl: string, commitId: string) => {
const key = stableHash({ repoUrl, commitId });
const checkoutFor = (source: GitBundleSource) => {
const key = stableHash(gitSourceIdentity(source));
let checkout = checkoutCache.get(key);
if (!checkout) {
checkout = checkoutGitCommit(checkoutRoot, repoUrl, commitId);
checkout = checkoutGitSource(checkoutRoot, source);
checkoutCache.set(key, checkout);
}
return checkout;
};
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, checkoutFor);
const defaultCheckout = await checkoutFor(resourceBundleRef.repoUrl, resourceBundleRef.commitId);
const defaultCheckout = await checkoutFor(defaultSource);
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor);
const tools = await prepareGitBundleTools(workspacePath);
const skills = await discoverGitBundleSkills(workspacePath);
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
@@ -92,7 +103,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
phase: "resource-bundle-materialized",
kind: "gitbundle",
repoUrl: resourceBundleRef.repoUrl,
commitId: resourceBundleRef.commitId,
commitId: defaultCheckout.commitId,
requestedCommitId: resourceBundleRef.commitId ?? null,
requestedRef: defaultCheckout.requestedRef ?? null,
treeId: defaultCheckout.treeId,
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
workspacePath: pathSummary(workspacePath),
@@ -110,26 +123,56 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
};
}
async function checkoutGitCommit(checkoutRoot: string, repoUrl: string, commitId: string): Promise<GitCheckout> {
const checkoutPath = path.join(checkoutRoot, stableHash({ repoUrl, commitId }).slice(0, 16));
function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitBundleSource {
const ref = optionalNonEmpty(resourceBundleRef.ref) ?? optionalNonEmpty(env.AGENTRUN_RESOURCE_BUNDLE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_BRANCH);
if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref };
const commitId = optionalNonEmpty(resourceBundleRef.commitId);
if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId };
return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD" };
}
function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource {
const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl;
const ref = optionalNonEmpty(bundle.ref);
if (ref) return { repoUrl, ref };
const commitId = optionalNonEmpty(bundle.commitId);
if (commitId) return { repoUrl, commitId };
if (repoUrl === defaultSource.repoUrl) return defaultSource;
if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref };
if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId };
return { repoUrl, ref: "HEAD" };
}
async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource): Promise<GitCheckout> {
const checkoutPath = path.join(checkoutRoot, stableHash(gitSourceIdentity(source)).slice(0, 16));
await mkdir(checkoutPath, { recursive: true });
await git(["init"], checkoutPath);
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
await git(["remote", "add", "origin", repoUrl], checkoutPath);
await git(["fetch", "--depth", "1", "origin", commitId], checkoutPath);
await git(["checkout", "--detach", commitId], checkoutPath);
await git(["remote", "add", "origin", source.repoUrl], checkoutPath);
if (source.ref) {
await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath);
await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
} else if (source.commitId) {
await git(["fetch", "--depth", "1", "origin", source.commitId], checkoutPath);
await git(["checkout", "--detach", source.commitId], checkoutPath);
} else {
throw new AgentRunError("schema-invalid", "gitbundle source must include repo ref or commit", { httpStatus: 400 });
}
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
if (actualCommit !== commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: commitId, actualCommit } });
if (source.commitId && actualCommit !== source.commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: source.commitId, actualCommit } });
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
return { repoUrl, commitId, checkoutPath, treeId };
return { repoUrl: source.repoUrl, commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId };
}
async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, checkoutFor: (repoUrl: string, commitId: string) => Promise<GitCheckout>): Promise<MaterializedGitBundle[]> {
function gitSourceIdentity(source: GitBundleSource): JsonRecord {
return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null };
}
async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource) => Promise<GitCheckout>): Promise<MaterializedGitBundle[]> {
const items: MaterializedGitBundle[] = [];
for (const [index, bundle] of resourceBundleRef.bundles.entries()) {
const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl;
const commitId = bundle.commitId ?? resourceBundleRef.commitId;
const checkout = await checkoutFor(repoUrl, commitId);
const gitSource = bundleGitSource(bundle, resourceBundleRef, defaultSource);
const checkout = gitSource === defaultSource ? defaultCheckout : await checkoutFor(gitSource);
const source = resolveBundlePath(checkout.checkoutPath, bundle.subpath, `bundles[${index}].subpath`);
const target = resolveWorkspaceTargetPath(workspacePath, bundle.targetPath, `bundles[${index}].target_path`);
let sourceStat;
@@ -141,11 +184,15 @@ async function materializeGitBundles(workspacePath: string, resourceBundleRef: R
await mkdir(path.dirname(target), { recursive: true });
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true, force: true, dereference: false });
items.push({ name: bundle.name ?? null, repoUrl, commitId, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null });
items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null });
}
return items;
}
function optionalNonEmpty(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
async function prepareGitBundleTools(workspacePath: string): Promise<{ binPath?: string; event: JsonRecord }> {
const binPath = path.join(workspacePath, "tools");
let entries;
+9 -3
View File
@@ -95,7 +95,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
if (!materializationAttempted) {
materializationAttempted = true;
try {
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId));
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId, claimed.workspaceRef));
if (materialized) {
workspacePath = materialized.workspacePath;
resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized);
@@ -145,8 +145,14 @@ function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.Pr
};
}
function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string): NodeJS.ProcessEnv {
return { ...env, AGENTRUN_RUN_ID: env.AGENTRUN_RUN_ID ?? runId, AGENTRUN_ATTEMPT_ID: env.AGENTRUN_ATTEMPT_ID ?? attemptId };
function resourceMaterializationEnv(env: NodeJS.ProcessEnv, runId: string, attemptId: string, workspaceRef: RunRecord["workspaceRef"]): NodeJS.ProcessEnv {
const workspaceBranch = typeof workspaceRef.branch === "string" && workspaceRef.branch.trim().length > 0 ? workspaceRef.branch.trim() : undefined;
return {
...env,
AGENTRUN_RUN_ID: env.AGENTRUN_RUN_ID ?? runId,
AGENTRUN_ATTEMPT_ID: env.AGENTRUN_ATTEMPT_ID ?? attemptId,
...(workspaceBranch ? { AGENTRUN_WORKSPACE_BRANCH: env.AGENTRUN_WORKSPACE_BRANCH ?? workspaceBranch, AGENTRUN_WORKSPACE_REF: env.AGENTRUN_WORKSPACE_REF ?? workspaceBranch } : {}),
};
}
function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited<ReturnType<typeof materializeResourceBundle>>): NodeJS.ProcessEnv | undefined {
+34 -5
View File
@@ -11,7 +11,8 @@ import type { JsonRecord, ResourceBundleRef } from "../../common/types.js";
import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js";
const execFile = promisify(execFileCallback);
type LocalBundle = { repoUrl: string; commitId: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] };
type LocalBundle = { repoUrl: string; commitId: string; branch?: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] };
type RefResolvedBundle = LocalBundle & { branch: string; latestCommitId: string };
const selfTest: SelfTestCase = async (context) => {
const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8");
@@ -95,6 +96,21 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
assertNoSecretLeak(resultEnvelope);
const refBundle = await createRefResolvedLocalGitBundle(context);
const refRun = await createHwlabRun(client, context, refBundle, "hwlab-session-ref-resolution", "resolve bundle from branch ref", "hwlab-command-ref-resolution");
const refRunResult = await runOnce({ managerUrl: server.baseUrl, runId: refRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-ref-resolution") }, oneShot: true }) as JsonRecord;
assert.equal(refRunResult.terminalStatus, "completed");
const refEnvelope = await client.get(`/api/v1/runs/${refRun.runId}/commands/${refRun.commandId}/result`) as JsonRecord;
const refResource = refEnvelope.resourceBundleRef as JsonRecord;
assert.equal(refResource.commitId, refBundle.commitId, "result summary keeps the request commit as a non-authoritative hint");
const refMaterialized = refResource.materialized as JsonRecord;
assert.equal(refMaterialized.commitId, refBundle.latestCommitId, "materialized bundle must resolve the current workspaceRef.branch commit");
assert.equal(refMaterialized.requestedCommitId, refBundle.commitId);
assert.equal(refMaterialized.requestedRef, refBundle.branch);
const refBundleCommits = (((refMaterialized.bundles as JsonRecord).items as JsonRecord[]).map((item) => item.commitId));
assert.deepEqual(refBundleCommits, [refBundle.latestCommitId, refBundle.latestCommitId]);
assertNoSecretLeak(refEnvelope);
const assemblyRun = await createHwlabRun(client, context, assemblyBundle, "hwlab-session-assembly", "list visible bundle skills without tools", "hwlab-command-assembly-1");
const assemblyInputFile = path.join(context.tmp, "fake-codex-turn-input-assembly.jsonl");
const assemblyRunner = runOnce({ managerUrl: server.baseUrl, runId: assemblyRun.runId, commandId: assemblyRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-assembly"), AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE: assemblyInputFile }, idleTimeoutMs: 500, pollIntervalMs: 50 });
@@ -195,14 +211,14 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const runningResult = await running;
assert.equal(runningResult.terminalStatus, "cancelled");
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
};
async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBundle> {
const repo = path.join(context.tmp, "bundle-repo");
async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle-repo"): Promise<LocalBundle> {
const repo = path.join(context.tmp, repoName);
await mkdir(repo, { recursive: true });
await execFile("git", ["init"], { cwd: repo });
await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8");
@@ -244,13 +260,26 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
return { repoUrl: repo, commitId: stdout.trim() };
}
async function createRefResolvedLocalGitBundle(context: SelfTestContext): Promise<RefResolvedBundle> {
const first = await createLocalGitBundle(context, "bundle-ref-repo");
const { stdout: branchStdout } = await execFile("git", ["branch", "--show-current"], { cwd: first.repoUrl });
const branch = branchStdout.trim() || "master";
await writeFile(path.join(first.repoUrl, "README.md"), "HWLAB bundle self-test latest ref\n", "utf8");
await execFile("git", ["add", "README.md"], { cwd: first.repoUrl });
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest latest ref"], { cwd: first.repoUrl });
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: first.repoUrl });
const latestCommitId = stdout.trim();
assert.notEqual(latestCommitId, first.commitId);
return { ...first, branch, latestCommitId };
}
async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> {
const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId: bundle.commitId, bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false };
if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs;
const run = await client.post("/api/v1/runs", {
tenantId: "hwlab",
projectId: "pikasTech/HWLAB",
workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB" },
workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB", ...(bundle.branch ? { branch: bundle.branch } : {}) },
sessionRef: { sessionId, conversationId: sessionId },
resourceBundleRef,
providerId: "G14",