mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
Add Java Modernization Studio canvas extension (#2156)
* Add Java Modernization Studio canvas extension A canvas extension that drives the GitHub Copilot App Modernization for Java workflow from an interactive dashboard: environment readiness checks, repo assessment, prioritized plan/progress, validation gates (CVE scan, test generation), and one-click predefined-task runs — all grounded in the repo's real artifacts (.appmod/assessment.json, plan.md, progress.md). Includes a node:test suite (109 tests) for the grounding logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Secure cockpit loopback server with a per-instance token Address Copilot review feedback on the Java Modernization Studio canvas: - Mint a per-instance secret in createInstanceServer, embed it in the iframe URL, and validate it on every loopback request (/, /state, /events, /action). Other local processes can no longer read repo state or dispatch agent actions just by guessing the random port. Mirrors the existing diagram-viewer token pattern; the client echoes the token from its boot payload. The guard is a no-op when no token is set, so direct makeHandler unit tests are unaffected. - Handle async request-handler rejections in createServer with a .catch that returns 500 and logs, instead of leaking an unhandled rejection that could destabilize the extension process. - Tests: token guard 403s unauthenticated /, /state, /events and /action and allows valid-token requests; the end-to-end test asserts the tokenized URL and a tokenless 403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CI: add preview screenshot and clear codespell hits Add the required assets/preview.png screenshot for the canvas-extension validator, and resolve two codespell findings in the cockpit: rename the planSim helper's `nd` variable to `notDone` and reword a catalog comment to avoid the `invokable`/`invocable` flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden cockpit loopback server per Copilot review Address the second-round Copilot review comments on the loopback server: - broadcast(): drop an SSE client whose write() throws instead of keeping it in the Set, so a uncleanly-disconnected client can't cause repeated exceptions or leak dead entries on every subsequent broadcast. - POST /action: treat a malformed JSON body or a missing/invalid "kind" as a 400 client error (with an application/json body) instead of a 200 carrying { ok:false, error:"Unknown action: undefined" }. - Handler catch: set Content-Type: application/json on the 500 error response so it is consistent with the other JSON routes. Adds four tests covering dead-client eviction, the two 400 paths, and the JSON-typed 500. Full suite: 115 passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Add canvas.json gallery metadata for Java Modernization Studio Aaron requested a canvas.json so the awesome-copilot website/gallery can list this extension. Generated via `npm run website:data` (writePerExtensionCanvasManifests), which derives id/name/description/ version/keywords/screenshots from package.json + the createCanvas source + assets, and carries the author through. Output committed verbatim so a CI regeneration produces no diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> * Add author tag to package.json Declare the author in the package manifest (object form, matching the author already carried in canvas.json so it includes the profile URL). The published gallery sources author from canvas.json; this adds the conventional npm author tag to package.json for completeness. Verified `npm run website:data` still emits the author on the extensions record and leaves canvas.json byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> --------- Signed-off-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
artifacts/
|
||||
@@ -0,0 +1,92 @@
|
||||
# Java Modernization Studio
|
||||
|
||||
An interactive GitHub Copilot **canvas** that turns the [GitHub Copilot App Modernization for Java](https://learn.microsoft.com/en-us/azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java) CLI workflow into a visible, steerable dashboard — assess a legacy Java app, drive a prioritized remediation plan, run validation gates, and dispatch Microsoft predefined tasks, all grounded in the repo's real artifacts.
|
||||
|
||||
The Copilot App Modernization for Java tooling stays the engine. This canvas is the cockpit on top of it: it reads what the workflow produces and turns each step into an agent-driving button.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Overview** — at-a-glance modernization status (phase, % complete, finding counts) scanned from the repo.
|
||||
- **Readiness (Environment Doctor)** — checks JDK, Maven (or `./mvnw`), Git, Docker, and Azure CLI on PATH and flags what's missing before you start.
|
||||
- **Assessment** — renders structured findings from `.appmod/assessment.json` (stack summary, severity-ordered findings, strengths), each with a one-click action.
|
||||
- **Plan & Progress** — renders `plan.md` / `progress.md` as live checklists (`- [ ]` / `- [x]`).
|
||||
- **Validation** — runs the workflow's quality gates (CVE validation, test generation) before and after changes.
|
||||
- **Tasks** — dispatches Microsoft predefined modernization tasks (managed identity for DB, secrets → Key Vault, message-broker → Service Bus, S3 → Blob, cache → Redis, Entra ID auth, and more) relevant to the detected stack.
|
||||
- **Summary** — surfaces `summary.md` when the run is complete.
|
||||
- **Autopilot** — an optional phase-ordered, hands-free loop that advances assessment → remediation → validation and updates the dashboard as the agent makes progress.
|
||||
|
||||
Buttons don't execute logic in the canvas — they dispatch a grounded prompt to your Copilot agent (action kinds: `run_task`, `generate_plan`, `run_cve`, `generate_tests`, `fix_finding`). The agent does the work; the canvas reflects the result.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **GitHub Copilot app** (the canvas host).
|
||||
- **GitHub Copilot App Modernization for Java** tooling — the underlying workflow this canvas drives.
|
||||
- **JDK 17+** and **Maven** (or a `./mvnw` wrapper) for the Java project you're modernizing.
|
||||
- *Optional:* **Azure CLI** (`az`) for cloud-readiness and Azure migration tasks; **Docker** for container checks.
|
||||
|
||||
## Install
|
||||
|
||||
This is an in-repo canvas extension. Copy the `java-modernization-studio/` folder into one of:
|
||||
|
||||
- `~/.copilot/extensions/` — **user scope** (just you), or
|
||||
- `.github/extensions/` — **project scope** (shared with your repo's team).
|
||||
|
||||
Then reload extensions (or restart the app) so Copilot discovers it. No build step is required — the Copilot CLI resolves `@github/copilot-sdk` automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
Point the canvas at a Java repository and let the agent drive it:
|
||||
|
||||
```text
|
||||
Open the Java Modernization Studio canvas for /path/to/my-java-app and run a readiness check.
|
||||
```
|
||||
|
||||
The canvas resolves the target repo from its `repoPath` input, falling back to the session's working directory. From there:
|
||||
|
||||
1. **Readiness first** — resolve any missing JDK/Maven/Azure CLI the Doctor flags.
|
||||
2. **Assess** — generate `.appmod/assessment.json` + a prioritized `plan.md` / `progress.md`.
|
||||
3. **Remediate** — work findings in severity order (P0 first), using task buttons and "Help me fix this".
|
||||
4. **Validate** — run the CVE and test-generation gates.
|
||||
5. **Ship** — when the work is genuinely complete, write `summary.md`.
|
||||
|
||||
### Suggested agent instructions
|
||||
|
||||
```text
|
||||
When a user modernizes a Java project with the Java Modernization Studio canvas:
|
||||
1) Open the canvas pointed at the repo (repoPath) and run the Environment Doctor first.
|
||||
2) Run an assessment; write findings to .appmod/assessment.json and a prioritized plan.md / progress.md.
|
||||
Start plan.md, progress.md, and summary.md with the exact first line <!-- appmod-cockpit --> so the
|
||||
canvas recognizes them as modernization artifacts.
|
||||
3) Work findings in severity order (P0 first); run the validation gates (CVE scan, test generation)
|
||||
before and after code changes.
|
||||
4) Keep plan.md / progress.md updated as - [ ] / - [x] checklists. Only write summary.md when the
|
||||
work is truly complete.
|
||||
```
|
||||
|
||||
## How it stays grounded
|
||||
|
||||
The canvas renders **real repo state**, never invented status:
|
||||
|
||||
- Structured findings come from `.appmod/assessment.json`.
|
||||
- Plan/progress/summary come from root `plan.md` / `progress.md` / `summary.md`.
|
||||
- To avoid mistaking an unrelated repo's `plan.md` for modernization output, root markdown is trusted **only** when `.appmod/` exists **or** the file's first line is the provenance marker `<!-- appmod-cockpit -->`.
|
||||
- Stack detection (build tool, Java version, framework, container) is parsed from `pom.xml` / Gradle / Dockerfile and drives which tasks are shown.
|
||||
|
||||
## Agent-callable actions
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `get_state` | Return the current modernization snapshot scanned from the repo (assessment, plan/progress, gates, tasks). |
|
||||
| `refresh` | Re-scan the repo and push a fresh snapshot to the open canvas. |
|
||||
|
||||
## Development
|
||||
|
||||
The grounding/parsing logic is pure and unit-tested independently of the canvas runtime:
|
||||
|
||||
```bash
|
||||
node --test test/cockpit.test.mjs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
@@ -0,0 +1,162 @@
|
||||
// autopilot.mjs — Sequencing engine for "Run on autopilot".
|
||||
//
|
||||
// Given the live plan, Autopilot drives the agent through the checklist one
|
||||
// step at a time: pick the next eligible step (respecting phase ordering),
|
||||
// hand it to the agent, wait for the turn to finish, re-scan, and repeat —
|
||||
// streaming progress to the canvas after every step.
|
||||
//
|
||||
// All I/O is injected (`snapshot`, `runTurn`, `buildStepPrompt`, `onProgress`)
|
||||
// so the loop is unit-testable without a live session or HTTP server. The pure
|
||||
// helpers below are the same selection logic the renderer uses for "Continue
|
||||
// here", which keeps the visible recommendation and the automated run in sync.
|
||||
|
||||
// How long to wait for a single step's turn to go idle. Generous: one
|
||||
// modernization step can involve edits, a build, and tests. The wait does not
|
||||
// abort in-flight agent work; it only bounds how long Autopilot blocks before
|
||||
// treating the step as failed.
|
||||
export const AUTOPILOT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export const AUTOPILOT_MAX_STEPS = 25;
|
||||
|
||||
/** The checklist Autopilot follows: progress.md when present, else plan.md. */
|
||||
export function currentSteps(state) {
|
||||
if (!state) return [];
|
||||
if (state.progress && state.progress.steps && state.progress.steps.length) return state.progress.steps;
|
||||
if (state.plan && state.plan.steps) return state.plan.steps;
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Stable identity for a step across re-scans (phase + title). */
|
||||
export function stepKey(step) {
|
||||
if (!step) return "";
|
||||
return (step.section || "") + "::" + (step.title || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The next step Autopilot should run: the first not-done step in the active
|
||||
* phase, falling back to the first not-done step overall. Mirrors the renderer's
|
||||
* "Continue here" so the automated run never jumps ahead of the safe next step.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function selectNextStep(state) {
|
||||
const steps = currentSteps(state);
|
||||
if (!steps.length) return null;
|
||||
const ord = (state && state.ordering) || { activeRank: null };
|
||||
const inPhase = steps.find(
|
||||
(x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank)
|
||||
);
|
||||
return inPhase || steps.find((x) => x.status !== "done") || null;
|
||||
}
|
||||
|
||||
/** Whether the given step is now checked off in a freshly scanned state. */
|
||||
export function isStepDone(state, step) {
|
||||
const k = stepKey(step);
|
||||
const match = currentSteps(state).find((s) => stepKey(s) === k);
|
||||
return !!(match && match.status === "done");
|
||||
}
|
||||
|
||||
/** Construct the mutable, serializable run record broadcast to the canvas. */
|
||||
export function makeRun({ scope, maxSteps, startRank } = {}) {
|
||||
return {
|
||||
running: true,
|
||||
cancelled: false,
|
||||
scope: scope === "all" ? "all" : "phase",
|
||||
maxSteps: maxSteps || AUTOPILOT_MAX_STEPS,
|
||||
startRank: startRank == null ? null : startRank,
|
||||
status: "running",
|
||||
current: null,
|
||||
completed: [],
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the run to completion. Mutates `run` in place and calls
|
||||
* `deps.onProgress(run, state|null)` whenever something changes so the caller
|
||||
* can broadcast. Resolves with the final `run`.
|
||||
*
|
||||
* @param {object} run from makeRun()
|
||||
* @param {{
|
||||
* snapshot: () => Promise<object>,
|
||||
* runTurn: (prompt: string) => Promise<any>,
|
||||
* buildStepPrompt: (step: object) => string,
|
||||
* onProgress: (run: object, state: object|null) => void,
|
||||
* log?: Function,
|
||||
* }} deps
|
||||
*/
|
||||
export async function runAutopilot(run, deps) {
|
||||
const onProgress = deps.onProgress || (() => {});
|
||||
let lastKey = null;
|
||||
try {
|
||||
while (true) {
|
||||
if (run.cancelled) {
|
||||
run.status = "cancelled";
|
||||
break;
|
||||
}
|
||||
if (run.completed.length >= run.maxSteps) {
|
||||
run.status = "capped";
|
||||
break;
|
||||
}
|
||||
const state = await deps.snapshot();
|
||||
const step = selectNextStep(state);
|
||||
if (!step) {
|
||||
run.status = "completed";
|
||||
break;
|
||||
}
|
||||
// Phase scope: stop once the active phase advances past where we began.
|
||||
if (run.scope === "phase" && run.startRank != null && step.rank != null && step.rank > run.startRank) {
|
||||
run.status = "phase_done";
|
||||
break;
|
||||
}
|
||||
const key = stepKey(step);
|
||||
// Selecting the same step twice running means the previous attempt did
|
||||
// not check it off — the agent is stuck or waiting on a decision. Stop
|
||||
// and hand control back rather than loop on it.
|
||||
if (key === lastKey) {
|
||||
run.status = "stuck";
|
||||
run.stuck = step.title;
|
||||
break;
|
||||
}
|
||||
lastKey = key;
|
||||
|
||||
run.current = { title: step.title, section: step.section || null };
|
||||
onProgress(run, state);
|
||||
if (deps.log) deps.log("Autopilot → " + step.title, { ephemeral: true });
|
||||
|
||||
let stepError = null;
|
||||
try {
|
||||
await deps.runTurn(deps.buildStepPrompt(step));
|
||||
} catch (e) {
|
||||
stepError = (e && e.message) || String(e);
|
||||
}
|
||||
|
||||
const after = await deps.snapshot();
|
||||
const done = isStepDone(after, step);
|
||||
run.completed.push({
|
||||
title: step.title,
|
||||
section: step.section || null,
|
||||
done,
|
||||
error: stepError,
|
||||
at: Date.now(),
|
||||
});
|
||||
run.current = null;
|
||||
onProgress(run, after);
|
||||
|
||||
if (stepError) {
|
||||
run.status = "error";
|
||||
run.error = stepError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
run.status = "error";
|
||||
run.error = (e && e.message) || String(e);
|
||||
} finally {
|
||||
if (run.status === "running") run.status = "completed";
|
||||
run.running = false;
|
||||
run.finishedAt = Date.now();
|
||||
onProgress(run, null);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "appmod-cockpit",
|
||||
"name": "Java Modernization Studio",
|
||||
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Ayan Gupta",
|
||||
"url": "https://github.com/ayangupt"
|
||||
},
|
||||
"keywords": [
|
||||
"app-modernization",
|
||||
"assessment-dashboard",
|
||||
"azure-migration",
|
||||
"java-modernization",
|
||||
"legacy-java",
|
||||
"modernization-cockpit",
|
||||
"validation-gates"
|
||||
],
|
||||
"screenshots": {
|
||||
"icon": {
|
||||
"path": "assets/preview.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
"gallery": {
|
||||
"path": "assets/preview.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// catalog.mjs — Static catalog of GitHub Copilot App Modernization for Java
|
||||
// predefined tasks and supported Java upgrade paths. Sourced from Microsoft Learn:
|
||||
// /azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java-predefined-tasks
|
||||
// /azure/developer/github-copilot-app-modernization/tools
|
||||
//
|
||||
// `detect` lists dependency-signature keys (see scan.mjs DEP_SIGNATURES). When a
|
||||
// repo's build files match one of those keys, the task is flagged "relevant".
|
||||
|
||||
export const PREDEFINED_TASKS = [
|
||||
{
|
||||
id: "rabbitmq-to-servicebus",
|
||||
name: "RabbitMQ to Azure Service Bus",
|
||||
category: "Messaging",
|
||||
summary:
|
||||
"Convert RabbitMQ usage (Spring AMQP, Spring JMS, or Jakarta EE over AMQP) to Azure Service Bus, preserving messaging semantics with secure auth by default.",
|
||||
detect: ["rabbitmq"],
|
||||
},
|
||||
{
|
||||
id: "activemq-to-servicebus",
|
||||
name: "ActiveMQ to Azure Service Bus",
|
||||
category: "Messaging",
|
||||
summary:
|
||||
"Convert ActiveMQ producers, consumers, connection factories, and queue/topic interactions to Azure Service Bus equivalents.",
|
||||
detect: ["activemq"],
|
||||
},
|
||||
{
|
||||
id: "aws-sqs-to-servicebus",
|
||||
name: "AWS SQS to Azure Service Bus",
|
||||
category: "Messaging",
|
||||
summary:
|
||||
"Translate AWS SQS queue operations and message handling to Azure Service Bus, preserving at-least-once delivery, batching, and visibility-timeout behaviors.",
|
||||
detect: ["awsSqs"],
|
||||
},
|
||||
{
|
||||
id: "managed-identity-db",
|
||||
name: "Managed Identities for Database migration",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Prepare your codebase for Managed Identity authentication when moving from a local database to Azure SQL, MySQL, PostgreSQL, or Cosmos DB.",
|
||||
detect: ["jdbc"],
|
||||
},
|
||||
{
|
||||
id: "managed-identity-credentials",
|
||||
name: "Managed Identities for Credential Migration",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Replace connection strings / shared access signatures for messaging services (Event Hubs, Service Bus) with Azure Managed Identity authentication.",
|
||||
detect: ["rabbitmq", "activemq", "jms"],
|
||||
},
|
||||
{
|
||||
id: "secrets-to-keyvault",
|
||||
name: "Secrets & Certificate Management to Azure Key Vault",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Move hardcoded secrets and local TLS/mTLS certificates (Java KeyStores) to Azure Key Vault, retrieving them at runtime via the JCA provider.",
|
||||
detect: ["keystore"],
|
||||
},
|
||||
{
|
||||
id: "crypto-to-keyvault",
|
||||
name: "Cryptography operations to Azure Key Vault",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Migrate local signing, verification, encryption, and decryption to Azure Key Vault so keys never leave the vault.",
|
||||
detect: ["crypto"],
|
||||
},
|
||||
{
|
||||
id: "aws-secrets-to-keyvault",
|
||||
name: "AWS Secret Manager to Azure Key Vault",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Reconfigure secret creation, retrieval, update, and deletion from AWS Secret Manager to Azure Key Vault.",
|
||||
detect: ["awsSecrets"],
|
||||
},
|
||||
{
|
||||
id: "entra-id-auth",
|
||||
name: "User authentication to Microsoft Entra ID",
|
||||
category: "Identity & Secrets",
|
||||
summary:
|
||||
"Transition LDAP-based user authentication to Microsoft Entra ID authentication.",
|
||||
detect: ["ldap"],
|
||||
},
|
||||
{
|
||||
id: "aws-s3-to-blob",
|
||||
name: "AWS S3 to Azure Storage Blob",
|
||||
category: "Storage",
|
||||
summary:
|
||||
"Convert code that interacts with AWS S3 into Azure Storage Blob logic while maintaining the same semantics.",
|
||||
detect: ["awsS3"],
|
||||
},
|
||||
{
|
||||
id: "file-io-to-fileshare",
|
||||
name: "Local file I/O to Azure Storage File share mounts",
|
||||
category: "Storage",
|
||||
summary:
|
||||
"Convert local file reads/writes to unified mount-path access so an Azure Storage File share can persist data across replicas.",
|
||||
detect: [],
|
||||
},
|
||||
{
|
||||
id: "logging-to-console",
|
||||
name: "Logging to local file → console (Azure Monitor)",
|
||||
category: "Observability",
|
||||
summary:
|
||||
"Convert file-based logging to console-based logging so Azure hosting integrates it with Azure Monitor automatically.",
|
||||
detect: ["filelog"],
|
||||
},
|
||||
{
|
||||
id: "javamail-to-acs",
|
||||
name: "Java Mail to Azure Communication Services",
|
||||
category: "Email",
|
||||
summary:
|
||||
"Convert SMTP-based mail sending to Azure Communication Services, which works in Azure hosting environments that block port 25.",
|
||||
detect: ["javamail"],
|
||||
},
|
||||
{
|
||||
id: "databases-to-azure",
|
||||
name: "On-prem databases to Azure database offerings",
|
||||
category: "Database",
|
||||
summary:
|
||||
"Migrate Oracle, IBM Db2, Informix, or Sybase ASE to Azure Database for PostgreSQL or Azure SQL with passwordless Entra ID auth, reconciling SQL dialect differences.",
|
||||
detect: ["oracle", "db2", "sybase", "informix"],
|
||||
},
|
||||
{
|
||||
id: "cache-to-redis",
|
||||
name: "Cache solutions to Azure Managed Redis",
|
||||
category: "Cache",
|
||||
summary:
|
||||
"Migrate in-memory or distributed caches (Infinispan, SwarmCache, Memcached, etc.) to Azure Managed Redis with passwordless Entra ID auth.",
|
||||
detect: ["cache"],
|
||||
},
|
||||
{
|
||||
id: "ant-eclipse-to-maven",
|
||||
name: "Ant / Eclipse project to Maven",
|
||||
category: "Build",
|
||||
summary:
|
||||
"Convert Ant or Eclipse IDE projects to Maven so the project builds consistently from any environment.",
|
||||
detect: ["ant"],
|
||||
},
|
||||
];
|
||||
|
||||
// Java runtime upgrade ladder targeted by App Modernization (8 → 11 → 17 → 21 → 25),
|
||||
// with particular focus on Spring Boot apps.
|
||||
export const JAVA_UPGRADE_TARGETS = [11, 17, 21, 25];
|
||||
|
||||
// Day-to-day Java utilities you can invoke in Copilot Chat with the `#` prefix.
|
||||
export const APPMOD_TOOLS = {
|
||||
cve: {
|
||||
id: "appmod-validate-cves-for-java",
|
||||
label: "Validate CVEs",
|
||||
summary:
|
||||
"Scan the project for known Java CVEs and validate that critical vulnerabilities are addressed.",
|
||||
},
|
||||
tests: {
|
||||
id: "appmod-generate-tests-for-java",
|
||||
label: "Generate unit tests",
|
||||
summary: "Use AI code understanding to generate unit tests for the Java code.",
|
||||
},
|
||||
};
|
||||
|
||||
// The five validation gates App Modernization runs after code transformation.
|
||||
export const VALIDATION_GATES = [
|
||||
{ key: "build", label: "Build" },
|
||||
{ key: "tests", label: "Unit Tests" },
|
||||
{ key: "cve", label: "CVE Check" },
|
||||
{ key: "consistency", label: "Consistency" },
|
||||
{ key: "completeness", label: "Completeness" },
|
||||
];
|
||||
|
||||
/** Return the catalog with a `relevant` flag set per task based on detected deps. */
|
||||
export function catalogWithRelevance(detectedKeys) {
|
||||
const set = new Set(detectedKeys || []);
|
||||
return PREDEFINED_TASKS.map((t) => ({
|
||||
...t,
|
||||
relevant: t.detect.some((d) => set.has(d)),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// doctor.mjs — Environment & workflow readiness checks for the App Modernization
|
||||
// Cockpit. Probes the local CLI toolchain (JDK, Maven/Gradle, git, Docker, Azure
|
||||
// CLI / azd, Node) and combines those results with repo facts from scan.mjs so a
|
||||
// user can confirm their machine is ready *before* they start migrating — and get
|
||||
// clear, actionable remediation when something is missing.
|
||||
//
|
||||
// Split into a pure builder (`buildDoctorReport`) and an impure runner
|
||||
// (`runDoctor`) so the decision logic is unit-testable without shelling out.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
// One probe per tool. Many CLIs print their version to stderr (notably `java`),
|
||||
// so probes capture both streams.
|
||||
export const TOOL_PROBES = [
|
||||
{ key: "java", cmd: "java", args: ["-version"] },
|
||||
{ key: "mvn", cmd: "mvn", args: ["-v"] },
|
||||
{ key: "gradle", cmd: "gradle", args: ["-v"] },
|
||||
{ key: "git", cmd: "git", args: ["--version"] },
|
||||
{ key: "docker", cmd: "docker", args: ["--version"] },
|
||||
{ key: "az", cmd: "az", args: ["--version"] },
|
||||
{ key: "azd", cmd: "azd", args: ["version"] },
|
||||
{ key: "node", cmd: "node", args: ["--version"] },
|
||||
];
|
||||
|
||||
/** Friendly OS label for remediation hints. */
|
||||
export function osLabel() {
|
||||
const p = platform();
|
||||
if (p === "darwin") return "macOS";
|
||||
if (p === "win32") return "Windows";
|
||||
if (p === "linux") return "Linux";
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Extract a short version string from a tool's --version output. Pure. */
|
||||
export function parseToolVersion(key, text) {
|
||||
if (!text) return null;
|
||||
const t = String(text);
|
||||
if (key === "java") {
|
||||
const m = t.match(/version\s+"?([\d._]+)"?/i);
|
||||
if (m) return m[1];
|
||||
}
|
||||
if (key === "node") {
|
||||
const m = t.match(/v?(\d+\.\d+\.\d+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
const g = t.match(/(\d+\.\d+(?:\.\d+)?)/);
|
||||
if (g) return g[1];
|
||||
const first = t.split(/\r?\n/)[0].trim();
|
||||
return first ? first.slice(0, 40) : null;
|
||||
}
|
||||
|
||||
function defaultExec(cmd, args) {
|
||||
return makeExec(augmentedEnv(process.env, platform()))(cmd, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a child-process env whose PATH also covers the usual places dev tools
|
||||
* live. A GUI-launched app (the Copilot app) inherits a minimal launchd PATH
|
||||
* (/usr/bin:/bin:/usr/sbin:/sbin), so tools installed via Homebrew, SDKMAN,
|
||||
* asdf, or a keg-only JDK aren't found even though the user's terminal sees
|
||||
* them fine. Prepending these locations makes the probes match reality without
|
||||
* touching the user's actual environment. Pure given an injected directory
|
||||
* lister (so it's unit-testable without hitting the filesystem).
|
||||
*/
|
||||
export function augmentedEnv(env, plat, lister) {
|
||||
const base = Object.assign({}, env);
|
||||
if (plat === "win32") return base; // Windows PATH semantics differ; leave as-is.
|
||||
const ls = lister || ((d) => { try { return readdirSync(d); } catch (e) { return []; } });
|
||||
const extra = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"];
|
||||
if (base.HOME) {
|
||||
extra.push(base.HOME + "/.sdkman/candidates/java/current/bin");
|
||||
extra.push(base.HOME + "/.asdf/shims");
|
||||
}
|
||||
if (base.JAVA_HOME) extra.push(base.JAVA_HOME + "/bin");
|
||||
// Keg-only Homebrew JDKs aren't symlinked into .../bin, so enumerate them.
|
||||
for (const root of ["/opt/homebrew/opt", "/usr/local/opt"]) {
|
||||
for (const name of ls(root)) {
|
||||
if (/^openjdk(@\d+)?$/.test(name)) extra.push(root + "/" + name + "/libexec/openjdk.jdk/Contents/Home/bin");
|
||||
}
|
||||
}
|
||||
const seen = {};
|
||||
const prefix = extra.filter((p) => { if (seen[p]) return false; seen[p] = 1; return true; });
|
||||
base.PATH = prefix.join(":") + (base.PATH ? ":" + base.PATH : "");
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Build an exec function bound to a specific environment. */
|
||||
export function makeExec(env) {
|
||||
return (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(cmd, args, { timeout: 3500, windowsHide: true, env }, (error, stdout, stderr) => {
|
||||
resolve({ error: error || null, stdout: String(stdout || ""), stderr: String(stderr || "") });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a shell-style JAVA_HOME value from an rc assignment into a concrete path,
|
||||
* WITHOUT executing anything. Handles surrounding quotes, an inline comment on
|
||||
* unquoted values, and `~` / `$HOME` / `${HOME}` expansion. Returns null when the
|
||||
* value still contains something only a shell could resolve (command substitution,
|
||||
* other variables) — we'd rather report nothing than a bogus path.
|
||||
*/
|
||||
export function expandJavaHomeValue(raw, env) {
|
||||
if (typeof raw !== "string") return null;
|
||||
let v = raw.trim();
|
||||
if (!v) return null;
|
||||
if (v[0] !== '"' && v[0] !== "'") {
|
||||
const c = v.indexOf(" #");
|
||||
if (c >= 0) v = v.slice(0, c).trim();
|
||||
}
|
||||
if (v.length >= 2 && ((v[0] === '"' && v[v.length - 1] === '"') || (v[0] === "'" && v[v.length - 1] === "'"))) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
const home = (env && env.HOME) || "";
|
||||
if (v === "~") v = home;
|
||||
else if (v.startsWith("~/")) v = home + v.slice(1);
|
||||
v = v.replace(/\$\{HOME\}/g, home).replace(/\$HOME(?=\/|$)/g, home);
|
||||
if (!v || /[`]|\$\(|\$\w/.test(v)) return null; // unresolved shell expansion
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically parse a shell rc file's text for the last `JAVA_HOME=` assignment.
|
||||
* Pure and side-effect-free. Returns the expanded path or null.
|
||||
*/
|
||||
export function parseJavaHomeFromRc(text, env) {
|
||||
if (typeof text !== "string" || !text) return null;
|
||||
const re = /^[ \t]*(?:export[ \t]+)?JAVA_HOME[ \t]*=[ \t]*(.+?)[ \t]*$/gm;
|
||||
let m;
|
||||
let last = null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const val = expandJavaHomeValue(m[1], env);
|
||||
if (val) last = val; // later assignments win, mirroring shell evaluation order
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort discovery of the JAVA_HOME the user's interactive shell is
|
||||
* configured with, so the readiness report reflects the JDK their terminal
|
||||
* actually uses (e.g. a pinned LTS) rather than whichever JDK happens to sort
|
||||
* first on the augmented PATH.
|
||||
*
|
||||
* IMPORTANT: this only *reads and statically parses* the shell rc files — it never
|
||||
* sources or executes them. That keeps the readiness probe honest with the UI
|
||||
* promise ("only reads version numbers; nothing is installed or changed") and
|
||||
* avoids running arbitrary user startup code. A JAVA_HOME defined via command
|
||||
* substitution (e.g. `$(/usr/libexec/java_home)`) can't be resolved statically, so
|
||||
* we simply fall back to PATH-based probing in that case. The rc reader is
|
||||
* injectable for tests.
|
||||
*/
|
||||
export async function discoverJavaHome(env, plat, readRc) {
|
||||
const e = env || {};
|
||||
if (e.JAVA_HOME) return e.JAVA_HOME; // already explicit in this process
|
||||
if (plat === "win32") return null; // not a $JAVA_HOME/rc world
|
||||
const home = e.HOME || "";
|
||||
if (!home) return null;
|
||||
const shell = e.SHELL || (plat === "darwin" ? "/bin/zsh" : "/bin/bash");
|
||||
const names = /zsh/.test(shell)
|
||||
? [".zshenv", ".zprofile", ".zshrc"]
|
||||
: /bash/.test(shell)
|
||||
? [".bash_profile", ".bashrc", ".profile"]
|
||||
: [".profile"];
|
||||
const read =
|
||||
readRc ||
|
||||
(async (p) => {
|
||||
try {
|
||||
return await readFile(p, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
let found = null;
|
||||
for (const name of names) {
|
||||
let text = null;
|
||||
try {
|
||||
text = await read(join(home, name));
|
||||
} catch {
|
||||
text = null;
|
||||
}
|
||||
const val = parseJavaHomeFromRc(text, e);
|
||||
if (val) found = val; // later rc files (e.g. .zshrc) win
|
||||
}
|
||||
return found || null;
|
||||
}
|
||||
|
||||
/** Probe a single tool. Returns { key, found, version }. */
|
||||
export async function probeOne(def, exec) {
|
||||
const run = exec || defaultExec;
|
||||
let r;
|
||||
try {
|
||||
r = await run(def.cmd, def.args);
|
||||
} catch (e) {
|
||||
r = { error: e, stdout: "", stderr: "" };
|
||||
}
|
||||
const text = ((r.stdout || "") + "\n" + (r.stderr || "")).trim();
|
||||
// ENOENT means the binary isn't on PATH at all.
|
||||
if (r.error && r.error.code === "ENOENT") return { key: def.key, found: false, version: null };
|
||||
if (r.error && !text) return { key: def.key, found: false, version: null };
|
||||
const version = parseToolVersion(def.key, text);
|
||||
if (r.error) {
|
||||
// Non-zero exit. Genuine `--version` calls exit 0, so only trust this if a
|
||||
// real version number came back. This rejects the macOS `java` stub, which
|
||||
// exits 1 with "Unable to locate a Java Runtime" when no JDK is installed.
|
||||
if (!(version && /\d/.test(version))) return { key: def.key, found: false, version: null };
|
||||
}
|
||||
return { key: def.key, found: true, version };
|
||||
}
|
||||
|
||||
/** Probe every tool and build the readiness report. Impure (spawns processes). */
|
||||
export async function runDoctor(scan, opts = {}) {
|
||||
let exec = opts.exec;
|
||||
if (!exec) {
|
||||
const env = augmentedEnv(process.env, platform());
|
||||
// Prefer the JDK the user's shell is configured with so the report matches
|
||||
// their terminal (and so a dependency-pulled JDK doesn't shadow their pin).
|
||||
// Static rc parse — no shell sourcing.
|
||||
const jh = await discoverJavaHome(process.env, platform());
|
||||
if (jh && !env.JAVA_HOME) {
|
||||
env.JAVA_HOME = jh;
|
||||
env.PATH = jh + "/bin:" + env.PATH;
|
||||
}
|
||||
exec = makeExec(env);
|
||||
}
|
||||
const probesArr = await Promise.all(TOOL_PROBES.map((d) => probeOne(d, exec)));
|
||||
const probes = {};
|
||||
for (const p of probesArr) probes[p.key] = p;
|
||||
return buildDoctorReport({ probes, scan: scan || {} });
|
||||
}
|
||||
|
||||
function envAction(tool, detail, fix) {
|
||||
return { kind: "fix_env", payload: { tool, detail, fix }, label: "Help me set up " + tool };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn probe results + scan facts into grouped readiness checks. Pure.
|
||||
* @returns {{ overall:"ready"|"caution"|"blocked", generatedAt:string, groups:Array }}
|
||||
*/
|
||||
export function buildDoctorReport({ probes, scan }) {
|
||||
const get = (k) => (probes && probes[k]) || { found: false, version: null };
|
||||
const a = (scan && scan.assessment) || {};
|
||||
const buildTool = a.buildTool || null;
|
||||
|
||||
const buildRun = [];
|
||||
|
||||
// --- JDK -----------------------------------------------------------------
|
||||
const java = get("java");
|
||||
buildRun.push(
|
||||
java.found
|
||||
? { id: "jdk", label: "Java Development Kit", status: "ok", detail: "Java " + (java.version || "detected") }
|
||||
: {
|
||||
id: "jdk",
|
||||
label: "Java Development Kit",
|
||||
status: "fail",
|
||||
detail: "java not found on PATH",
|
||||
fix: "Install a JDK 17 or newer (Microsoft Build of OpenJDK or Eclipse Temurin) and make sure `java` is on your PATH.",
|
||||
action: envAction("a JDK", "java not found on PATH", "Install JDK 17+ (Microsoft OpenJDK / Temurin) and add it to PATH."),
|
||||
}
|
||||
);
|
||||
|
||||
// --- Build tool ----------------------------------------------------------
|
||||
if (buildTool === "Maven") {
|
||||
const mvn = get("mvn");
|
||||
const ok = mvn.found || a.hasMavenWrapper;
|
||||
buildRun.push(
|
||||
ok
|
||||
? {
|
||||
id: "build",
|
||||
label: "Maven",
|
||||
status: "ok",
|
||||
detail: mvn.found ? "Maven " + (mvn.version || "detected") : "Using the project Maven wrapper (./mvnw)",
|
||||
}
|
||||
: {
|
||||
id: "build",
|
||||
label: "Maven",
|
||||
status: "fail",
|
||||
detail: "mvn not found and no ./mvnw wrapper in the repo",
|
||||
fix: "Install Apache Maven, or add the Maven wrapper (`mvn -N wrapper:wrapper`) so the build runs anywhere.",
|
||||
action: envAction("Maven", "mvn not found and no ./mvnw wrapper", "Install Apache Maven or add the ./mvnw wrapper."),
|
||||
}
|
||||
);
|
||||
} else if (buildTool === "Gradle") {
|
||||
const gr = get("gradle");
|
||||
const ok = gr.found || a.hasGradleWrapper;
|
||||
buildRun.push(
|
||||
ok
|
||||
? {
|
||||
id: "build",
|
||||
label: "Gradle",
|
||||
status: "ok",
|
||||
detail: gr.found ? "Gradle " + (gr.version || "detected") : "Using the project Gradle wrapper (./gradlew)",
|
||||
}
|
||||
: {
|
||||
id: "build",
|
||||
label: "Gradle",
|
||||
status: "fail",
|
||||
detail: "gradle not found and no ./gradlew wrapper in the repo",
|
||||
fix: "Install Gradle, or add the Gradle wrapper (`gradle wrapper`) so the build runs anywhere.",
|
||||
action: envAction("Gradle", "gradle not found and no ./gradlew wrapper", "Install Gradle or add the ./gradlew wrapper."),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
buildRun.push({
|
||||
id: "build",
|
||||
label: "Build tool",
|
||||
status: "info",
|
||||
detail: "No Maven or Gradle build file detected yet.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- git -----------------------------------------------------------------
|
||||
const git = get("git");
|
||||
buildRun.push(
|
||||
git.found
|
||||
? { id: "git", label: "Git", status: "ok", detail: git.version ? "git " + git.version : "installed" }
|
||||
: {
|
||||
id: "git",
|
||||
label: "Git",
|
||||
status: "fail",
|
||||
detail: "git not found on PATH",
|
||||
fix: "Install Git — the modernization workflow uses a branch and a pull request.",
|
||||
action: envAction("Git", "git not found on PATH", "Install Git and ensure it is on PATH."),
|
||||
}
|
||||
);
|
||||
|
||||
// --- containerize / deploy (optional) ------------------------------------
|
||||
const deploy = [];
|
||||
const docker = get("docker");
|
||||
if (docker.found) {
|
||||
deploy.push({ id: "docker", label: "Docker", status: "ok", detail: docker.version ? "Docker " + docker.version : "installed" });
|
||||
} else if (a.hasDockerfile) {
|
||||
deploy.push({
|
||||
id: "docker",
|
||||
label: "Docker",
|
||||
status: "warn",
|
||||
detail: "A Dockerfile is present but Docker isn't installed.",
|
||||
fix: "Install Docker Desktop (or the Docker engine) to build and run the container image locally.",
|
||||
action: envAction("Docker", "Dockerfile present but Docker not installed", "Install Docker Desktop / engine."),
|
||||
});
|
||||
} else {
|
||||
deploy.push({ id: "docker", label: "Docker", status: "info", detail: "Not installed — only needed to build container images." });
|
||||
}
|
||||
const az = get("az");
|
||||
deploy.push(
|
||||
az.found
|
||||
? { id: "az", label: "Azure CLI", status: "ok", detail: az.version ? "az " + az.version : "installed" }
|
||||
: { id: "az", label: "Azure CLI", status: "info", detail: "Optional — needed to provision Azure resources and set up passwordless auth." }
|
||||
);
|
||||
const azd = get("azd");
|
||||
deploy.push(
|
||||
azd.found
|
||||
? { id: "azd", label: "Azure Developer CLI", status: "ok", detail: azd.version ? "azd " + azd.version : "installed" }
|
||||
: { id: "azd", label: "Azure Developer CLI", status: "info", detail: "Optional — `azd` deploys the app to Azure once it's modernized." }
|
||||
);
|
||||
|
||||
// --- workflow readiness (from scan, no probes) ---------------------------
|
||||
const flow = [];
|
||||
const g = scan && scan.git;
|
||||
if (!g) {
|
||||
flow.push({ id: "branch", label: "Source control", status: "info", detail: "Git branch state unavailable." });
|
||||
} else if (g.isMigrationBranch) {
|
||||
flow.push({ id: "branch", label: "Working branch", status: "ok", detail: "On modernization branch '" + g.branch + "'." });
|
||||
} else {
|
||||
flow.push({
|
||||
id: "branch",
|
||||
label: "Working branch",
|
||||
status: "info",
|
||||
detail: "On '" + (g.branch || "unknown") + "' — consider a dedicated modernization branch before making changes.",
|
||||
});
|
||||
}
|
||||
const hasAssessment = !!(
|
||||
(scan && scan.report && scan.report.findings && scan.report.findings.length) ||
|
||||
(scan && scan.plan && scan.plan.exists) ||
|
||||
(scan && scan.progress && scan.progress.exists)
|
||||
);
|
||||
flow.push(
|
||||
hasAssessment
|
||||
? { id: "assessed", label: "Assessment", status: "ok", detail: "Assessment artifacts found — you're underway." }
|
||||
: {
|
||||
id: "assessed",
|
||||
label: "Assessment",
|
||||
status: "warn",
|
||||
detail: "No assessment yet. Start here to map your modernization work.",
|
||||
action: { kind: "start_assessment", payload: {}, label: "Run assessment" },
|
||||
}
|
||||
);
|
||||
const nSkills = (scan && scan.skills && scan.skills.length) || 0;
|
||||
flow.push({
|
||||
id: "skills",
|
||||
label: "Custom skills",
|
||||
status: "info",
|
||||
detail: nSkills ? nSkills + " custom skill" + (nSkills > 1 ? "s" : "") + " discovered." : "No custom skills yet (optional).",
|
||||
});
|
||||
|
||||
const groups = [
|
||||
{ id: "buildRun", name: "Build & run", checks: buildRun },
|
||||
{ id: "deploy", name: "Containerize & deploy to Azure (optional)", checks: deploy },
|
||||
{ id: "flow", name: "Workflow readiness", checks: flow },
|
||||
];
|
||||
|
||||
let overall = "ready";
|
||||
const all = groups.reduce((acc, grp) => acc.concat(grp.checks), []);
|
||||
if (all.some((c) => c.status === "fail")) overall = "blocked";
|
||||
else if (all.some((c) => c.status === "warn")) overall = "caution";
|
||||
|
||||
return { overall, generatedAt: new Date().toISOString(), groups };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// extension.mjs — Java Modernization Studio (user-scoped canvas). Wiring only.
|
||||
//
|
||||
// Declares the canvas and bridges the SDK to the testable modules:
|
||||
// scan.mjs repo + markdown parsing -> grounded state
|
||||
// catalog.mjs Microsoft predefined task catalog
|
||||
// prompts.mjs action -> crafted agent prompt
|
||||
// server.mjs per-instance loopback HTTP server + action dispatch
|
||||
// renderer.mjs cockpit UI
|
||||
//
|
||||
// Button clicks POST /action; agent actions are forwarded to session.send. When a
|
||||
// turn finishes (session.idle) every open cockpit re-scans and pushes fresh state.
|
||||
|
||||
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
|
||||
import { basename } from "node:path";
|
||||
import { scanRepo } from "./scan.mjs";
|
||||
import { createInstanceServer, pushState, resolveRepoPath } from "./server.mjs";
|
||||
import { AUTOPILOT_TURN_TIMEOUT_MS } from "./autopilot.mjs";
|
||||
|
||||
const instances = new Map(); // instanceId -> rec { server, url, repoPath, sseClients }
|
||||
let sessionRef = null;
|
||||
let lastWorkingDir = null;
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [
|
||||
createCanvas({
|
||||
id: "appmod-cockpit",
|
||||
displayName: "Java Modernization Studio",
|
||||
description:
|
||||
"Drive the GitHub Copilot App Modernization for Java workflow: assessment, plan/progress, validation gates, and task/skill runs grounded in the repo's real artifacts.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
repoPath: { type: "string", description: "Absolute path to the Java repo to inspect." },
|
||||
initialTab: { type: "string", enum: ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"] },
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
name: "get_state",
|
||||
description:
|
||||
"Return the current modernization state snapshot scanned from the repo (assessment, plan/progress, gates, skills, tasks).",
|
||||
handler: async (ctx) => {
|
||||
const rec = instances.get(ctx.instanceId);
|
||||
const repoPath = rec ? rec.repoPath : resolveRepoPath(ctx, lastWorkingDir);
|
||||
return await scanRepo(repoPath);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refresh",
|
||||
description: "Re-scan the repo and push a fresh snapshot to the open cockpit.",
|
||||
handler: async (ctx) => {
|
||||
const rec = instances.get(ctx.instanceId);
|
||||
if (!rec) throw new CanvasError("not_open", "Cockpit instance is not open.");
|
||||
await pushState(rec, logFn);
|
||||
return { ok: true, repoPath: rec.repoPath };
|
||||
},
|
||||
},
|
||||
],
|
||||
open: async (ctx) => {
|
||||
const repoPath = resolveRepoPath(ctx, lastWorkingDir);
|
||||
if (repoPath) lastWorkingDir = repoPath; // never clobber a good target with null
|
||||
const initialTab = ctx.input && ctx.input.initialTab ? ctx.input.initialTab : "overview";
|
||||
let rec = instances.get(ctx.instanceId);
|
||||
if (!rec) {
|
||||
rec = await createInstanceServer({
|
||||
instanceId: ctx.instanceId,
|
||||
repoPath,
|
||||
initialTab,
|
||||
sendPrompt: (prompt) => sessionRef.send({ prompt }),
|
||||
runTurn: (prompt) => sessionRef.sendAndWait({ prompt }, AUTOPILOT_TURN_TIMEOUT_MS),
|
||||
log: logFn,
|
||||
});
|
||||
instances.set(ctx.instanceId, rec);
|
||||
} else if (rec.repoPath !== repoPath) {
|
||||
rec.repoPath = repoPath; // refresh target on re-open
|
||||
rec.doctor = null; // env readiness depends on repo facts — recompute for the new target
|
||||
}
|
||||
return {
|
||||
title: "Java Modernization Studio",
|
||||
status: repoPath ? basename(repoPath) : "no repo",
|
||||
url: rec.url,
|
||||
};
|
||||
},
|
||||
onClose: async (ctx) => {
|
||||
const rec = instances.get(ctx.instanceId);
|
||||
if (rec) {
|
||||
instances.delete(ctx.instanceId);
|
||||
for (const res of rec.sseClients) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => rec.server.close(() => resolve()));
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sessionRef = session;
|
||||
function logFn(message, opts) {
|
||||
try {
|
||||
return sessionRef.log(message, opts);
|
||||
} catch {
|
||||
/* logging is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
if (session.workspacePath && !lastWorkingDir) lastWorkingDir = session.workspacePath;
|
||||
|
||||
// A finished turn may have changed plan.md/progress.md/summary.md or the working
|
||||
// tree — re-scan and push fresh state to every open cockpit.
|
||||
session.on("session.idle", async () => {
|
||||
for (const rec of instances.values()) {
|
||||
await pushState(rec, logFn);
|
||||
}
|
||||
});
|
||||
|
||||
await session.log("Java Modernization Studio ready", { ephemeral: true });
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "java-modernization-studio",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"author": {
|
||||
"name": "Ayan Gupta",
|
||||
"url": "https://github.com/ayangupt"
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.4"
|
||||
},
|
||||
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
|
||||
"keywords": [
|
||||
"java-modernization",
|
||||
"app-modernization",
|
||||
"azure-migration",
|
||||
"legacy-java",
|
||||
"assessment-dashboard",
|
||||
"validation-gates",
|
||||
"modernization-cockpit"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// prompts.mjs — Maps each cockpit button action to a crafted agent instruction.
|
||||
// Pure (no I/O, no session) so it can be unit-tested in isolation.
|
||||
|
||||
import { PREDEFINED_TASKS } from "./catalog.mjs";
|
||||
|
||||
export const APPMOD_PREAMBLE =
|
||||
"You are running the GitHub Copilot App Modernization for Java workflow. " +
|
||||
"Keep plan.md and progress.md up to date as a checklist (use - [ ] / - [x]) and, when the work is complete, write summary.md. " +
|
||||
"Start each of these files (plan.md, progress.md, summary.md) with the exact line <!-- appmod-cockpit --> as the very first line so the cockpit recognizes them as modernization artifacts.";
|
||||
|
||||
export const ACTION_LABELS = {
|
||||
start_assessment: "Assessment",
|
||||
generate_plan: "Generate plan",
|
||||
run_task: "Run task",
|
||||
run_skill: "Run skill",
|
||||
run_cve: "CVE scan",
|
||||
generate_tests: "Generate tests",
|
||||
run_build_tests: "Build & tests",
|
||||
run_consistency: "Consistency check",
|
||||
run_completeness: "Completeness check",
|
||||
create_skill: "Create skill",
|
||||
open_pr: "Open PR",
|
||||
fix_finding: "Resolve finding",
|
||||
work_step: "Work on step",
|
||||
fix_env: "Environment setup",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the agent prompt for a cockpit action.
|
||||
* @returns {string|null} prompt text, or null for unknown actions / unknown task ids.
|
||||
*/
|
||||
export function buildPrompt(kind, payload, repoPath) {
|
||||
const p = payload || {};
|
||||
switch (kind) {
|
||||
case "start_assessment":
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nRun an App Modernization assessment of this Java project at " + repoPath + ". " +
|
||||
"Report the current Java runtime, build tool, framework versions, outdated/vulnerable dependencies, " +
|
||||
"and cloud-readiness issues for Azure. Summarize findings and write a prioritized plan.md of remediation steps. Do not change code yet." +
|
||||
"\n\nAlso write the findings as structured JSON to .appmod/assessment.json so the cockpit can render them. Use this shape:\n" +
|
||||
"{\n" +
|
||||
' "generatedAt": "<ISO 8601 timestamp>",\n' +
|
||||
' "headline": "<one-line stack summary>",\n' +
|
||||
' "summary": "<2-3 sentence plain-language verdict>",\n' +
|
||||
' "stack": { "buildTool": "", "java": "", "framework": "", "database": "", "container": "" },\n' +
|
||||
' "findings": [\n' +
|
||||
' { "id": "kebab-id", "severity": "P0|P1|P2|P3", "title": "", "detail": "", "files": ["path"],\n' +
|
||||
' "action": { "kind": "run_task|generate_plan|run_cve|generate_tests|fix_finding", "payload": {}, "label": "" } }\n' +
|
||||
" ],\n" +
|
||||
' "strengths": ["<things already done well>"]\n' +
|
||||
"}\n" +
|
||||
"Order findings by severity (P0 first). For action.kind, use run_task with payload {\"taskId\":\"...\"} when a Microsoft predefined task fits, " +
|
||||
"generate_plan/run_cve/generate_tests when those fit, otherwise use fix_finding (the cockpit turns it into a 'Help me fix this' button). Omit action only if there is genuinely nothing to do."
|
||||
);
|
||||
case "generate_plan":
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nCreate a step-by-step modernization plan to upgrade this project to Java " + (p.targetJava || 21) + ". " +
|
||||
"Cover build-file changes, removed/replaced APIs, dependency upgrades, and required test updates. " +
|
||||
"Write the plan as a checklist in plan.md and create an empty progress.md mirroring those steps. Do not change code yet."
|
||||
);
|
||||
case "run_task": {
|
||||
const t = PREDEFINED_TASKS.find((x) => x.id === p.taskId);
|
||||
if (!t) return null;
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nApply the App Modernization predefined task \"" + t.name + "\": " + t.summary + "\n" +
|
||||
"Steps: (1) record the plan in plan.md and progress.md; (2) check out a migration branch; " +
|
||||
"(3) make the code changes for this task; (4) validate by building, running unit tests, and a CVE check; " +
|
||||
"(5) write a summary.md of what changed. Pause for my confirmation before committing."
|
||||
);
|
||||
}
|
||||
case "run_skill":
|
||||
if (!p.folder) return null;
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nRun the custom modernization skill in .github/skills/" + p.folder + "/SKILL.md. " +
|
||||
"Follow its instructions and referenced resources exactly, updating plan.md and progress.md as you go, " +
|
||||
"and write summary.md when finished."
|
||||
);
|
||||
case "run_cve":
|
||||
return "Check this Java project for known CVE issues using #appmod-validate-cves-for-java and report which dependencies need upgrading, then update progress.md.";
|
||||
case "generate_tests":
|
||||
return "Generate unit tests for this Java project using #appmod-generate-tests-for-java, then run them and record the result in progress.md.";
|
||||
case "run_build_tests":
|
||||
return "Build this project and run its unit tests. Report failures with root-cause analysis and update the Build and Unit Tests entries in progress.md.";
|
||||
case "run_consistency":
|
||||
return "Run a consistency check comparing the modernized code against the original behavior (APIs, SQL, wire format). List any behavioral differences and update progress.md.";
|
||||
case "run_completeness":
|
||||
return "Run a completeness check: verify every step in plan.md has been implemented. List anything missing and update progress.md.";
|
||||
case "create_skill":
|
||||
return (
|
||||
"Help me create a custom App Modernization skill. Create .github/skills/<skill-name>/SKILL.md with Skill Name, " +
|
||||
"Description, and Content sections following the Agent Skills specification, plus any resource files it references. Ask me what migration this skill should capture."
|
||||
);
|
||||
case "open_pr":
|
||||
return "Summarize the modernization changes on the current branch and open a pull request with a clear title and a body that lists the plan steps completed and validation results.";
|
||||
case "work_step": {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) return null;
|
||||
const phase = p.section ? " (phase: " + p.section + ")" : "";
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nWork on this single step from the modernization checklist" + phase + ":\n\"" + title + "\"\n\n" +
|
||||
"Do just this step: tell me what you'll change, make the code/config changes, then check it off in progress.md " +
|
||||
"(change its - [ ] to - [x]) and add a one-line note of what changed. If the step needs a decision from me or is " +
|
||||
"ambiguous, ask before proceeding. Pause for my confirmation before committing."
|
||||
);
|
||||
}
|
||||
case "auto_step": {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) return null;
|
||||
const phase = p.section ? " (phase: " + p.section + ")" : "";
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nYou are running in AUTOPILOT, working the modernization checklist on your own. " +
|
||||
"Do exactly ONE step now" + phase + ":\n\"" + title + "\"\n\n" +
|
||||
"For this single step: briefly state what you will change, make the necessary code/config changes, " +
|
||||
"then check it off in progress.md (change its - [ ] to - [x]) and add a one-line note of what changed. " +
|
||||
"Do NOT commit, and do NOT start any other step. " +
|
||||
"If this step genuinely needs a decision from me, or is blocked or ambiguous, do NOT guess: leave it unchecked, " +
|
||||
"add a short note in progress.md describing exactly what you need, and stop so I can take over."
|
||||
);
|
||||
}
|
||||
case "fix_finding": {
|
||||
const title = (p.title || "").trim();
|
||||
if (!title) return null;
|
||||
const detail = (p.detail || "").trim();
|
||||
const files = Array.isArray(p.files) && p.files.length ? "\nAffected files: " + p.files.join(", ") + "." : "";
|
||||
const sev = p.severity ? " (" + p.severity + ")" : "";
|
||||
return (
|
||||
APPMOD_PREAMBLE +
|
||||
"\n\nHelp me resolve this App Modernization assessment finding" + sev + ":\n" +
|
||||
title + (detail ? " — " + detail : "") + "." + files +
|
||||
"\n\nExplain the fix, make the change, and update the matching checklist items in plan.md and progress.md. " +
|
||||
"Pause for my confirmation before committing."
|
||||
);
|
||||
}
|
||||
case "fix_env": {
|
||||
const tool = (p.tool || "").trim();
|
||||
if (!tool) return null;
|
||||
const detail = p.detail ? "\nWhat the readiness check found: " + p.detail + "." : "";
|
||||
const fix = p.fix ? "\nSuggested fix: " + p.fix : "";
|
||||
return (
|
||||
"I'm preparing my local environment for the GitHub Copilot App Modernization for Java workflow.\n" +
|
||||
"The readiness check flagged a missing or misconfigured tool: " + tool + "." + detail + fix +
|
||||
"\n\nWalk me through installing and configuring " + tool + " on my machine, including the exact commands for my OS " +
|
||||
"and how to verify it's on PATH at the right version for this project. This is environment setup only — do not modify my application code."
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
// scan.mjs — Grounds the cockpit in the real repository. Reads App Modernization
|
||||
// artifacts (plan.md / progress.md / summary.md), discovers custom skills under
|
||||
// .github/skills/, extracts assessment facts from Maven/Gradle build files, and
|
||||
// reports git branch state. Everything degrades gracefully when files are absent.
|
||||
|
||||
import { readFile, readdir, stat, open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { catalogWithRelevance, VALIDATION_GATES } from "./catalog.mjs";
|
||||
|
||||
// Dependency signatures: substring(s) searched in build files -> catalog `detect` key.
|
||||
const DEP_SIGNATURES = {
|
||||
rabbitmq: ["rabbitmq", "spring-rabbit", "amqp-client", "spring-amqp", "starter-amqp", "amqp"],
|
||||
activemq: ["activemq"],
|
||||
jms: ["javax.jms", "jakarta.jms", "spring-jms"],
|
||||
awsS3: ["aws-java-sdk-s3", "s3", "software.amazon.awssdk"],
|
||||
awsSqs: ["aws-java-sdk-sqs", "sqs"],
|
||||
awsSecrets: ["secretsmanager"],
|
||||
javamail: ["javax.mail", "jakarta.mail", "com.sun.mail", "spring-boot-starter-mail"],
|
||||
ldap: ["spring-ldap", "spring-security-ldap", "unboundid-ldapsdk", "ldaptive"],
|
||||
cache: ["infinispan", "swarmcache", "memcached", "spymemcached", "ehcache"],
|
||||
oracle: ["ojdbc", "oracle"],
|
||||
db2: ["db2jcc", "com.ibm.db2"],
|
||||
sybase: ["jconn", "sybase"],
|
||||
informix: ["informix"],
|
||||
jdbc: ["jdbc", "hikari", "mysql", "postgresql", "mssql-jdbc", "sqlserver"],
|
||||
keystore: ["keystore", ".jks", "javax.net.ssl"],
|
||||
crypto: ["javax.crypto", "java.security", "bouncycastle", "bcprov"],
|
||||
filelog: ["fileappender", "rollingfileappender", "logback", "log4j"],
|
||||
};
|
||||
|
||||
// Cap how much of any single file we read. Build files and modernization docs are
|
||||
// tiny; an unbounded read on a pathological repo (a giant generated XML, a vendored
|
||||
// blob) would waste memory for no parsing benefit. Truncating is safe — every
|
||||
// parser here scans for early markers/coordinates.
|
||||
const MAX_READ_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function execGit(args, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
execFile("git", args, { cwd, timeout: 4000 }, (err, stdout) => {
|
||||
if (err) resolve(null);
|
||||
else resolve(String(stdout).trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readText(path) {
|
||||
try {
|
||||
const st = await stat(path);
|
||||
if (!st.isFile()) return null;
|
||||
if (st.size > MAX_READ_BYTES) {
|
||||
const fh = await open(path, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(MAX_READ_BYTES);
|
||||
const { bytesRead } = await fh.read(buf, 0, MAX_READ_BYTES, 0);
|
||||
return buf.toString("utf8", 0, bytesRead);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isDirectory(path) {
|
||||
try {
|
||||
return (await stat(path)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit provenance marker the cockpit writes at the top of artifacts it owns,
|
||||
// so a root plan/progress/summary self-identifies as modernization state even
|
||||
// when there is no .appmod/ directory yet.
|
||||
const APPMOD_MARKER_RE = /<!--\s*appmod-cockpit\b/i;
|
||||
function hasMarker(md) {
|
||||
return typeof md === "string" && APPMOD_MARKER_RE.test(md.slice(0, 2000));
|
||||
}
|
||||
|
||||
// The assessment.json is written by an LLM step, so its shape can drift: severities
|
||||
// may be lowercase or word-form, `files` may be a bare string, findings may be
|
||||
// missing, and the top level might not even be an object. Normalize defensively so
|
||||
// the renderer always gets P0–P3 severities and array `files`, and nothing is
|
||||
// silently dropped or used to build an invalid CSS class.
|
||||
const SEVERITY_ALIASES = {
|
||||
p0: "P0", p1: "P1", p2: "P2", p3: "P3",
|
||||
critical: "P0", blocker: "P0", severe: "P0",
|
||||
high: "P1", major: "P1",
|
||||
medium: "P2", moderate: "P2", warning: "P2", warn: "P2",
|
||||
low: "P3", minor: "P3", info: "P3", informational: "P3", note: "P3",
|
||||
};
|
||||
function normalizeSeverity(sev) {
|
||||
if (typeof sev !== "string") return "P3";
|
||||
return SEVERITY_ALIASES[sev.trim().toLowerCase()] || "P3";
|
||||
}
|
||||
function normalizeFinding(f, idx) {
|
||||
if (!f || typeof f !== "object" || Array.isArray(f)) return null;
|
||||
let files = [];
|
||||
if (typeof f.files === "string") files = f.files ? [f.files] : [];
|
||||
else if (Array.isArray(f.files)) files = f.files.filter((x) => typeof x === "string");
|
||||
const title =
|
||||
(typeof f.title === "string" && f.title.trim()) ||
|
||||
(typeof f.id === "string" && f.id.trim()) ||
|
||||
"Untitled finding";
|
||||
const out = {
|
||||
id: typeof f.id === "string" && f.id ? f.id : "finding-" + (idx + 1),
|
||||
severity: normalizeSeverity(f.severity),
|
||||
title,
|
||||
detail: typeof f.detail === "string" ? f.detail : "",
|
||||
files,
|
||||
};
|
||||
if (f.action && typeof f.action === "object" && !Array.isArray(f.action)) out.action = f.action;
|
||||
return out;
|
||||
}
|
||||
function normalizeReport(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const findingsIn = Array.isArray(raw.findings) ? raw.findings : [];
|
||||
const findings = [];
|
||||
findingsIn.forEach((f, i) => {
|
||||
const n = normalizeFinding(f, i);
|
||||
if (n) findings.push(n);
|
||||
});
|
||||
const out = { ...raw, findings };
|
||||
if (raw.strengths != null && !Array.isArray(raw.strengths)) {
|
||||
out.strengths = typeof raw.strengths === "string" && raw.strengths ? [raw.strengths] : [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripMd(s) {
|
||||
return String(s)
|
||||
.replace(/[*_`~]+/g, "")
|
||||
.replace(/^\s*#+\s*/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Infer a status from inline markers / emojis in a line of text.
|
||||
function statusFromText(text) {
|
||||
const t = text.toLowerCase();
|
||||
if (/[✅✔]|\bdone\b|\bcompleted?\b|\bpassed?\b/.test(t)) return "done";
|
||||
if (/[❌✖]|\bfailed?\b|\berror\b|\bblocked\b/.test(t)) return "failed";
|
||||
if (/[⏳🔄]|in progress|in-progress|ongoing|running|wip/.test(t)) return "in_progress";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse GitHub-style task list items: "- [ ] ...", "- [x] ...", "- [/] ...".
|
||||
// Each item also carries the nearest preceding heading as its `section`.
|
||||
function parseChecklist(md) {
|
||||
const items = [];
|
||||
let section = null;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||||
if (h) { section = stripMd(h[1]); continue; }
|
||||
const m = line.match(/^\s*[-*]\s+\[([ xX/~\-])\]\s+(.*\S)\s*$/);
|
||||
if (!m) continue;
|
||||
const mark = m[1].toLowerCase();
|
||||
let status = "pending";
|
||||
if (mark === "x") status = "done";
|
||||
else if (mark === "/" || mark === "~" || mark === "-") status = "in_progress";
|
||||
const inline = statusFromText(m[2]);
|
||||
if (inline) status = inline;
|
||||
items.push({ title: stripMd(m[2]), status, section });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Fallbacks when there are no checkbox items: numbered steps, then H2/H3 headings.
|
||||
function parseLooseSteps(md) {
|
||||
const numbered = [];
|
||||
let section = null;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||||
if (h) { section = stripMd(h[1]); continue; }
|
||||
const m = line.match(/^\s*\d+[.)]\s+(.*\S)\s*$/);
|
||||
if (m) numbered.push({ title: stripMd(m[1]), status: statusFromText(m[1]) || "pending", section });
|
||||
}
|
||||
if (numbered.length) return numbered;
|
||||
|
||||
const headings = [];
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*#{2,4}\s+(.*\S)\s*$/);
|
||||
if (m) headings.push({ title: stripMd(m[1]), status: statusFromText(m[1]) || "pending", section: null });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function parseSteps(md) {
|
||||
if (!md) return [];
|
||||
const checklist = parseChecklist(md);
|
||||
return checklist.length ? checklist : parseLooseSteps(md);
|
||||
}
|
||||
|
||||
// Return the steps under the first heading matching `headingRe`, up to the next
|
||||
// heading, or null if there is no such section. Used to scope the validation
|
||||
// gates to an explicit "Validation gates" section so unrelated checklist items
|
||||
// that merely mention "build"/"vulnerable"/etc. don't hijack a gate's status.
|
||||
function sectionSteps(md, headingRe) {
|
||||
if (!md) return null;
|
||||
let inSection = false;
|
||||
const buf = [];
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^\s*#{1,6}\s+(.*\S)\s*$/);
|
||||
if (h) {
|
||||
if (inSection) break; // next heading closes the section
|
||||
if (headingRe.test(h[1])) inSection = true;
|
||||
continue;
|
||||
}
|
||||
if (inSection) buf.push(line);
|
||||
}
|
||||
if (!inSection) return null;
|
||||
const steps = parseSteps(buf.join("\n"));
|
||||
return steps.length ? steps : null;
|
||||
}
|
||||
|
||||
function percentDone(steps) {
|
||||
if (!steps.length) return 0;
|
||||
let score = 0;
|
||||
for (const s of steps) {
|
||||
if (s.status === "done") score += 1;
|
||||
else if (s.status === "in_progress") score += 0.5;
|
||||
}
|
||||
return Math.round((score / steps.length) * 100);
|
||||
}
|
||||
|
||||
// Rank a phase/section heading into the canonical modernization order so the UI
|
||||
// can recommend (and gently gate) the sequence: assessment -> P0 -> P1 -> P2 ->
|
||||
// P3 -> validation. Unknown sections return null (never gated).
|
||||
function phaseRankFromName(name) {
|
||||
if (!name) return null;
|
||||
const n = String(name).toLowerCase();
|
||||
if (/assessment/.test(n)) return 0;
|
||||
const m = n.match(/\bp([0-3])\b/);
|
||||
if (m) return 1 + Number(m[1]);
|
||||
if (/validation|gates|sign-?off/.test(n)) return 5;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Summarize phase ordering from the checklist: which phase is the earliest one
|
||||
// with unfinished work (the "active" phase the user should be in), plus a
|
||||
// per-phase done/total roll-up. Steps in phases ranked after `activeRank` are
|
||||
// "ahead of sequence" and the UI flags them so users don't jump the gun.
|
||||
function computeOrdering(steps) {
|
||||
const phases = [];
|
||||
const byKey = {};
|
||||
for (const st of steps) {
|
||||
const key = st.section || "Steps";
|
||||
if (!byKey[key]) {
|
||||
byKey[key] = { name: key, rank: st.rank == null ? null : st.rank, total: 0, done: 0 };
|
||||
phases.push(byKey[key]);
|
||||
}
|
||||
byKey[key].total++;
|
||||
if (st.status === "done") byKey[key].done++;
|
||||
}
|
||||
let activeRank = null;
|
||||
let activePhase = null;
|
||||
for (const st of steps) {
|
||||
if (st.status !== "done" && st.rank != null) {
|
||||
if (activeRank == null || st.rank < activeRank) {
|
||||
activeRank = st.rank;
|
||||
activePhase = st.section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { activeRank, activePhase, phases };
|
||||
}
|
||||
|
||||
// Derive the five validation gates from progress/summary step titles.
|
||||
function deriveGates(steps, summaryText) {
|
||||
const patterns = {
|
||||
build: /\bbuild\b|compil/i,
|
||||
tests: /unit test|\btests?\b|junit/i,
|
||||
cve: /\bcve\b|vulnerab|security scan/i,
|
||||
consistency: /consistenc/i,
|
||||
completeness: /completenes/i,
|
||||
};
|
||||
const gates = {};
|
||||
for (const g of VALIDATION_GATES) {
|
||||
let status = "not_run";
|
||||
for (const s of steps) {
|
||||
if (patterns[g.key].test(s.title)) {
|
||||
status = s.status; // done | in_progress | failed | pending
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (status === "not_run" && summaryText) {
|
||||
const line = summaryText
|
||||
.split(/\r?\n/)
|
||||
.find((l) => patterns[g.key].test(l));
|
||||
if (line) status = statusFromText(line) || "in_progress";
|
||||
}
|
||||
gates[g.key] = status;
|
||||
}
|
||||
return gates;
|
||||
}
|
||||
|
||||
async function discoverSkills(repoPath) {
|
||||
const skillsDir = join(repoPath, ".github", "skills");
|
||||
const out = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(skillsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const md = await readText(join(skillsDir, e.name, "SKILL.md"));
|
||||
let name = e.name;
|
||||
let description = "";
|
||||
if (md) {
|
||||
const nameM = md.match(/(?:^|\n)#+\s*(.+)|name:\s*(.+)/i);
|
||||
if (nameM) name = stripMd(nameM[1] || nameM[2] || e.name);
|
||||
const descM = md.match(/description:\s*(.+)/i);
|
||||
if (descM) description = stripMd(descM[1]);
|
||||
}
|
||||
out.push({ folder: e.name, name, description, hasSkillMd: !!md });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectJavaVersion(pom, gradle) {
|
||||
const text = `${pom || ""}\n${gradle || ""}`;
|
||||
const grab = (re) => {
|
||||
const m = text.match(re);
|
||||
return m ? m[1].replace(/^1\./, "") : null;
|
||||
};
|
||||
return (
|
||||
grab(/<maven\.compiler\.release>\s*([\d.]+)\s*</) ||
|
||||
grab(/<maven\.compiler\.source>\s*([\d.]+)\s*</) ||
|
||||
grab(/<java\.version>\s*([\d.]+)\s*</) ||
|
||||
grab(/<source>\s*([\d.]+)\s*</) ||
|
||||
grab(/<release>\s*([\d.]+)\s*</) ||
|
||||
grab(/sourceCompatibility\s*=?\s*['"]?(?:JavaVersion\.VERSION_)?([\d.]+)/) ||
|
||||
grab(/languageVersion[^)]*?(\d+)/) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Match a dependency signature against build text. Plain substring matching makes
|
||||
// short alpha tokens dangerous ("ant" used to hit "important/constant", and a bare
|
||||
// token could land inside an unrelated word). We instead require the signature to
|
||||
// begin a *token* — i.e. be preceded by a word boundary — which rejects mid-word
|
||||
// matches while still allowing versioned/suffixed coordinates (ojdbc -> "ojdbc8",
|
||||
// hikari -> "hikaricp", s3 -> "...-s3"). Signatures that intentionally start with a
|
||||
// separator (e.g. ".jks") are matched as plain substrings.
|
||||
function signatureMatches(text, sig) {
|
||||
const s = sig.toLowerCase();
|
||||
if (!s) return false;
|
||||
const alnum = (c) => (c >= "a" && c <= "z") || (c >= "0" && c <= "9");
|
||||
if (!alnum(s[0])) return text.includes(s);
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const i = text.indexOf(s, from);
|
||||
if (i < 0) return false;
|
||||
const before = i === 0 ? "" : text[i - 1];
|
||||
if (before === "" || !alnum(before)) return true; // signature begins a token
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function detectDependencies(buildText) {
|
||||
const text = (buildText || "").toLowerCase();
|
||||
const keys = [];
|
||||
for (const [key, sigs] of Object.entries(DEP_SIGNATURES)) {
|
||||
if (sigs.some((s) => signatureMatches(text, s))) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const DOCKERFILE_RE = /^(dockerfile|containerfile)(\.[\w.-]+)?$/i;
|
||||
|
||||
async function dirHasDockerfile(dir) {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
return entries.some((e) => (e.isFile() || e.isSymbolicLink()) && DOCKERFILE_RE.test(e.name));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findDockerfile(repoPath) {
|
||||
// Case-insensitive, and also accept Containerfile (Podman) / Dockerfile.<tag>.
|
||||
if (await dirHasDockerfile(repoPath)) return true;
|
||||
try {
|
||||
const entries = await readdir(repoPath, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && !e.name.startsWith(".")) {
|
||||
if (await dirHasDockerfile(join(repoPath, e.name))) return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Directories we never descend into when unioning module build files.
|
||||
const SKIP_DIRS = new Set(["node_modules", "target", "build", "dist", "out", "bin", ".git"]);
|
||||
|
||||
async function buildAssessment(repoPath) {
|
||||
const rootPom = await readText(join(repoPath, "pom.xml"));
|
||||
let rootGradle = await readText(join(repoPath, "build.gradle"));
|
||||
if (!rootGradle) rootGradle = await readText(join(repoPath, "build.gradle.kts"));
|
||||
const antBuild = await readText(join(repoPath, "build.xml"));
|
||||
|
||||
// Union one level of modules so multi-module Maven, Gradle subprojects, and
|
||||
// monorepos report the real Java version / dependencies — not just whatever
|
||||
// the (often dependency-free) aggregator root pom declares.
|
||||
const childPoms = [];
|
||||
const childGradles = [];
|
||||
try {
|
||||
const entries = await readdir(repoPath, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
||||
const cp = await readText(join(repoPath, e.name, "pom.xml"));
|
||||
if (cp) childPoms.push(cp);
|
||||
let cg = await readText(join(repoPath, e.name, "build.gradle"));
|
||||
if (!cg) cg = await readText(join(repoPath, e.name, "build.gradle.kts"));
|
||||
if (cg) childGradles.push(cg);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const allPom = [rootPom, ...childPoms].filter(Boolean).join("\n");
|
||||
const allGradle = [rootGradle, ...childGradles].filter(Boolean).join("\n");
|
||||
const buildText = `${allPom}\n${allGradle}\n${antBuild || ""}`;
|
||||
const hasMaven = !!(rootPom || childPoms.length);
|
||||
const hasGradle = !!(rootGradle || childGradles.length);
|
||||
const buildTool = hasMaven ? "Maven" : hasGradle ? "Gradle" : antBuild ? "Ant" : null;
|
||||
const springBoot = /spring-boot/i.test(buildText);
|
||||
const springVersion = (() => {
|
||||
const m = buildText.match(/spring-boot[^>]*?<version>\s*([\d.]+[\w.-]*)\s*</i);
|
||||
return m ? m[1] : null;
|
||||
})();
|
||||
|
||||
const detectedKeys = detectDependencies(buildText);
|
||||
// Ant is identified by its build file, not a content substring (avoids the
|
||||
// old "ant" false positives), so flag it from the resolved build tool.
|
||||
if (buildTool === "Ant" && !detectedKeys.includes("ant")) detectedKeys.push("ant");
|
||||
|
||||
return {
|
||||
buildTool,
|
||||
javaVersion: detectJavaVersion(allPom, allGradle),
|
||||
springBoot,
|
||||
springVersion,
|
||||
hasDockerfile: await findDockerfile(repoPath),
|
||||
hasMavenWrapper:
|
||||
(await exists(join(repoPath, "mvnw"))) || (await exists(join(repoPath, "mvnw.cmd"))),
|
||||
hasGradleWrapper:
|
||||
(await exists(join(repoPath, "gradlew"))) || (await exists(join(repoPath, "gradlew.bat"))),
|
||||
detectedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a repo and produce the full cockpit state snapshot.
|
||||
* @param {string} repoPath absolute path to the repo working directory
|
||||
* @param {{ includeGit?: boolean }} [opts]
|
||||
*/
|
||||
export async function scanRepo(repoPath, opts = {}) {
|
||||
const includeGit = opts.includeGit !== false;
|
||||
if (!repoPath || !(await isDirectory(repoPath))) {
|
||||
return { ok: false, repoPath: repoPath || null, error: "repo path not available" };
|
||||
}
|
||||
|
||||
// Modernization docs. Prefer the namespaced .appmod/ copies; the root-level
|
||||
// files (plan.md / progress.md / summary.md) use very common filenames, so we
|
||||
// trust them as workflow state only when the provenance gate below passes.
|
||||
const [apPlan, apProgress, apSummary, rootPlan, rootProgress, rootSummary] = await Promise.all([
|
||||
readText(join(repoPath, ".appmod", "plan.md")),
|
||||
readText(join(repoPath, ".appmod", "progress.md")),
|
||||
readText(join(repoPath, ".appmod", "summary.md")),
|
||||
readText(join(repoPath, "plan.md")),
|
||||
readText(join(repoPath, "progress.md")),
|
||||
readText(join(repoPath, "summary.md")),
|
||||
]);
|
||||
|
||||
// Structured assessment report (written by the assessment step). Optional,
|
||||
// and the strongest provenance signal that this repo is running the workflow.
|
||||
let report = null;
|
||||
const reportRaw = await readText(join(repoPath, ".appmod", "assessment.json"));
|
||||
if (reportRaw) {
|
||||
try {
|
||||
report = normalizeReport(JSON.parse(reportRaw));
|
||||
} catch {
|
||||
report = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Provenance gate. A .appmod/ artifact (assessment.json or a namespaced doc)
|
||||
// means the modernization workflow is active in this repo, so the root docs
|
||||
// are ours. Otherwise a root doc is trusted only if it carries the explicit
|
||||
// <!-- appmod-cockpit --> marker. This stops an unrelated summary.md from
|
||||
// flipping a foreign repo to "completed", or a stray plan.md from injecting
|
||||
// fake steps into the progress/ordering/autopilot machinery.
|
||||
const hasAppmodArtifact =
|
||||
reportRaw != null || apPlan != null || apProgress != null || apSummary != null;
|
||||
const trusted = (root) => hasAppmodArtifact || hasMarker(root);
|
||||
const planMd = apPlan || (trusted(rootPlan) ? rootPlan : null);
|
||||
const progressMd = apProgress || (trusted(rootProgress) ? rootProgress : null);
|
||||
const summaryMd = apSummary || (trusted(rootSummary) ? rootSummary : null);
|
||||
|
||||
const assessment = await buildAssessment(repoPath);
|
||||
const skills = await discoverSkills(repoPath);
|
||||
|
||||
const planSteps = parseSteps(planMd);
|
||||
const progressSteps = parseSteps(progressMd);
|
||||
// Tag each step with its canonical phase rank for ordering guidance.
|
||||
for (const arr of [planSteps, progressSteps]) {
|
||||
for (const st of arr) st.rank = phaseRankFromName(st.section);
|
||||
}
|
||||
// Progress is the source of truth for status; fall back to plan steps.
|
||||
const steps = progressSteps.length ? progressSteps : planSteps;
|
||||
const percent = percentDone(steps);
|
||||
const ordering = computeOrdering(steps);
|
||||
// Prefer an explicit "Validation"/"gates" section for gate status; fall back
|
||||
// to scanning all steps when no such section exists.
|
||||
const gateSteps =
|
||||
sectionSteps(progressMd, /validation|gates/i) ||
|
||||
sectionSteps(summaryMd, /validation|gates/i) ||
|
||||
steps;
|
||||
const gates = deriveGates(gateSteps, summaryMd);
|
||||
|
||||
let status = "not_started";
|
||||
if (summaryMd) status = "completed";
|
||||
else if (planMd || progressMd) status = "in_progress";
|
||||
|
||||
let git = null;
|
||||
if (includeGit) {
|
||||
const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], repoPath);
|
||||
const dirty = await execGit(["status", "--porcelain"], repoPath);
|
||||
git = {
|
||||
branch,
|
||||
isMigrationBranch: !!branch && /moderniz|migrat|upgrade|appmod/i.test(branch),
|
||||
dirty: dirty === null ? null : dirty.length > 0,
|
||||
changedFiles: dirty ? dirty.split(/\r?\n/).filter(Boolean).length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
repoPath,
|
||||
scannedAt: new Date().toISOString(),
|
||||
status,
|
||||
percent,
|
||||
assessment,
|
||||
tasks: catalogWithRelevance(assessment.detectedKeys),
|
||||
skills,
|
||||
git,
|
||||
plan: { exists: !!planMd, steps: planSteps },
|
||||
progress: { exists: !!progressMd, steps: progressSteps },
|
||||
summary: { exists: !!summaryMd, markdown: summaryMd || "" },
|
||||
report,
|
||||
ordering,
|
||||
gates,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// server.mjs — Per-instance loopback HTTP server + action dispatch for the cockpit.
|
||||
// Decoupled from the SDK: agent messaging is injected as `sendPrompt`, so this
|
||||
// module can be exercised by tests without a live session.
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { scanRepo } from "./scan.mjs";
|
||||
import { renderHtml } from "./renderer.mjs";
|
||||
import { buildPrompt, ACTION_LABELS } from "./prompts.mjs";
|
||||
import { runDoctor } from "./doctor.mjs";
|
||||
import { makeRun, runAutopilot, AUTOPILOT_MAX_STEPS } from "./autopilot.mjs";
|
||||
|
||||
/** Resolve which repo path an open/action context refers to, by precedence. */
|
||||
export function resolveRepoPath(ctx, lastWorkingDir) {
|
||||
const fromInput =
|
||||
ctx && ctx.input && typeof ctx.input.repoPath === "string" ? ctx.input.repoPath : null;
|
||||
const fromSession =
|
||||
ctx && ctx.session && ctx.session.workingDirectory ? ctx.session.workingDirectory : null;
|
||||
// No process.cwd() fallback: that resolves to the extension's own directory,
|
||||
// not the user's repo, and would make the cockpit scan unrelated files. When
|
||||
// nothing resolves we return null and the UI shows a "repo not available"
|
||||
// state instead of plausible-but-wrong data.
|
||||
return fromInput || fromSession || lastWorkingDir || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the repo and attach the environment readiness report. Tool probing is
|
||||
* expensive (spawns several processes), so the doctor result is cached on the
|
||||
* instance record and only recomputed on first build or an explicit recheck —
|
||||
* not on every per-turn refresh. Probing only runs when `rec.runDoctor` is wired
|
||||
* (real instances), so unit tests stay fast and deterministic.
|
||||
*/
|
||||
export async function buildState(rec, { recheckEnv = false } = {}) {
|
||||
const state = await scanRepo(rec.repoPath);
|
||||
if (state && state.ok && typeof rec.runDoctor === "function") {
|
||||
if (recheckEnv || rec.doctor == null) {
|
||||
try {
|
||||
rec.doctor = await rec.runDoctor(state);
|
||||
} catch {
|
||||
rec.doctor = rec.doctor || null;
|
||||
}
|
||||
}
|
||||
state.doctor = rec.doctor;
|
||||
} else if (state && state.ok) {
|
||||
state.doctor = rec.doctor || null;
|
||||
}
|
||||
if (state && state.ok) state.autopilot = rec.autopilot || null;
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Write an SSE payload to every connected client of an instance. */
|
||||
export function broadcast(rec, payload) {
|
||||
const data = "data: " + JSON.stringify(payload) + "\n\n";
|
||||
for (const res of rec.sseClients) {
|
||||
try {
|
||||
res.write(data);
|
||||
} catch {
|
||||
// Client disconnected uncleanly: drop it so we don't keep throwing on
|
||||
// every future broadcast (dead clients would otherwise leak in the Set).
|
||||
rec.sseClients.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-scan the repo and push the fresh snapshot to connected clients. */
|
||||
export async function pushState(rec, log, opts) {
|
||||
let state;
|
||||
try {
|
||||
state = await buildState(rec, opts);
|
||||
} catch (e) {
|
||||
if (log) log("appmod-cockpit scan failed: " + e.message, { level: "error" });
|
||||
return null;
|
||||
}
|
||||
broadcast(rec, { type: "state", state });
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Clamp a requested step budget into a sane range. */
|
||||
function clampSteps(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return AUTOPILOT_MAX_STEPS;
|
||||
return Math.max(1, Math.min(50, Math.round(v)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off an Autopilot run for this instance. Returns immediately; the run loop
|
||||
* streams progress to connected clients over SSE. The run promise is parked on
|
||||
* the record so it is not garbage-collected mid-flight.
|
||||
* @param {object} rec instance record
|
||||
* @param {object} payload { scope?: "phase"|"all", maxSteps?, force? }
|
||||
* @param {{ runTurn:(p:string)=>Promise<any>, log?:Function }} deps
|
||||
*/
|
||||
export async function startAutopilot(rec, payload, deps) {
|
||||
if (rec.autopilot && rec.autopilot.running) {
|
||||
return { ok: false, error: "Autopilot is already running." };
|
||||
}
|
||||
if (!deps || typeof deps.runTurn !== "function") {
|
||||
return { ok: false, error: "This session can't drive Autopilot." };
|
||||
}
|
||||
const p = payload || {};
|
||||
const start = await buildState(rec);
|
||||
if (!start || !start.ok) {
|
||||
return { ok: false, error: "Can't read the repo to start Autopilot." };
|
||||
}
|
||||
if (start.doctor && start.doctor.overall === "blocked" && !p.force) {
|
||||
return { ok: false, error: "Your environment isn't ready. Open Readiness and install the missing tools first." };
|
||||
}
|
||||
const scope = p.scope === "all" ? "all" : "phase";
|
||||
const startRank = start.ordering ? start.ordering.activeRank : null;
|
||||
const run = makeRun({ scope, maxSteps: clampSteps(p.maxSteps), startRank });
|
||||
rec.autopilot = run;
|
||||
broadcast(rec, { type: "autopilot", autopilot: run });
|
||||
|
||||
const runDeps = {
|
||||
snapshot: () => buildState(rec),
|
||||
runTurn: deps.runTurn,
|
||||
buildStepPrompt: (step) => buildPrompt("auto_step", { title: step.title, section: step.section }, rec.repoPath),
|
||||
onProgress: (r, state) => {
|
||||
if (state) broadcast(rec, { type: "state", state });
|
||||
else broadcast(rec, { type: "autopilot", autopilot: r });
|
||||
},
|
||||
log: deps.log,
|
||||
};
|
||||
rec.autopilotPromise = runAutopilot(run, runDeps)
|
||||
.then(() => pushState(rec, deps.log))
|
||||
.catch((e) => {
|
||||
if (deps.log) deps.log("Autopilot run failed: " + e.message, { level: "error" });
|
||||
});
|
||||
return { ok: true, message: "Autopilot started", autopilot: run };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a cockpit action.
|
||||
* @param {object} rec instance record ({ repoPath, sseClients, ... })
|
||||
* @param {string} kind action kind
|
||||
* @param {object} payload action payload
|
||||
* @param {{ sendPrompt:(p:string)=>Promise<void>, runTurn?:Function, log?:Function }} deps
|
||||
*/
|
||||
export async function dispatchAction(rec, kind, payload, deps) {
|
||||
if (kind === "refresh") {
|
||||
const state = await pushState(rec, deps && deps.log);
|
||||
return { ok: true, message: "Refreshed", state };
|
||||
}
|
||||
if (kind === "recheck_env") {
|
||||
const state = await pushState(rec, deps && deps.log, { recheckEnv: true });
|
||||
return { ok: true, message: "Re-checked environment", state };
|
||||
}
|
||||
if (kind === "autopilot_start") {
|
||||
return await startAutopilot(rec, payload, deps);
|
||||
}
|
||||
if (kind === "autopilot_stop") {
|
||||
if (rec.autopilot && rec.autopilot.running) {
|
||||
rec.autopilot.cancelled = true;
|
||||
broadcast(rec, { type: "autopilot", autopilot: rec.autopilot });
|
||||
return { ok: true, message: "Stopping after the current step finishes…" };
|
||||
}
|
||||
return { ok: false, error: "Autopilot isn't running." };
|
||||
}
|
||||
if (kind === "autopilot_dismiss") {
|
||||
if (rec.autopilot && !rec.autopilot.running) {
|
||||
rec.autopilot = null;
|
||||
await pushState(rec, deps && deps.log);
|
||||
}
|
||||
return { ok: true, message: "Dismissed" };
|
||||
}
|
||||
const prompt = buildPrompt(kind, payload, rec.repoPath);
|
||||
if (!prompt) return { ok: false, error: "Unknown action: " + kind };
|
||||
if (!deps || typeof deps.sendPrompt !== "function") {
|
||||
return { ok: false, error: "Session not ready" };
|
||||
}
|
||||
const label = ACTION_LABELS[kind] || kind;
|
||||
try {
|
||||
await deps.sendPrompt(prompt);
|
||||
if (deps.log) deps.log("Java Modernization Studio → " + label, { ephemeral: true });
|
||||
return { ok: true, label, message: "Sent to the agent: " + label, prompt };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Cap the request body the /action endpoint will buffer. Actions carry tiny JSON
|
||||
// payloads; anything large is a bug or abuse, so we reject rather than buffer it.
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
function readBody(req, max = MAX_BODY_BYTES) {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
let size = 0;
|
||||
let done = false;
|
||||
req.on("data", (c) => {
|
||||
if (done) return;
|
||||
size += c.length;
|
||||
if (size > max) {
|
||||
done = true;
|
||||
resolve({ body: "", tooLarge: true });
|
||||
if (typeof req.destroy === "function") req.destroy();
|
||||
return;
|
||||
}
|
||||
data += c;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve({ body: data, tooLarge: false });
|
||||
}
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve({ body: "", tooLarge: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request handler for an instance. Exposed for tests so routes can be
|
||||
* exercised without binding a socket.
|
||||
*/
|
||||
export function makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }) {
|
||||
return async function handler(req, res) {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
try {
|
||||
// Per-instance secret: real instances (createInstanceServer) mint a token
|
||||
// that the host embeds in the iframe URL. Reject any loopback request that
|
||||
// doesn't present it, so other local processes can't read repo state or
|
||||
// dispatch agent actions just by guessing the random port. When no token is
|
||||
// set (direct makeHandler unit tests) the guard is a no-op.
|
||||
if (rec.token) {
|
||||
const provided = url.searchParams.get("token");
|
||||
if (provided !== rec.token) {
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "forbidden" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderHtml({ instanceId, initialTab, token: rec.token }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/state") {
|
||||
const state = await buildState(rec);
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(state));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write("retry: 3000\n\n");
|
||||
rec.sseClients.add(res);
|
||||
req.on("close", () => rec.sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/action") {
|
||||
const { body, tooLarge } = await readBody(req);
|
||||
if (tooLarge) {
|
||||
res.statusCode = 413;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "request body too large" }));
|
||||
return;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body || "{}");
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "invalid JSON body" }));
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed.kind !== "string" || !parsed.kind) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "missing or invalid 'kind'" }));
|
||||
return;
|
||||
}
|
||||
const result = await dispatchAction(rec, parsed.kind, parsed.payload, { sendPrompt, runTurn, log });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
} catch (e) {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
}
|
||||
res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback server for one canvas instance.
|
||||
* @returns {Promise<object>} rec with { server, url, repoPath, sseClients }
|
||||
*/
|
||||
export async function createInstanceServer({ instanceId, repoPath, initialTab, sendPrompt, runTurn, log, runDoctor: runDoctorImpl }) {
|
||||
const rec = { server: null, url: "", repoPath, sseClients: new Set(), doctor: null, autopilot: null, token: randomBytes(16).toString("hex") };
|
||||
// Wire the environment doctor (injectable for tests); real instances probe the
|
||||
// local toolchain, cached and only recomputed on explicit recheck.
|
||||
rec.runDoctor = typeof runDoctorImpl === "function" ? runDoctorImpl : (scan) => runDoctor(scan, {});
|
||||
const handler = makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log });
|
||||
const server = createServer((req, res) => {
|
||||
// The handler is async: if it rejects, surface a 500 and log it rather than
|
||||
// letting it become an unhandled rejection that can crash the extension.
|
||||
handler(req, res).catch((err) => {
|
||||
if (log) log("Java Modernization Studio server error: " + (err && err.message ? err.message : err), { level: "error" });
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "internal error" }));
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
} catch {
|
||||
/* response already torn down */
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
rec.server = server;
|
||||
rec.url = "http://127.0.0.1:" + port + "/?token=" + rec.token;
|
||||
return rec;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user