支持 gitbundle 资源装配
This commit is contained in:
+10
-19
@@ -56,34 +56,25 @@ export interface SecretRef extends JsonRecord {
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export interface GitBundleItemRef extends JsonRecord {
|
||||
name?: string;
|
||||
repoUrl?: string;
|
||||
commitId?: string;
|
||||
subpath: string;
|
||||
targetPath: string;
|
||||
}
|
||||
|
||||
export interface ResourceBundleRef extends JsonRecord {
|
||||
kind: "git";
|
||||
kind: "gitbundle";
|
||||
repoUrl: string;
|
||||
commitId: string;
|
||||
subdir?: string;
|
||||
sparsePaths?: string[];
|
||||
toolAliases?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "node-script" | "bun-script" | "sh-script" | "executable";
|
||||
}>;
|
||||
bundles: GitBundleItemRef[];
|
||||
promptRefs?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
inject?: "thread-start";
|
||||
required?: boolean;
|
||||
}>;
|
||||
skillRefs?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
required?: boolean;
|
||||
aggregateAs?: string;
|
||||
}>;
|
||||
workspaceFiles?: Array<{
|
||||
path: string;
|
||||
content: string;
|
||||
encoding?: "utf8";
|
||||
}>;
|
||||
submodules?: false;
|
||||
lfs?: false;
|
||||
credentialRef?: SecretRef;
|
||||
|
||||
+39
-76
@@ -83,26 +83,13 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
|
||||
if (value === undefined || value === null) return null;
|
||||
const record = asRecord(value, "resourceBundleRef");
|
||||
const kind = requiredString(record, "kind");
|
||||
if (kind !== "git") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be git in v0.1", { httpStatus: 400 });
|
||||
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");
|
||||
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", "resourceBundleRef.commitId must be a full 40-character git commit sha", { httpStatus: 400 });
|
||||
const result: ResourceBundleRef = { kind: "git", repoUrl, commitId };
|
||||
const subdir = optionalString(record.subdir);
|
||||
if (subdir) {
|
||||
if (subdir.startsWith("/") || subdir.includes("..")) throw new AgentRunError("schema-invalid", "resourceBundleRef.subdir must stay within the checkout", { httpStatus: 400 });
|
||||
result.subdir = subdir;
|
||||
}
|
||||
if (record.sparsePaths !== undefined) {
|
||||
if (!Array.isArray(record.sparsePaths) || !record.sparsePaths.every((item) => typeof item === "string" && item.length > 0 && !item.startsWith("/") && !item.includes(".."))) {
|
||||
throw new AgentRunError("schema-invalid", "resourceBundleRef.sparsePaths must be relative path strings", { httpStatus: 400 });
|
||||
}
|
||||
result.sparsePaths = record.sparsePaths as string[];
|
||||
}
|
||||
if (record.toolAliases !== undefined) result.toolAliases = validateResourceToolAliases(record.toolAliases);
|
||||
validateCommitId(commitId, "resourceBundleRef.commitId");
|
||||
rejectLegacyResourceBundleFields(record);
|
||||
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, commitId, bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId) };
|
||||
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
|
||||
if (record.skillRefs !== undefined) result.skillRefs = validateResourceSkillRefs(record.skillRefs);
|
||||
if (record.workspaceFiles !== undefined) result.workspaceFiles = validateResourceWorkspaceFiles(record.workspaceFiles);
|
||||
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 });
|
||||
if (record.submodules === false) result.submodules = false;
|
||||
@@ -111,24 +98,39 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateResourceToolAliases(value: unknown): NonNullable<ResourceBundleRef["toolAliases"]> {
|
||||
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must be an array", { httpStatus: 400 });
|
||||
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must contain at most 16 entries", { httpStatus: 400 });
|
||||
function rejectLegacyResourceBundleFields(record: JsonRecord): void {
|
||||
for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths"] as const) {
|
||||
if (record[field] !== undefined) throw new AgentRunError("schema-invalid", `resourceBundleRef.${field} is removed; use resourceBundleRef.bundles[] with kind=gitbundle`, { httpStatus: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId: 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 });
|
||||
const seen = new Set<string>();
|
||||
return value.map((entry, index) => {
|
||||
const record = asRecord(entry, `resourceBundleRef.toolAliases[${index}]`);
|
||||
const name = requiredString(record, "name");
|
||||
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].name must be a lowercase command name`, { httpStatus: 400 });
|
||||
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases name ${name} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(name);
|
||||
const aliasPath = requiredString(record, "path");
|
||||
if (aliasPath.startsWith("/") || aliasPath.includes("..")) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].path must stay within the checkout`, { httpStatus: 400 });
|
||||
const kind = requiredString(record, "kind");
|
||||
if (kind !== "node-script" && kind !== "bun-script" && kind !== "sh-script" && kind !== "executable") throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].kind is not supported in v0.1`, { httpStatus: 400, details: { allowedKinds: ["node-script", "bun-script", "sh-script", "executable"] } });
|
||||
return { name, path: aliasPath, kind };
|
||||
const record = asRecord(entry, `resourceBundleRef.bundles[${index}]`);
|
||||
const name = optionalString(record.name);
|
||||
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`);
|
||||
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}`;
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 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 });
|
||||
@@ -147,62 +149,23 @@ function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleR
|
||||
});
|
||||
}
|
||||
|
||||
function validateResourceSkillRefs(value: unknown): NonNullable<ResourceBundleRef["skillRefs"]> {
|
||||
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.skillRefs must be an array", { httpStatus: 400 });
|
||||
if (value.length > 32) throw new AgentRunError("schema-invalid", "resourceBundleRef.skillRefs must contain at most 32 entries", { httpStatus: 400 });
|
||||
const seen = new Set<string>();
|
||||
return value.map((entry, index) => {
|
||||
const record = asRecord(entry, `resourceBundleRef.skillRefs[${index}]`);
|
||||
const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.skillRefs[${index}].name`);
|
||||
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs name ${name} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(name);
|
||||
const skillPath = validateBundleRelativePath(requiredString(record, "path"), `resourceBundleRef.skillRefs[${index}].path`);
|
||||
if (!skillPath.endsWith("SKILL.md")) throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs[${index}].path must point to SKILL.md in v0.1`, { httpStatus: 400 });
|
||||
const required = record.required === undefined ? false : record.required;
|
||||
if (typeof required !== "boolean") throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs[${index}].required must be boolean`, { httpStatus: 400 });
|
||||
const aggregateAs = optionalString(record.aggregateAs);
|
||||
if (aggregateAs) validateResourceName(aggregateAs, `resourceBundleRef.skillRefs[${index}].aggregateAs`);
|
||||
return { name, path: skillPath, required, ...(aggregateAs ? { aggregateAs } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
const maxWorkspaceFiles = Number.MAX_SAFE_INTEGER;
|
||||
const maxWorkspaceFileBytes = 128 * 1024;
|
||||
const maxWorkspaceFilesTotalBytes = 4 * 1024 * 1024;
|
||||
|
||||
function validateResourceWorkspaceFiles(value: unknown): NonNullable<ResourceBundleRef["workspaceFiles"]> {
|
||||
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.workspaceFiles must be an array", { httpStatus: 400 });
|
||||
if (value.length > maxWorkspaceFiles) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles must contain at most ${maxWorkspaceFiles} entries`, { httpStatus: 400 });
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
return value.map((entry, index) => {
|
||||
const record = asRecord(entry, `resourceBundleRef.workspaceFiles[${index}]`);
|
||||
const filePath = validateWorkspaceRelativePath(requiredString(record, "path"), `resourceBundleRef.workspaceFiles[${index}].path`);
|
||||
if (seen.has(filePath)) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles path ${filePath} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(filePath);
|
||||
if (typeof record.content !== "string") throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}].content must be a utf8 string`, { httpStatus: 400 });
|
||||
const encoding = optionalString(record.encoding) ?? "utf8";
|
||||
if (encoding !== "utf8") throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}].encoding must be utf8`, { httpStatus: 400 });
|
||||
const bytes = Buffer.byteLength(record.content, "utf8");
|
||||
if (bytes > maxWorkspaceFileBytes) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}] exceeds the per-file size limit`, { httpStatus: 400, details: { path: filePath, bytes, maxWorkspaceFileBytes, valuesPrinted: false } });
|
||||
totalBytes += bytes;
|
||||
if (totalBytes > maxWorkspaceFilesTotalBytes) throw new AgentRunError("schema-invalid", "resourceBundleRef.workspaceFiles exceeds the total size limit", { httpStatus: 400, details: { totalBytes, maxWorkspaceFilesTotalBytes, valuesPrinted: false } });
|
||||
return { path: filePath, content: record.content, encoding: "utf8" as const };
|
||||
});
|
||||
}
|
||||
|
||||
function validateResourceName(name: string, fieldName: string): string {
|
||||
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 });
|
||||
return name;
|
||||
}
|
||||
|
||||
function validateBundleSubpath(relativePath: string, fieldName: string): string {
|
||||
if (relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
function validateBundleRelativePath(relativePath: string, fieldName: string): string {
|
||||
if (relativePath.startsWith("/") || relativePath.includes("..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
|
||||
if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
function validateWorkspaceRelativePath(relativePath: string, fieldName: string): string {
|
||||
if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the workspace`, { httpStatus: 400 });
|
||||
if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the workspace`, { httpStatus: 400 });
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -172,11 +172,12 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
|
||||
kind: run.resourceBundleRef.kind,
|
||||
repoUrl: run.resourceBundleRef.repoUrl,
|
||||
commitId: run.resourceBundleRef.commitId,
|
||||
subdir: run.resourceBundleRef.subdir ?? null,
|
||||
toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
|
||||
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 })),
|
||||
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 },
|
||||
skillRefs: run.resourceBundleRef.skillRefs ? { count: run.resourceBundleRef.skillRefs.length, names: run.resourceBundleRef.skillRefs.map((item) => item.name), required: run.resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
|
||||
workspaceFiles: run.resourceBundleRef.workspaceFiles ? { count: run.resourceBundleRef.workspaceFiles.length, paths: run.resourceBundleRef.workspaceFiles.map((item) => item.path), valuesPrinted: false } : { count: 0, paths: [], valuesPrinted: false },
|
||||
materialized: materialized as JsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-5
@@ -732,12 +732,12 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
|
||||
kind: resourceBundleRef.kind,
|
||||
repoUrl: resourceBundleRef.repoUrl,
|
||||
commitId: resourceBundleRef.commitId,
|
||||
subdir: resourceBundleRef.subdir ?? null,
|
||||
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
|
||||
toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
|
||||
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 })),
|
||||
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 },
|
||||
skillRefs: resourceBundleRef.skillRefs ? { count: resourceBundleRef.skillRefs.length, names: resourceBundleRef.skillRefs.map((item) => item.name), required: resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
|
||||
workspaceFiles: resourceBundleRef.workspaceFiles ? { count: resourceBundleRef.workspaceFiles.length, paths: resourceBundleRef.workspaceFiles.map((item) => item.path), valuesPrinted: false } : { count: 0, paths: [], valuesPrinted: false },
|
||||
submodules: resourceBundleRef.submodules ?? false,
|
||||
lfs: resourceBundleRef.lfs ?? false,
|
||||
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
|
||||
|
||||
+135
-125
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, cp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import { redactText } from "../common/redaction.js";
|
||||
@@ -40,70 +40,135 @@ interface MaterializedSkillRef {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface GitCheckout {
|
||||
repoUrl: string;
|
||||
commitId: string;
|
||||
checkoutPath: string;
|
||||
treeId: string;
|
||||
}
|
||||
|
||||
interface MaterializedGitBundle {
|
||||
name: string | null;
|
||||
repoUrl: string;
|
||||
commitId: string;
|
||||
subpath: string;
|
||||
targetPath: string;
|
||||
sourceKind: "file" | "directory";
|
||||
sourceBytes: number | null;
|
||||
}
|
||||
|
||||
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
|
||||
if (!resourceBundleRef) return null;
|
||||
const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces");
|
||||
const checkoutPath = path.join(workspaceRoot, stableHash({ repoUrl: resourceBundleRef.repoUrl, commitId: resourceBundleRef.commitId }).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", resourceBundleRef.repoUrl], checkoutPath);
|
||||
if (resourceBundleRef.sparsePaths && resourceBundleRef.sparsePaths.length > 0) {
|
||||
await git(["config", "core.sparseCheckout", "true"], checkoutPath);
|
||||
await mkdir(path.join(checkoutPath, ".git", "info"), { recursive: true });
|
||||
await writeFile(path.join(checkoutPath, ".git", "info", "sparse-checkout"), `${resourceBundleRef.sparsePaths.join("\n")}\n`, "utf8");
|
||||
}
|
||||
await git(["fetch", "--depth", "1", "origin", resourceBundleRef.commitId], checkoutPath);
|
||||
await git(["checkout", "--detach", resourceBundleRef.commitId], checkoutPath);
|
||||
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
|
||||
if (actualCommit !== resourceBundleRef.commitId) throw new AgentRunError("infra-failed", "resource bundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: resourceBundleRef.commitId, actualCommit } });
|
||||
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
||||
const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir);
|
||||
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env);
|
||||
const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []);
|
||||
const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []);
|
||||
const workspaceFiles = await materializeWorkspaceFiles(workspacePath, resourceBundleRef.workspaceFiles ?? []);
|
||||
const runScope = env.AGENTRUN_RUN_ID ?? env.AGENTRUN_ATTEMPT_ID ?? "standalone";
|
||||
const assemblyRoot = path.join(workspaceRoot, `gitbundle-${stableHash({ runScope, resourceBundleRef }).slice(0, 16)}`);
|
||||
const checkoutRoot = path.join(assemblyRoot, "checkouts");
|
||||
const workspacePath = path.join(assemblyRoot, "workspace");
|
||||
await rm(assemblyRoot, { recursive: true, force: true });
|
||||
await mkdir(checkoutRoot, { recursive: true });
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const checkoutCache = new Map<string, Promise<GitCheckout>>();
|
||||
const checkoutFor = (repoUrl: string, commitId: string) => {
|
||||
const key = stableHash({ repoUrl, commitId });
|
||||
let checkout = checkoutCache.get(key);
|
||||
if (!checkout) {
|
||||
checkout = checkoutGitCommit(checkoutRoot, repoUrl, commitId);
|
||||
checkoutCache.set(key, checkout);
|
||||
}
|
||||
return checkout;
|
||||
};
|
||||
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, checkoutFor);
|
||||
const defaultCheckout = await checkoutFor(resourceBundleRef.repoUrl, resourceBundleRef.commitId);
|
||||
const tools = await prepareGitBundleTools(workspacePath);
|
||||
const skills = await discoverGitBundleSkills(workspacePath);
|
||||
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
|
||||
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
|
||||
return {
|
||||
workspacePath,
|
||||
...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}),
|
||||
...(tools.binPath ? { binPath: tools.binPath } : {}),
|
||||
...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}),
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
event: {
|
||||
phase: "resource-bundle-materialized",
|
||||
kind: "git",
|
||||
kind: "gitbundle",
|
||||
repoUrl: resourceBundleRef.repoUrl,
|
||||
commitId: resourceBundleRef.commitId,
|
||||
treeId,
|
||||
checkoutPath: pathSummary(checkoutPath),
|
||||
treeId: defaultCheckout.treeId,
|
||||
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
|
||||
workspacePath: pathSummary(workspacePath),
|
||||
subdir: resourceBundleRef.subdir ?? null,
|
||||
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
|
||||
toolAliases: toolAliases.event,
|
||||
skillRefs: skills.event,
|
||||
bundles: {
|
||||
count: materializedBundles.length,
|
||||
items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
tools: tools.event,
|
||||
skillDirs: skills.event,
|
||||
promptRefs: prompts.event,
|
||||
workspaceFiles: workspaceFiles.event,
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false },
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeToolAliases(checkoutPath: string, aliases: NonNullable<ResourceBundleRef["toolAliases"]>, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> {
|
||||
if (aliases.length === 0) return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } };
|
||||
const binPath = path.resolve(env.AGENTRUN_RESOURCE_BIN_PATH ?? path.join(path.dirname(checkoutPath), ".bin"));
|
||||
await mkdir(binPath, { recursive: true });
|
||||
const names: string[] = [];
|
||||
for (const alias of aliases) {
|
||||
const target = resolveBundlePath(checkoutPath, alias.path, `toolAliases.${alias.name}.path`);
|
||||
const wrapper = path.join(binPath, alias.name);
|
||||
const content = aliasWrapper(alias.kind, target);
|
||||
await assertAliasWrapperWritable(wrapper, alias.name);
|
||||
await writeFile(wrapper, content, "utf8");
|
||||
await chmod(wrapper, 0o755);
|
||||
names.push(alias.name);
|
||||
async function checkoutGitCommit(checkoutRoot: string, repoUrl: string, commitId: string): Promise<GitCheckout> {
|
||||
const checkoutPath = path.join(checkoutRoot, stableHash({ repoUrl, commitId }).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);
|
||||
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 } });
|
||||
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
||||
return { repoUrl, commitId, checkoutPath, treeId };
|
||||
}
|
||||
|
||||
async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, checkoutFor: (repoUrl: string, commitId: string) => 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 source = resolveBundlePath(checkout.checkoutPath, bundle.subpath, `bundles[${index}].subpath`);
|
||||
const target = resolveWorkspaceTargetPath(workspacePath, bundle.targetPath, `bundles[${index}].target_path`);
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = await stat(source);
|
||||
} catch (error) {
|
||||
throw new AgentRunError("schema-invalid", `gitbundle subpath ${bundle.subpath} is not readable`, { httpStatus: 400, details: { index, subpath: bundle.subpath, error: fileErrorSummary(error), valuesPrinted: false } });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
return { binPath, event: { count: names.length, names, binPath: pathSummary(binPath), valuesPrinted: false } };
|
||||
return items;
|
||||
}
|
||||
|
||||
async function prepareGitBundleTools(workspacePath: string): Promise<{ binPath?: string; event: JsonRecord }> {
|
||||
const binPath = path.join(workspacePath, "tools");
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(binPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } };
|
||||
throw error;
|
||||
}
|
||||
const names: string[] = [];
|
||||
const items: JsonRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(binPath, entry.name);
|
||||
const text = await readFile(filePath, "utf8");
|
||||
const firstLine = text.split(/\r?\n/u, 1)[0] ?? "";
|
||||
if (entry.name.endsWith(".ts") && !firstLine.startsWith("#!")) throw new AgentRunError("schema-invalid", `gitbundle tool ${entry.name} must start with a shebang`, { httpStatus: 400 });
|
||||
if (!firstLine.startsWith("#!")) continue;
|
||||
await chmod(filePath, 0o755);
|
||||
names.push(entry.name);
|
||||
items.push({ name: entry.name, sha256: sha256Text(text), bytes: Buffer.byteLength(text, "utf8"), shebang: firstLine.slice(0, 80), valuesPrinted: false });
|
||||
}
|
||||
return { binPath, event: { count: names.length, names, items, binPath: pathSummary(binPath), valuesPrinted: false } };
|
||||
}
|
||||
|
||||
async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> {
|
||||
@@ -142,41 +207,39 @@ async function materializePromptRefs(checkoutPath: string, refs: NonNullable<Res
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeSkillRefs(checkoutPath: string, workspacePath: string, refs: NonNullable<ResourceBundleRef["skillRefs"]>): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
|
||||
async function discoverGitBundleSkills(workspacePath: string): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
const skillsDir = path.join(workspacePath, ".agents", "skills");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
|
||||
throw error;
|
||||
}
|
||||
const items: MaterializedSkillRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
for (const ref of refs) {
|
||||
const manifestPath = resolveBundlePath(checkoutPath, ref.path, `skillRefs.${ref.name}.path`);
|
||||
const required = ref.required === true;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const aggregateAs = entry.name;
|
||||
const manifestPath = path.join(skillsDir, aggregateAs, "SKILL.md");
|
||||
let manifestText: string;
|
||||
try {
|
||||
manifestText = await readFile(manifestPath, "utf8");
|
||||
} catch (error) {
|
||||
if (required) throw new AgentRunError("skill-unavailable", `required resource skill ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } });
|
||||
eventItems.push({ name: ref.name, path: ref.path, required, aggregateAs: ref.aggregateAs ?? ref.name, status: "missing", valuesPrinted: false });
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), valuesPrinted: false });
|
||||
continue;
|
||||
}
|
||||
const aggregateAs = ref.aggregateAs ?? ref.name;
|
||||
const sourceRoot = path.dirname(manifestPath);
|
||||
const targetRoot = path.join(skillsDir, aggregateAs);
|
||||
if (path.resolve(sourceRoot) !== path.resolve(targetRoot)) {
|
||||
await rm(targetRoot, { recursive: true, force: true });
|
||||
await cp(sourceRoot, targetRoot, { recursive: true, force: true, dereference: false });
|
||||
}
|
||||
const bytes = Buffer.byteLength(manifestText, "utf8");
|
||||
const sha = sha256Text(manifestText);
|
||||
const summary = skillSummary(manifestText);
|
||||
items.push({ name: ref.name, path: ref.path, aggregateAs, required, registryPath: path.join(targetRoot, "SKILL.md"), manifestBytes: bytes, manifestSha256: sha, summary });
|
||||
eventItems.push({ name: ref.name, path: ref.path, aggregateAs, required, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(path.join(targetRoot, "SKILL.md")), summary, valuesPrinted: false });
|
||||
items.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary });
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), summary, valuesPrinted: false });
|
||||
}
|
||||
return {
|
||||
items,
|
||||
skillsDir,
|
||||
event: {
|
||||
count: refs.length,
|
||||
count: entries.filter((entry) => entry.isDirectory()).length,
|
||||
materializedCount: items.length,
|
||||
names: items.map((item) => item.name),
|
||||
skillsDir: pathSummary(skillsDir),
|
||||
@@ -186,50 +249,25 @@ async function materializeSkillRefs(checkoutPath: string, workspacePath: string,
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeWorkspaceFiles(workspacePath: string, files: NonNullable<ResourceBundleRef["workspaceFiles"]>): Promise<{ event: JsonRecord }> {
|
||||
if (files.length === 0) return { event: { count: 0, materializedCount: 0, items: [], totalBytes: 0, valuesPrinted: false } };
|
||||
const eventItems: JsonRecord[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const file of files) {
|
||||
const target = resolveWorkspaceFilePath(workspacePath, file.path, `workspaceFiles.${file.path}.path`);
|
||||
const content = file.content;
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, content, "utf8");
|
||||
totalBytes += bytes;
|
||||
eventItems.push({ path: file.path, encoding: file.encoding ?? "utf8", status: "materialized", bytes, sha256: sha256Text(content), valuesPrinted: false });
|
||||
}
|
||||
return {
|
||||
event: {
|
||||
count: files.length,
|
||||
materializedCount: eventItems.length,
|
||||
paths: eventItems.map((item) => item.path),
|
||||
items: eventItems,
|
||||
totalBytes,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
||||
if (promptRefs.length === 0 && skillRefs.length === 0) return undefined;
|
||||
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
||||
if (promptRefs.length === 0 && skills.length === 0) return undefined;
|
||||
const sections: string[] = [
|
||||
"AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and skillRefs for the first thread-start turn only.",
|
||||
"AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and gitbundle skill directories for the first thread-start turn only.",
|
||||
];
|
||||
for (const prompt of promptRefs) {
|
||||
sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n"));
|
||||
}
|
||||
if (skillRefs.length > 0) {
|
||||
if (skills.length > 0) {
|
||||
const lines = [
|
||||
"## Resource Skills",
|
||||
"The following required runtime skills are mounted in the current workspace. Use these bundle skills instead of default model skill guesses.",
|
||||
...skillRefs.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`),
|
||||
...skills.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`),
|
||||
];
|
||||
sections.push(lines.join("\n"));
|
||||
}
|
||||
const text = sections.join("\n\n");
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillRefCount: skillRefs.length, valuesPrinted: false } });
|
||||
if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillCount: skills.length, valuesPrinted: false } });
|
||||
return {
|
||||
text,
|
||||
summary: {
|
||||
@@ -238,8 +276,8 @@ function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: M
|
||||
sha256: sha256Text(text),
|
||||
promptRefCount: promptRefs.length,
|
||||
promptRefNames: promptRefs.map((item) => item.name),
|
||||
skillRefCount: skillRefs.length,
|
||||
skillRefNames: skillRefs.map((item) => item.name),
|
||||
skillCount: skills.length,
|
||||
skillNames: skills.map((item) => item.name),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
@@ -269,25 +307,6 @@ function fileErrorSummary(error: unknown): JsonRecord {
|
||||
return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null };
|
||||
}
|
||||
|
||||
function aliasWrapper(kind: string, target: string): string {
|
||||
if (kind === "node-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec node ${shellArg(target)} "$@"\n`;
|
||||
if (kind === "bun-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec bun ${shellArg(target)} "$@"\n`;
|
||||
if (kind === "sh-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec sh ${shellArg(target)} "$@"\n`;
|
||||
return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec ${shellArg(target)} "$@"\n`;
|
||||
}
|
||||
|
||||
async function assertAliasWrapperWritable(wrapper: string, name: string): Promise<void> {
|
||||
try {
|
||||
const existing = await readFile(wrapper, "utf8");
|
||||
if (existing.includes("agentrun-resource-alias-wrapper")) return;
|
||||
throw new AgentRunError("schema-invalid", `resource bundle tool alias ${name} would overwrite an existing command`, { httpStatus: 409, details: { wrapper: pathSummary(wrapper) } });
|
||||
} catch (error) {
|
||||
if (error instanceof AgentRunError) throw error;
|
||||
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function git(args: string[], cwd: string, options: { allowFailure?: boolean } = {}): Promise<{ stdout: string; stderr: string }> {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
@@ -308,11 +327,6 @@ async function git(args: string[], cwd: string, options: { allowFailure?: boolea
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(checkoutPath: string, subdir: string | undefined): string {
|
||||
if (!subdir) return checkoutPath;
|
||||
return resolveBundlePath(checkoutPath, subdir, "resourceBundleRef.subdir");
|
||||
}
|
||||
|
||||
function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string {
|
||||
const resolved = path.resolve(checkoutPath, relativePath);
|
||||
const root = path.resolve(checkoutPath);
|
||||
@@ -320,17 +334,13 @@ function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveWorkspaceFilePath(workspacePath: string, relativePath: string, fieldName: string): string {
|
||||
function resolveWorkspaceTargetPath(workspacePath: string, relativePath: string, fieldName: string): string {
|
||||
const resolved = path.resolve(workspacePath, relativePath);
|
||||
const root = path.resolve(workspacePath);
|
||||
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped workspace`, { httpStatus: 400 });
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function shellArg(value: string): string {
|
||||
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function pathSummary(value: string): JsonRecord {
|
||||
const parts = value.split(/[\\/]+/u).filter(Boolean);
|
||||
return { absolute: path.isAbsolute(value), basename: parts.at(-1) ?? null, depth: parts.length, fingerprint: stableHash(value).slice(0, 16), valuePrinted: false };
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
if (!materializationAttempted) {
|
||||
materializationAttempted = true;
|
||||
try {
|
||||
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, options.env ?? process.env);
|
||||
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, resourceMaterializationEnv(options.env ?? process.env, options.runId, attemptId));
|
||||
if (materialized) {
|
||||
workspacePath = materialized.workspacePath;
|
||||
resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized);
|
||||
@@ -145,6 +145,10 @@ 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 resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited<ReturnType<typeof materializeResourceBundle>>): NodeJS.ProcessEnv | undefined {
|
||||
if (!materialized) return undefined;
|
||||
let next: NodeJS.ProcessEnv | undefined;
|
||||
|
||||
@@ -7,12 +7,11 @@ import { startManagerServer } from "../../mgr/server.js";
|
||||
import { ManagerClient } from "../../mgr/client.js";
|
||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||
import { runOnce } from "../../runner/run-once.js";
|
||||
import { stableHash } from "../../common/validation.js";
|
||||
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; toolAliases?: ResourceBundleRef["toolAliases"]; promptRefs?: ResourceBundleRef["promptRefs"]; skillRefs?: ResourceBundleRef["skillRefs"]; workspaceFiles?: ResourceBundleRef["workspaceFiles"] };
|
||||
type LocalBundle = { repoUrl: string; commitId: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] };
|
||||
|
||||
const selfTest: SelfTestCase = async (context) => {
|
||||
const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8");
|
||||
@@ -49,10 +48,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
const assemblyBundle: LocalBundle = {
|
||||
...bundle,
|
||||
promptRefs: [{ name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true }],
|
||||
skillRefs: [
|
||||
{ name: "hwpod-cli", path: "skills/hwpod-cli/SKILL.md", required: true, aggregateAs: "hwpod-cli" },
|
||||
{ name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" },
|
||||
],
|
||||
};
|
||||
const first = await createHwlabRun(client, context, bundle, "hwlab-session-1", "hello bundle", "hwlab-command-1");
|
||||
const created = await client.post(`/api/v1/runs/${first.runId}/runner-jobs`, { commandId: first.commandId, idempotencyKey: "hwlab-trace-1" }) as JsonRecord;
|
||||
@@ -66,7 +61,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord;
|
||||
assert.ok(JSON.stringify(manifest).includes("AGENTRUN_RESOURCE_BUNDLE_JSON"));
|
||||
assert.equal(runnerEnvValue(manifest, "AGENTRUN_RESOURCE_BIN_PATH"), "/usr/local/bin");
|
||||
assert.ok(JSON.stringify(manifest).includes("/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh"));
|
||||
assert.ok(JSON.stringify(manifest).includes("AGENTRUN_BOOT_COMMIT"));
|
||||
assertNoSecretLeak(created);
|
||||
@@ -84,16 +78,8 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
);
|
||||
|
||||
const sessionRun = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello session", "hwlab-command-session");
|
||||
const resourceBin = path.join(context.tmp, "resource-bin");
|
||||
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces"), AGENTRUN_RESOURCE_BIN_PATH: resourceBin }, oneShot: true });
|
||||
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces") }, oneShot: true });
|
||||
assert.equal(runResult.terminalStatus, "completed");
|
||||
const hwpod = await execFile(path.join(resourceBin, "hwpod"), ["profile", "list"]);
|
||||
assert.match(hwpod.stdout, /"argv":\["profile","list"\]/u);
|
||||
await writeFile(path.join(resourceBin, "blocked"), "#!/usr/bin/env sh\necho existing\n", "utf8");
|
||||
const blockedRun = await createHwlabRun(client, context, { ...bundle, toolAliases: [{ name: "blocked", path: "tools/hwpod-cli.mjs", kind: "node-script" }] }, "hwlab-session-blocked-alias", "blocked alias", "hwlab-command-blocked-alias");
|
||||
const blockedResult = await runOnce({ managerUrl: server.baseUrl, runId: blockedRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-blocked"), AGENTRUN_RESOURCE_BIN_PATH: resourceBin }, oneShot: true });
|
||||
assert.equal(blockedResult.terminalStatus, "blocked");
|
||||
assert.equal(blockedResult.failureKind, "schema-invalid");
|
||||
const session = await store.getSession("hwlab-session-resume");
|
||||
assert.equal(session?.threadId, "thread_selftest_1");
|
||||
const resultEnvelope = await client.get(`/api/v1/runs/${sessionRun.runId}/commands/${sessionRun.commandId}/result`) as JsonRecord;
|
||||
@@ -101,26 +87,14 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.equal(resultEnvelope.reply, "fake codex stdio reply");
|
||||
assert.equal(((resultEnvelope.sessionRef as JsonRecord).threadId), "thread_selftest_1");
|
||||
assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).commitId), bundle.commitId);
|
||||
assert.deepEqual(((resultEnvelope.resourceBundleRef as JsonRecord).toolAliases as JsonRecord).names, ["hwpod"]);
|
||||
assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).kind), "gitbundle");
|
||||
const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
|
||||
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
|
||||
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
|
||||
assert.deepEqual(((materialized.toolAliases as JsonRecord).names), ["hwpod"]);
|
||||
assert.deepEqual(((materialized.tools as JsonRecord).names), ["hwpod"]);
|
||||
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
|
||||
assertNoSecretLeak(resultEnvelope);
|
||||
|
||||
const seededRun = await createHwlabRun(client, context, { ...bundle, workspaceFiles: [{ path: ".hwlab/hwpod-spec.yaml", content: "apiVersion: hwlab.dev/v0alpha1\nkind: Hwpod\n", encoding: "utf8" }] }, "hwlab-session-seeded", "inspect seeded spec", "hwlab-command-seeded");
|
||||
const seededRoot = path.join(context.tmp, "workspaces-seeded");
|
||||
const seededResult = await runOnce({ managerUrl: server.baseUrl, runId: seededRun.runId, commandId: seededRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: seededRoot }, oneShot: true }) as JsonRecord;
|
||||
assert.equal(seededResult.terminalStatus, "completed");
|
||||
const seededEnvelope = await client.get(`/api/v1/runs/${seededRun.runId}/commands/${seededRun.commandId}/result`) as JsonRecord;
|
||||
const seededResource = seededEnvelope.resourceBundleRef as JsonRecord;
|
||||
assert.deepEqual(((seededResource.workspaceFiles as JsonRecord).paths), [".hwlab/hwpod-spec.yaml"]);
|
||||
const seededMaterialized = seededResource.materialized as JsonRecord;
|
||||
const seededWorkspaceFiles = seededMaterialized.workspaceFiles as JsonRecord;
|
||||
assert.equal(seededWorkspaceFiles.count, 1);
|
||||
assert.deepEqual(seededWorkspaceFiles.paths, [".hwlab/hwpod-spec.yaml"]);
|
||||
assert.equal(JSON.stringify(seededWorkspaceFiles).includes("apiVersion"), false);
|
||||
const seededWorkspace = path.join(seededRoot, stableHash({ repoUrl: bundle.repoUrl, commitId: bundle.commitId }).slice(0, 16));
|
||||
assert.equal(await readTextIfExists(path.join(seededWorkspace, ".hwlab", "hwpod-spec.yaml")), "apiVersion: hwlab.dev/v0alpha1\nkind: Hwpod\n");
|
||||
|
||||
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 });
|
||||
@@ -150,10 +124,11 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
const assemblyEnvelope = await client.get(`/api/v1/runs/${assemblyRun.runId}/commands/${assemblyRun.commandId}/result`) as JsonRecord;
|
||||
const assemblyResource = assemblyEnvelope.resourceBundleRef as JsonRecord;
|
||||
assert.deepEqual(((assemblyResource.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
|
||||
assert.deepEqual(((assemblyResource.skillRefs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
|
||||
const assemblyBundleTargets = ((assemblyResource.bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
|
||||
assert.deepEqual(assemblyBundleTargets, ["tools", ".agents/skills"]);
|
||||
const assemblyMaterialized = assemblyResource.materialized as JsonRecord;
|
||||
assert.deepEqual(((assemblyMaterialized.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
|
||||
assert.deepEqual(((assemblyMaterialized.skillRefs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
|
||||
assert.deepEqual(((assemblyMaterialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
|
||||
assert.equal(((assemblyMaterialized.initialPrompt as JsonRecord).available), true);
|
||||
assertNoSecretLeak(assemblyEnvelope);
|
||||
|
||||
@@ -162,11 +137,6 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.equal(missingPromptResult.terminalStatus, "blocked");
|
||||
assert.equal(missingPromptResult.failureKind, "prompt-unavailable");
|
||||
|
||||
const missingSkillRun = await createHwlabRun(client, context, { ...bundle, skillRefs: [{ name: "missing-skill", path: "skills/missing-skill/SKILL.md", required: true }] }, "hwlab-session-missing-skill", "missing skill", "hwlab-command-missing-skill");
|
||||
const missingSkillResult = await runOnce({ managerUrl: server.baseUrl, runId: missingSkillRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-missing-skill") }, oneShot: true }) as JsonRecord;
|
||||
assert.equal(missingSkillResult.terminalStatus, "blocked");
|
||||
assert.equal(missingSkillResult.failureKind, "skill-unavailable");
|
||||
|
||||
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
|
||||
const resumedRun = await client.get(`/api/v1/runs/${resumed.runId}`) as JsonRecord;
|
||||
assert.equal(((resumedRun.sessionRef as JsonRecord).threadId), "thread_selftest_1");
|
||||
@@ -225,7 +195,7 @@ 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-bundle-materialization", "resource-bundle-tool-alias", "resource-bundle-workspace-files", "resource-prompt-skill-assembly", "resource-prompt-skill-required-blockers", "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-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()));
|
||||
}
|
||||
@@ -237,6 +207,7 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
|
||||
await execFile("git", ["init"], { cwd: repo });
|
||||
await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8");
|
||||
await mkdir(path.join(repo, "tools"), { recursive: true });
|
||||
await writeFile(path.join(repo, "tools", "hwpod"), "#!/usr/bin/env bun\nconsole.log(JSON.stringify({ ok: true, cli: 'hwpod-selftest', argv: process.argv.slice(2) }));\n", "utf8");
|
||||
await writeFile(path.join(repo, "tools", "hwpod-cli.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-cli-selftest', argv: process.argv.slice(2) }));\n", "utf8");
|
||||
await mkdir(path.join(repo, "internal", "agent", "prompts"), { recursive: true });
|
||||
await writeFile(path.join(repo, "internal", "agent", "prompts", "hwlab-v02-runtime.md"), [
|
||||
@@ -266,18 +237,15 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
|
||||
"Use hwpod-ctl for HWPOD runtime inspection and control-plane state.",
|
||||
].join("\n"), "utf8");
|
||||
await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8");
|
||||
await execFile("git", ["add", "README.md", "tools/hwpod-cli.mjs", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
|
||||
await execFile("git", ["add", "README.md", "tools/hwpod", "tools/hwpod-cli.mjs", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
|
||||
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
|
||||
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
|
||||
return { repoUrl: repo, commitId: stdout.trim() };
|
||||
}
|
||||
|
||||
async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> {
|
||||
const toolAliases = bundle.toolAliases ?? [{ name: "hwpod", path: "tools/hwpod-cli.mjs", kind: "node-script" }];
|
||||
const resourceBundleRef: ResourceBundleRef = { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false };
|
||||
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;
|
||||
if (bundle.skillRefs) resourceBundleRef.skillRefs = bundle.skillRefs;
|
||||
if (bundle.workspaceFiles) resourceBundleRef.workspaceFiles = bundle.workspaceFiles;
|
||||
const run = await client.post("/api/v1/runs", {
|
||||
tenantId: "hwlab",
|
||||
projectId: "pikasTech/HWLAB",
|
||||
@@ -299,6 +267,13 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b
|
||||
return { runId: run.id, commandId: command.id };
|
||||
}
|
||||
|
||||
function defaultGitBundles(): ResourceBundleRef["bundles"] {
|
||||
return [
|
||||
{ name: "hwlab-tools", subpath: "tools", targetPath: "tools" },
|
||||
{ name: "hwlab-agent-skills", subpath: "skills", targetPath: ".agents/skills" },
|
||||
];
|
||||
}
|
||||
|
||||
async function waitForCommandState(client: ManagerClient, runId: string, commandId: string, state: string): Promise<void> {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -167,7 +167,7 @@ async function assertResourceBundleFailure(client: ManagerClient, context: SelfT
|
||||
const repo = await createLocalGitRepo(context);
|
||||
const run = await client.post("/api/v1/runs", {
|
||||
...runPayload(context, "codex", "selftest-bad-bundle-session"),
|
||||
resourceBundleRef: { kind: "git", repoUrl: repo.repoUrl, commitId: "0000000000000000000000000000000000000000", submodules: false, lfs: false },
|
||||
resourceBundleRef: { kind: "gitbundle", repoUrl: repo.repoUrl, commitId: "0000000000000000000000000000000000000000", bundles: [{ name: "tools", subpath: "tools", targetPath: "tools" }], submodules: false, lfs: false },
|
||||
}) as { id: string };
|
||||
const command = await client.post(`/api/v1/runs/${run.id}/commands`, { type: "turn", payload: { prompt: "bad bundle" }, idempotencyKey: "selftest-bad-bundle" }) as { id: string };
|
||||
const result = await runOnce({ managerUrl, runId: run.id, commandId: command.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "bad-bundle-workspaces") }, oneShot: true }) as JsonRecord;
|
||||
|
||||
Reference in New Issue
Block a user