243 lines
8.6 KiB
TypeScript
243 lines
8.6 KiB
TypeScript
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
|
import { readHwpodSpec } from "../../tools/src/hwpod-harness-lib.ts";
|
|
|
|
const HWPOD_SPEC_DISCOVERY_CONTRACT_VERSION = "hwpod-spec-discovery-v1";
|
|
const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml";
|
|
const DEFAULT_HWPOD_SPEC_REGISTRY_DIR = ".hwlab/hwpod-specs";
|
|
|
|
export { HWPOD_SPEC_DISCOVERY_CONTRACT_VERSION };
|
|
|
|
export async function discoverHwpodSpecs(options: any = {}) {
|
|
const env = options.env ?? process.env;
|
|
const workspace = text(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE) || text(env.HWLAB_CODE_AGENT_WORKSPACE) || process.cwd();
|
|
const roots = uniquePaths([workspace, process.cwd(), options.repoRoot].filter(Boolean));
|
|
const explicitPaths = splitEnvList(env.HWLAB_HWPOD_SPEC_PATHS).map((value) => resolveMaybeRelative(value, process.cwd()));
|
|
const explicitDirs = splitEnvList(env.HWLAB_HWPOD_SPEC_REGISTRY_DIRS).map((value) => resolveMaybeRelative(value, process.cwd()));
|
|
const candidatePaths = uniquePaths([
|
|
...explicitPaths,
|
|
...roots.map((root) => path.resolve(root, DEFAULT_HWPOD_SPEC_PATH)),
|
|
...await candidateRegistrySpecPaths(uniquePaths([
|
|
...explicitDirs,
|
|
...roots.map((root) => path.resolve(root, DEFAULT_HWPOD_SPEC_REGISTRY_DIR))
|
|
]))
|
|
]);
|
|
const discovered = [];
|
|
for (const specPath of candidatePaths) {
|
|
const item = await readSpecCandidate(specPath);
|
|
if (item) discovered.push(item);
|
|
}
|
|
return dedupeSpecs(discovered);
|
|
}
|
|
|
|
export function hwpodSpecDiscoveryPayload(specs: any[], options: any = {}) {
|
|
const availableCount = specs.filter((item) => item.availability?.ok === true).length;
|
|
const probed = specs.some((item) => item.availability?.status !== "unprobed");
|
|
return {
|
|
ok: true,
|
|
status: specs.length === 0 ? "empty" : "completed",
|
|
contractVersion: HWPOD_SPEC_DISCOVERY_CONTRACT_VERSION,
|
|
nodeOpsContractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
route: "/v1/hwpod/specs",
|
|
specAuthority: "workspace-or-registry",
|
|
count: specs.length,
|
|
availableCount: probed ? availableCount : null,
|
|
specs,
|
|
observedAt: options.observedAt ?? new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
export function hwpodSpecWorkspaceProbePlan(spec: any, options: any = {}) {
|
|
const document = spec.document ?? spec;
|
|
const metadata = objectValue(document.metadata);
|
|
const body = objectValue(document.spec);
|
|
const nodeBinding = objectValue(body.nodeBinding);
|
|
const workspace = objectValue(body.workspace);
|
|
const hwpodId = text(metadata.name) || text(metadata.uid);
|
|
const nodeId = text(nodeBinding.nodeId);
|
|
return {
|
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
|
planId: options.planId ?? `hwpod_spec_probe_${safeId(hwpodId || nodeId || "spec")}_${Date.now()}`,
|
|
hwpodId,
|
|
nodeId,
|
|
intent: "workspace.ls",
|
|
source: {
|
|
compiler: "hwpod-spec-discovery",
|
|
specPath: spec.specPath ?? options.specPath,
|
|
specAuthority: spec.authority ?? "workspace-or-registry"
|
|
},
|
|
resourceHints: {
|
|
workspacePath: workspace.path,
|
|
targetDevice: body.targetDevice,
|
|
debugProbe: body.debugProbe,
|
|
ioProbe: body.ioProbe
|
|
},
|
|
ops: [
|
|
{
|
|
opId: "op_01_workspace_ls",
|
|
op: "workspace.ls",
|
|
args: {
|
|
hwpodId,
|
|
workspacePath: workspace.path,
|
|
path: ".",
|
|
targetDevice: body.targetDevice,
|
|
debugProbe: body.debugProbe,
|
|
ioProbe: body.ioProbe
|
|
}
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
async function candidateRegistrySpecPaths(dirs: string[]) {
|
|
const paths: string[] = [];
|
|
for (const dir of dirs) {
|
|
try {
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
if (!/\.ya?ml$/iu.test(entry.name)) continue;
|
|
paths.push(path.join(dir, entry.name));
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") throw error;
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
async function readSpecCandidate(specPath: string) {
|
|
try {
|
|
const info = await stat(specPath);
|
|
if (!info.isFile()) return null;
|
|
const document = await readHwpodSpec(specPath);
|
|
const metadata = await readSpecMetadata(specPath);
|
|
return summarizeSpec({ document, specPath, metadata });
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
|
|
return {
|
|
ok: false,
|
|
status: "invalid",
|
|
specPath,
|
|
error: {
|
|
code: error?.code ?? "invalid_hwpod_spec",
|
|
message: error?.message ?? String(error)
|
|
},
|
|
availability: { ok: false, status: "invalid", checkedAt: new Date().toISOString() }
|
|
};
|
|
}
|
|
}
|
|
|
|
async function readSpecMetadata(specPath: string) {
|
|
for (const candidate of metadataCandidates(specPath)) {
|
|
try {
|
|
const parsed = JSON.parse(await readFile(candidate, "utf8"));
|
|
return isPlainObject(parsed) ? { ...parsed, metadataPath: candidate } : { metadataPath: candidate };
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") return { metadataPath: candidate, metadataError: error?.message ?? String(error) };
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function metadataCandidates(specPath: string) {
|
|
const parsed = path.parse(specPath);
|
|
const basename = parsed.name === "hwpod-spec" ? "hwpod-spec.meta.json" : `${parsed.name}.meta.json`;
|
|
return [path.join(parsed.dir, basename), `${specPath}.meta.json`];
|
|
}
|
|
|
|
function summarizeSpec({ document, specPath, metadata }: { document: any; specPath: string; metadata: any }) {
|
|
const docMetadata = objectValue(document.metadata);
|
|
const body = objectValue(document.spec);
|
|
const nodeBinding = objectValue(body.nodeBinding);
|
|
const workspace = objectValue(body.workspace);
|
|
const name = text(docMetadata.name) || text(docMetadata.uid);
|
|
const uid = text(docMetadata.uid) || null;
|
|
const source = clean({
|
|
kind: text(metadata?.source?.kind) || "workspace-or-registry",
|
|
caseRepo: text(metadata?.source?.caseRepo),
|
|
caseId: text(metadata?.source?.caseId),
|
|
caseSpecPath: text(metadata?.source?.caseSpecPath),
|
|
verificationIssue: text(metadata?.source?.verificationIssue),
|
|
verificationPr: text(metadata?.source?.verificationPr),
|
|
verifiedAt: text(metadata?.source?.verifiedAt),
|
|
metadataPath: text(metadata?.metadataPath),
|
|
metadataError: text(metadata?.metadataError)
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: "discovered",
|
|
name,
|
|
hwpodId: name,
|
|
uid,
|
|
metadata: docMetadata,
|
|
specPath,
|
|
authority: source.kind,
|
|
source,
|
|
nodeBinding,
|
|
nodeId: text(nodeBinding.nodeId),
|
|
nodeType: text(nodeBinding.nodeType) || null,
|
|
workspace,
|
|
workspacePath: text(workspace.path),
|
|
targetDevice: body.targetDevice,
|
|
debugProbe: body.debugProbe,
|
|
ioProbe: body.ioProbe,
|
|
document,
|
|
availability: { ok: null, status: "unprobed", checkedAt: null }
|
|
};
|
|
}
|
|
|
|
function dedupeSpecs(specs: any[]) {
|
|
const byKey = new Map();
|
|
for (const spec of specs) {
|
|
const key = spec.ok === false
|
|
? `invalid:${spec.specPath}`
|
|
: [spec.name || spec.uid, spec.nodeId, spec.workspacePath].filter(Boolean).join("|") || spec.specPath;
|
|
const previous = byKey.get(key);
|
|
if (!previous || specPriority(spec) > specPriority(previous)) byKey.set(key, spec);
|
|
}
|
|
return Array.from(byKey.values()).sort((left, right) => String(left.name ?? left.specPath).localeCompare(String(right.name ?? right.specPath)));
|
|
}
|
|
|
|
function specPriority(spec: any) {
|
|
const sourceKind = text(spec.source?.kind);
|
|
const specPath = text(spec.specPath);
|
|
if (specPath.endsWith(DEFAULT_HWPOD_SPEC_PATH)) return 30;
|
|
if (sourceKind.includes("preinstalled")) return 20;
|
|
return 10;
|
|
}
|
|
|
|
function splitEnvList(value: unknown) {
|
|
return String(value ?? "").split(/[,\n]/u).map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function resolveMaybeRelative(value: string, base: string) {
|
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
}
|
|
|
|
function uniquePaths(values: string[]) {
|
|
return Array.from(new Set(values.map((value) => path.resolve(value))));
|
|
}
|
|
|
|
function safeId(value: string) {
|
|
return String(value || "spec").replace(/[^A-Za-z0-9_.:-]+/gu, "_").slice(0, 80) || "spec";
|
|
}
|
|
|
|
function text(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
}
|
|
|
|
function objectValue(value: any) {
|
|
return isPlainObject(value) ? value : {};
|
|
}
|
|
|
|
function isPlainObject(value: any) {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function clean(value: Record<string, unknown>) {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null));
|
|
}
|