Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
415 lines
12 KiB
JavaScript
415 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const scriptSource = await fs.readFile(__filename, "utf8");
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, ".."));
|
|
const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md");
|
|
const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json");
|
|
|
|
const workflow = await fs.readFile(workflowPath, "utf8");
|
|
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
|
|
validateManifest(manifest);
|
|
|
|
const outputs = buildOutputs({
|
|
manifest,
|
|
workflow,
|
|
scriptSource,
|
|
});
|
|
|
|
if (args.mode === "check") {
|
|
const drifted = await findDriftedOutputs(workspaceRoot, outputs);
|
|
if (drifted.length > 0) {
|
|
console.error("AI workflow outputs are out of sync:");
|
|
for (const drift of drifted) {
|
|
console.error(`- ${drift}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("AI workflow outputs are in sync.");
|
|
return;
|
|
}
|
|
|
|
const changed = await writeOutputs(workspaceRoot, outputs);
|
|
if (changed.length === 0) {
|
|
console.log("AI workflow outputs are already up to date.");
|
|
return;
|
|
}
|
|
|
|
console.log("Updated AI workflow outputs:");
|
|
for (const changedPath of changed) {
|
|
console.log(`- ${changedPath}`);
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
mode: null,
|
|
root: null,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
|
|
if (arg === "--write") {
|
|
args.mode = "write";
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--check") {
|
|
args.mode = "check";
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--root") {
|
|
args.root = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Unsupported argument: ${arg}`);
|
|
}
|
|
|
|
if (!args.mode) {
|
|
throw new Error("Expected --write or --check.");
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function validateManifest(manifest) {
|
|
const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"];
|
|
for (const key of requiredKeys) {
|
|
if (!(key in manifest)) {
|
|
throw new Error(`Manifest is missing required key: ${key}`);
|
|
}
|
|
}
|
|
|
|
const seenPaths = new Set();
|
|
for (const output of manifest.generated_outputs) {
|
|
if (!output.path || !output.template) {
|
|
throw new Error("Each generated output must define path and template.");
|
|
}
|
|
|
|
if (seenPaths.has(output.path)) {
|
|
throw new Error(`Duplicate generated output path: ${output.path}`);
|
|
}
|
|
|
|
seenPaths.add(output.path);
|
|
}
|
|
}
|
|
|
|
function buildOutputs(context) {
|
|
const outputs = [];
|
|
for (const output of context.manifest.generated_outputs) {
|
|
outputs.push({
|
|
path: normalizeRelativePath(output.path),
|
|
content: renderTemplate(output, context),
|
|
});
|
|
}
|
|
return outputs;
|
|
}
|
|
|
|
function renderTemplate(output, context) {
|
|
const templateMap = {
|
|
codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest),
|
|
codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project),
|
|
aiassistant_backend_tests_rule: () =>
|
|
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines),
|
|
aiassistant_backend_routes_rule: () =>
|
|
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines),
|
|
aiassistant_frontend_tests_rule: () =>
|
|
renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines),
|
|
junie_backend_guidelines: () =>
|
|
renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines),
|
|
junie_frontend_guidelines: () =>
|
|
renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines),
|
|
copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot),
|
|
workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project),
|
|
project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project),
|
|
project_sync_script: () => context.scriptSource,
|
|
};
|
|
|
|
const renderer = templateMap[output.template];
|
|
if (!renderer) {
|
|
throw new Error(`Unsupported template: ${output.template}`);
|
|
}
|
|
|
|
return ensureTrailingNewline(renderer());
|
|
}
|
|
|
|
function renderWorkspaceCodexEnvironment(manifest) {
|
|
return renderCodexEnvironment({
|
|
name: manifest.workspace.name,
|
|
setup: manifest.workspace.codex_environment.setup,
|
|
actions: manifest.workspace.codex_environment.actions,
|
|
manifest,
|
|
workspaceScoped: true,
|
|
});
|
|
}
|
|
|
|
function renderProjectCodexEnvironment(manifest, projectKey) {
|
|
const project = manifest.projects[projectKey];
|
|
return renderCodexEnvironment({
|
|
name: project.codex_environment.name,
|
|
setup: project.commands.setup,
|
|
actions: project.codex_environment.actions,
|
|
manifest,
|
|
projectKey,
|
|
workspaceScoped: false,
|
|
});
|
|
}
|
|
|
|
function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) {
|
|
const lines = [
|
|
"# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY",
|
|
"version = 1",
|
|
`name = "${name}"`,
|
|
"",
|
|
];
|
|
|
|
appendTomlCommandBlock(lines, "setup", setup);
|
|
|
|
for (const action of actions) {
|
|
lines.push("");
|
|
lines.push("[[actions]]");
|
|
lines.push(`name = "${escapeTomlString(action.name)}"`);
|
|
lines.push(`icon = "${escapeTomlString(action.icon)}"`);
|
|
lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`);
|
|
|
|
if (action.platform) {
|
|
lines.push(`platform = "${escapeTomlString(action.platform)}"`);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function appendTomlCommandBlock(lines, key, commandSpec = {}) {
|
|
lines.push(`[${key}]`);
|
|
lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`);
|
|
|
|
if (commandSpec.win32 !== undefined) {
|
|
lines.push("");
|
|
lines.push(`[${key}.win32]`);
|
|
lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`);
|
|
}
|
|
}
|
|
|
|
function resolveActionCommand(action, manifest, projectKey, workspaceScoped) {
|
|
if (action.literal_command) {
|
|
return action.literal_command.default ?? "";
|
|
}
|
|
|
|
if (!action.command_id) {
|
|
throw new Error(`Action ${action.name} is missing command_id or literal_command.`);
|
|
}
|
|
|
|
const resolvedProjectKey = action.project ?? projectKey;
|
|
if (!resolvedProjectKey) {
|
|
throw new Error(`Action ${action.name} does not resolve to a project.`);
|
|
}
|
|
|
|
const project = manifest.projects[resolvedProjectKey];
|
|
const command = project.commands[action.command_id]?.default ?? "";
|
|
|
|
if (!workspaceScoped) {
|
|
return command;
|
|
}
|
|
|
|
return joinCommands([`cd ${project.relative_root}`, command]);
|
|
}
|
|
|
|
function renderAiAssistantRule(lines) {
|
|
return [
|
|
"---",
|
|
"apply: always",
|
|
"---",
|
|
"",
|
|
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
|
"",
|
|
...lines,
|
|
].join("\n");
|
|
}
|
|
|
|
function renderJunieGuidelines(lines) {
|
|
return [
|
|
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
|
"",
|
|
...lines,
|
|
].join("\n");
|
|
}
|
|
|
|
function renderCopilotWorkflow(copilot) {
|
|
const body = copilot.body_lines
|
|
.map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK"))
|
|
.join("\n");
|
|
|
|
return [
|
|
`name: ${copilot.workflow_name}`,
|
|
"on:",
|
|
" workflow_dispatch:",
|
|
" inputs:",
|
|
" task:",
|
|
` description: '${copilot.input_description}'`,
|
|
" required: true",
|
|
" type: string",
|
|
"",
|
|
"jobs:",
|
|
" assign-task:",
|
|
" runs-on: ubuntu-latest",
|
|
" permissions:",
|
|
" issues: write",
|
|
" steps:",
|
|
" - name: Checkout repository",
|
|
" uses: actions/checkout@v4",
|
|
"",
|
|
" - name: Create GitHub Issue for Copilot",
|
|
" env:",
|
|
" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
|
|
" TASK: ${{ github.event.inputs.task }}",
|
|
" ACTOR: ${{ github.actor }}",
|
|
" run: |",
|
|
" BODY=\"$(cat <<EOF",
|
|
...body.split("\n").map((line) => ` ${line}`),
|
|
" EOF",
|
|
" )\"",
|
|
" gh issue create \\",
|
|
` --title "${copilot.title_prefix} $TASK" \\`,
|
|
" --body \"$BODY\" \\",
|
|
` --label "${copilot.issue_label}"`,
|
|
].join("\n");
|
|
}
|
|
|
|
function renderWorkflowSnapshot(workflow, projectKey) {
|
|
return [
|
|
`<!-- AUTOGENERATED SNAPSHOT for ${projectKey}: refresh from the canonical workspace with \`node scripts/sync-ai-workflow.mjs --write\`. -->`,
|
|
"",
|
|
workflow.trimEnd(),
|
|
].join("\n");
|
|
}
|
|
|
|
function renderProjectSnapshotManifest(manifest, projectKey) {
|
|
const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey);
|
|
return `${JSON.stringify(snapshotManifest, null, 2)}\n`;
|
|
}
|
|
|
|
function buildProjectSnapshotManifest(manifest, projectKey) {
|
|
const project = JSON.parse(JSON.stringify(manifest.projects[projectKey]));
|
|
const prefix = `${manifest.projects[projectKey].relative_root}/`;
|
|
|
|
project.relative_root = ".";
|
|
|
|
const snapshotOutputs = manifest.generated_outputs
|
|
.filter((output) => output.project === projectKey)
|
|
.filter((output) => output.template !== "workflow_snapshot")
|
|
.filter((output) => output.template !== "project_snapshot_manifest")
|
|
.filter((output) => output.template !== "project_sync_script")
|
|
.map((output) => ({
|
|
...output,
|
|
path: normalizeRelativePath(output.path.slice(prefix.length)),
|
|
}));
|
|
|
|
const snapshotManifest = {
|
|
"version": manifest.version,
|
|
"snapshot_of": projectKey,
|
|
"projects": {
|
|
[projectKey]: project,
|
|
},
|
|
"assistants": manifest.assistants,
|
|
"commands": manifest.commands,
|
|
"generated_outputs": snapshotOutputs,
|
|
"sync_targets": {
|
|
[projectKey]: {
|
|
"source_root": ".",
|
|
"destination": manifest.sync_targets[projectKey]?.destination ?? "",
|
|
"supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [],
|
|
},
|
|
},
|
|
};
|
|
|
|
if (projectKey === "backend-php" && manifest.copilot) {
|
|
snapshotManifest.copilot = manifest.copilot;
|
|
}
|
|
|
|
if (projectKey === "front-end-vue" && manifest.unsupported) {
|
|
snapshotManifest.unsupported = manifest.unsupported;
|
|
}
|
|
|
|
return snapshotManifest;
|
|
}
|
|
|
|
async function findDriftedOutputs(workspaceRoot, outputs) {
|
|
const drifted = [];
|
|
for (const output of outputs) {
|
|
const absolutePath = path.join(workspaceRoot, output.path);
|
|
const currentContent = await readIfExists(absolutePath);
|
|
if (currentContent !== output.content) {
|
|
drifted.push(output.path);
|
|
}
|
|
}
|
|
return drifted;
|
|
}
|
|
|
|
async function writeOutputs(workspaceRoot, outputs) {
|
|
const changed = [];
|
|
for (const output of outputs) {
|
|
const absolutePath = path.join(workspaceRoot, output.path);
|
|
const currentContent = await readIfExists(absolutePath);
|
|
if (currentContent === output.content) {
|
|
continue;
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
await fs.writeFile(absolutePath, output.content, "utf8");
|
|
changed.push(output.path);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
async function readIfExists(filePath) {
|
|
try {
|
|
return await fs.readFile(filePath, "utf8");
|
|
} catch (error) {
|
|
if (error && error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function joinCommands(commands) {
|
|
return commands.filter((command) => command && command.trim() !== "").join("\n");
|
|
}
|
|
|
|
function renderTomlMultiline(value) {
|
|
return `'''\n${value ?? ""}\n'''`;
|
|
}
|
|
|
|
function escapeTomlString(value) {
|
|
return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
}
|
|
|
|
function normalizeRelativePath(filePath) {
|
|
return filePath.replaceAll("\\", "/");
|
|
}
|
|
|
|
function ensureTrailingNewline(value) {
|
|
return value.endsWith("\n") ? value : `${value}\n`;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.message);
|
|
process.exit(1);
|
|
});
|