@alavida/agentpack
Version:
Compiler-driven lifecycle CLI for source-backed agent skills
316 lines (282 loc) • 10.8 kB
JavaScript
import { join } from 'node:path';
import { hashFile } from '../../domain/compiler/source-hash.js';
import { readCompiledState } from '../../infrastructure/fs/compiled-state-repository.js';
import { startSkillDevWorkbenchServer } from '../../infrastructure/runtime/skill-dev-workbench-server.js';
import { openBrowser } from '../../infrastructure/runtime/open-browser.js';
import { watchSkillWorkbench } from '../../infrastructure/runtime/watch-skill-workbench.js';
import { runSkillWorkbenchAction } from './run-skill-workbench-action.js';
import { resolveSingleSkillTarget } from '../../domain/skills/skill-target-resolution.js';
import { computeRuntimeSelectionUseCase } from './compute-runtime-selection.js';
function explainCompiledSourceStatus(status) {
if (status === 'changed') return 'Changed since compiled state was built';
return 'Current against compiled state';
}
function inferDependencyName(target) {
const [packageName, exportedName] = target.split(':');
if (exportedName) return exportedName;
return packageName.split('/').pop();
}
function inferDependencyType(selectedSkill, target, selection) {
const dependencySkill = selection.exports.find((skill) => skill.exportId === target) || null;
if (dependencySkill && dependencySkill.packageName === selectedSkill.packageName) {
return 'internal-skill';
}
return 'external-package';
}
function buildStatusMaps(selection, sourceNodes, selectedSkill) {
const staleExports = new Set();
for (const sourceNode of sourceNodes) {
if (sourceNode.status !== 'changed') continue;
for (const exportId of sourceNode.usedBy || []) {
staleExports.add(exportId);
}
}
const affectedExports = new Set();
const queue = [...staleExports];
while (queue.length > 0) {
const staleExportId = queue.shift();
for (const skill of selection.exports || []) {
const imports = skill.skillImports || [];
if (!imports.some((entry) => entry.target === staleExportId)) continue;
if (staleExports.has(skill.exportId) || affectedExports.has(skill.exportId)) continue;
affectedExports.add(skill.exportId);
queue.push(skill.exportId);
}
}
const selectedStatus = staleExports.has(selectedSkill.exportId)
? 'stale'
: (affectedExports.has(selectedSkill.exportId) ? 'affected' : 'current');
return {
staleExports,
affectedExports,
selectedStatus,
};
}
function resolveDependencyMetadata(repoRoot, skillImport, selection) {
const dependencySkill = selection.exports.find((skill) => skill.exportId === skillImport.target) || null;
if (dependencySkill) {
return {
packageName: dependencySkill.packageName,
name: dependencySkill.name,
description: dependencySkill.description || null,
version: dependencySkill.packageVersion || null,
status: 'current',
navigationTarget: dependencySkill.exportId,
};
}
try {
const resolved = resolveSingleSkillTarget(repoRoot, skillImport.target, { includeInstalled: false });
return {
packageName: resolved.package.packageName,
name: resolved.export.runtimeName || resolved.export.declaredName || resolved.export.name,
description: resolved.export.description || null,
version: resolved.package.packageVersion || null,
status: 'current',
navigationTarget: resolved.export.id,
};
} catch {
return {
packageName: skillImport.target,
name: inferDependencyName(skillImport.target),
description: null,
version: null,
status: 'unknown',
navigationTarget: skillImport.target,
};
}
}
function buildModelFromSelection(repoRoot, selection) {
const selectedSkill = selection.exports.find((skill) => skill.exportId === selection.selectedExportId)
|| selection.exports[0]
|| null;
if (!selectedSkill) return null;
const visibleExportIds = new Set(selection.exports.map((skill) => skill.exportId));
const changedSources = new Set();
const compiledState = readCompiledState(repoRoot);
const packageState = compiledState?.packages?.[selection.packageName] || null;
const sourceFileRecords = new Map((packageState?.sourceFiles || []).map((entry) => [entry.path, entry]));
const sourceNodes = (selection.sources || []).map((sourceFile) => {
const recorded = sourceFileRecords.get(sourceFile.path) || null;
const currentHash = hashFile(join(repoRoot, sourceFile.path));
const changed = recorded ? currentHash !== recorded.hash : false;
if (changed) changedSources.add(sourceFile.path);
return {
id: `source:${sourceFile.path}`,
type: 'source',
path: sourceFile.path,
status: changed ? 'changed' : 'current',
explanation: explainCompiledSourceStatus(changed ? 'changed' : 'current'),
depth: 0,
usedBy: sourceFile.usedBy,
};
});
const statusMaps = buildStatusMaps(selection, sourceNodes, selectedSkill);
// BFS from selected skill: compute depths for all transitive dependencies and collect all requires edges
const skillByExportId = new Map(selection.exports.map((skill) => [skill.exportId, skill]));
const depthMap = new Map();
const primaryContext = new Map();
const requiresEdges = [];
depthMap.set(selectedSkill.exportId, 0);
const bfsQueue = [selectedSkill];
while (bfsQueue.length > 0) {
const currentSkill = bfsQueue.shift();
const currentDepth = depthMap.get(currentSkill.exportId);
for (const skillImport of currentSkill.skillImports || []) {
requiresEdges.push({
source: currentSkill.exportId,
target: skillImport.target,
kind: 'requires',
context: skillImport.context || null,
targetType: inferDependencyType(selectedSkill, skillImport.target, selection),
});
if (depthMap.has(skillImport.target)) continue;
depthMap.set(skillImport.target, currentDepth + 1);
primaryContext.set(skillImport.target, skillImport.context || null);
const depSkill = skillByExportId.get(skillImport.target);
if (depSkill) {
bfsQueue.push(depSkill);
}
}
}
const dependencyNodes = [];
for (const [exportId, depth] of depthMap) {
if (exportId === selectedSkill.exportId) continue;
const dependencyMetadata = resolveDependencyMetadata(repoRoot, { target: exportId }, selection);
const type = inferDependencyType(selectedSkill, exportId, selection);
const dependencyStatus = statusMaps.staleExports.has(exportId)
? 'stale'
: (statusMaps.affectedExports.has(exportId) ? 'affected' : dependencyMetadata.status);
dependencyNodes.push({
id: exportId,
type,
packageName: dependencyMetadata.packageName,
navigationTarget: dependencyMetadata.navigationTarget,
name: dependencyMetadata.name,
context: primaryContext.get(exportId),
description: dependencyMetadata.description,
version: dependencyMetadata.version,
status: dependencyStatus,
explanation: dependencyStatus === 'stale'
? 'Directly impacted by one or more changed source files'
: dependencyStatus === 'affected'
? 'Affected by upstream stale sub-skills'
: type === 'internal-skill'
? 'Internal sub-skill in the same package'
: 'External package dependency',
depth,
});
}
const { selectedStatus } = statusMaps;
const selectedNode = {
id: selectedSkill.exportId,
type: 'skill',
packageName: selectedSkill.packageName,
name: selectedSkill.name,
description: selectedSkill.description || null,
version: selectedSkill.packageVersion || null,
status: selectedStatus,
explanation: selectedStatus === 'stale'
? `Stale because one or more compiled sources changed: ${[...changedSources].join(', ')}`
: selectedStatus === 'affected'
? `Affected by stale dependencies triggered by changed sources: ${[...changedSources].join(', ')}`
: 'Current against compiled state',
depth: 0,
sourceCount: sourceNodes.length,
sourceSummary: sourceNodes.length > 0
? `${sourceNodes.length} bound source file${sourceNodes.length === 1 ? '' : 's'} in this graph`
: 'No bound source material in this graph',
};
const edges = [
...sourceNodes.flatMap((node) => {
const targets = (node.usedBy || []).filter((exportId) => visibleExportIds.has(exportId));
if (targets.length === 0) {
return [{
source: node.id,
target: selectedNode.id,
kind: 'provenance',
}];
}
return targets.map((exportId) => ({
source: node.id,
target: exportId,
kind: 'provenance',
}));
}),
...requiresEdges,
];
return {
selected: selectedNode,
nodes: [...sourceNodes, selectedNode, ...dependencyNodes],
edges,
};
}
function resolveWorkbenchSelection(repoRoot, target) {
const resolved = resolveSingleSkillTarget(repoRoot, target, { includeInstalled: false, cwd: repoRoot });
return computeRuntimeSelectionUseCase({
cwd: repoRoot,
mode: 'closure',
packageName: resolved.package.packageName,
exportId: resolved.export.id,
});
}
export function resolveSkillDevWorkbenchModel({
repoRoot,
defaultTarget,
requestedTarget = null,
}) {
const selection = resolveWorkbenchSelection(repoRoot, requestedTarget || defaultTarget);
return buildModelFromSelection(repoRoot, selection);
}
export async function startSkillDevWorkbench({
repoRoot,
skillDir,
open = true,
disableBrowser = false,
}) {
const resolved = resolveSingleSkillTarget(repoRoot, skillDir, { includeInstalled: false });
const defaultSkill = resolved.package.packageName;
const packageDir = resolved.package.packageDir;
const defaultTarget = resolved.export.id;
const getSelection = () => computeRuntimeSelectionUseCase({
cwd: repoRoot,
mode: 'closure',
packageName: resolved.package.packageName,
exportId: resolved.export.id,
});
const server = await startSkillDevWorkbenchServer({
buildModel: (requestedTarget) => resolveSkillDevWorkbenchModel({
repoRoot,
defaultTarget,
requestedTarget,
}),
defaultSkill,
onAction(action) {
return runSkillWorkbenchAction(action, {
cwd: repoRoot,
target: skillDir,
packageName: defaultSkill,
});
},
});
const watcher = watchSkillWorkbench(repoRoot, {
packageDir,
getSelection,
onRefresh: () => {
// Model is rebuilt on each request, so no cache to invalidate
},
});
if (open && !disableBrowser) {
openBrowser(server.url);
}
return {
url: server.url,
port: server.port,
refresh() {
// no-op: models are built on demand per request
},
close() {
watcher.close();
server.close();
},
};
}