chore: publish from main

This commit is contained in:
github-actions[bot]
2026-09-14 04:29:33 +00:00
parent 83a621119b
commit bb286637d0
20 changed files with 1533 additions and 0 deletions
@@ -0,0 +1,190 @@
function serialize(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
export function renderHtml(initialState, token) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Modernization Workflow</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--background-color-default, #0d1117);
color: var(--text-color-default, #f0f6fc);
font: var(--text-body-medium, 14px)/var(--leading-body-medium, 20px) var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
}
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.shell { min-height: 100vh; }
header {
padding: 28px 32px 26px;
background: linear-gradient(125deg, #071a2d 0%, #0c3452 55%, #0d4757 100%);
color: #fff; border-bottom: 1px solid rgba(255,255,255,.14);
}
.eyebrow { display:flex; align-items:center; gap:9px; text-transform:uppercase; letter-spacing:.12em; font-size:11px; font-weight:700; color:#7ee7d8; }
.mark { width:9px; height:9px; border-radius:50%; background:#2bd4bd; box-shadow:0 0 0 5px rgba(43,212,189,.13); }
h1 { margin: 12px 0 7px; font-size: var(--text-title-large, 28px); line-height: 1.15; letter-spacing:-.03em; }
.subtitle { margin:0; max-width:720px; color:#bdd3e0; }
.header-row { display:flex; justify-content:space-between; align-items:end; gap:20px; }
.progress-wrap { min-width:180px; text-align:right; }
.progress-value { font-size:26px; font-weight:700; }
.progress-track { height:7px; margin-top:7px; border-radius:8px; background:rgba(255,255,255,.16); overflow:hidden; }
.progress-bar { height:100%; background:#2bd4bd; transition:width .25s ease; }
main { padding:24px 32px 40px; max-width:1280px; margin:auto; }
.notice { display:none; margin-bottom:18px; padding:11px 13px; border:1px solid #9a6700; border-radius:8px; background:rgba(187,128,9,.12); color:var(--text-color-default,#f0f6fc); }
.grid { display:grid; grid-template-columns:minmax(0, 1.45fr) minmax(280px, .75fr); gap:20px; }
.panel { background:var(--background-color-muted,#161b22); border:1px solid var(--border-color-default,#30363d); border-radius:12px; overflow:hidden; }
.panel-head { padding:16px 18px; border-bottom:1px solid var(--border-color-default,#30363d); display:flex; justify-content:space-between; align-items:center; gap:12px; }
.panel-head h2 { margin:0; font-size:16px; }
.panel-body { padding:18px; }
.workflow { display:grid; gap:12px; }
.step { border:1px solid var(--border-color-default,#30363d); border-radius:10px; padding:16px; background:var(--background-color-default,#0d1117); }
.step-top { display:flex; gap:12px; align-items:flex-start; }
.number { flex:0 0 32px; height:32px; display:grid; place-items:center; border-radius:50%; background:#173e54; color:#9ce7e0; font-weight:700; }
.step.complete .number { background:#1f6f58; color:#fff; }
.step.active { border-color:#2bd4bd; box-shadow:0 0 0 1px rgba(43,212,189,.22); }
.step h3 { margin:1px 0 3px; font-size:15px; }
.step p { margin:0; color:var(--text-color-muted,#8b949e); font-size:12px; }
.status { margin-left:auto; padding:3px 8px; border-radius:99px; font-size:11px; text-transform:capitalize; border:1px solid var(--border-color-default,#30363d); }
.status.complete { color:#56d6a1; border-color:#238636; background:rgba(35,134,54,.12); }
.status.active { color:#7ee7d8; border-color:#2bd4bd; }
.status.blocked { color:#ffb8b0; border-color:#f85149; }
.command { display:flex; align-items:center; gap:10px; margin:13px 0 10px 44px; padding:10px 11px; border-radius:7px; background:#050b10; border:1px solid #26323d; }
code { flex:1; min-width:0; overflow:auto; color:#d8f4f0; font:12px/18px var(--font-mono, Consolas, monospace); white-space:nowrap; }
.copy, .small-btn { flex:0 0 auto; border:1px solid var(--border-color-default,#30363d); background:var(--background-color-muted,#161b22); color:inherit; border-radius:6px; padding:6px 9px; }
.actions { margin-left:44px; display:flex; gap:7px; flex-wrap:wrap; }
.primary { border-color:#238636; background:#238636; color:#fff; }
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
label { display:grid; gap:5px; color:var(--text-color-muted,#8b949e); font-size:12px; }
label.wide { grid-column:1/-1; }
input, select, textarea { width:100%; border:1px solid var(--border-color-default,#30363d); border-radius:7px; padding:8px 9px; background:var(--background-color-default,#0d1117); color:var(--text-color-default,#f0f6fc); }
textarea { min-height:65px; resize:vertical; }
.save-row { display:flex; justify-content:flex-end; margin-top:12px; }
.meta { display:grid; gap:10px; margin:0; }
.meta div { display:grid; grid-template-columns:105px 1fr; gap:10px; }
.meta dt { color:var(--text-color-muted,#8b949e); }
.meta dd { margin:0; overflow-wrap:anywhere; }
.artifact-list { margin:0; padding:0; list-style:none; display:grid; gap:7px; }
.artifact-list li { padding:8px 9px; border-radius:6px; background:var(--background-color-default,#0d1117); font:11px/16px var(--font-mono,Consolas,monospace); overflow-wrap:anywhere; }
.empty { color:var(--text-color-muted,#8b949e); font-size:12px; }
.links { display:flex; gap:8px; flex-wrap:wrap; }
.links a { color:#58a6ff; text-decoration:none; }
.stack { display:grid; gap:20px; }
.toast { position:fixed; right:20px; bottom:20px; padding:9px 12px; border-radius:7px; background:#238636; color:#fff; opacity:0; transform:translateY(8px); transition:.2s; pointer-events:none; }
.toast.show { opacity:1; transform:none; }
@media (max-width:850px) { .grid { grid-template-columns:1fr; } .header-row { align-items:start; } .progress-wrap { min-width:130px; } }
@media (max-width:560px) { header, main { padding-left:18px; padding-right:18px; } .form-grid { grid-template-columns:1fr; } label.wide { grid-column:auto; } .command,.actions { margin-left:0; } }
</style>
</head>
<body>
<div class="shell">
<header>
<div class="header-row">
<div>
<div class="eyebrow"><span class="mark"></span> GitHub Copilot modernization</div>
<h1>Modernization Workflow</h1>
<p class="subtitle">Move from evidence to an approved plan and validated code changes with the Modernize CLI.</p>
</div>
<div class="progress-wrap"><div class="progress-value" id="progressValue">0%</div><div class="progress-track"><div class="progress-bar" id="progressBar"></div></div></div>
</div>
</header>
<main>
<div class="notice" id="notice"></div>
<div class="grid">
<section class="panel">
<div class="panel-head"><h2>Assess → Plan → Execute</h2><button class="small-btn" id="refresh">Refresh artifacts</button></div>
<div class="panel-body">
<div class="workflow" id="workflow"></div>
<div class="step" style="margin-top:12px">
<div class="step-top"><div class="number">↗</div><div><h3>Upgrade fast path</h3><p>Run the documented end-to-end plan-and-execute workflow when a separate plan review is not required.</p></div></div>
<div class="command"><code id="upgradeCommand"></code><button class="copy" data-command="upgrade">Copy</button></div>
</div>
</div>
</section>
<div class="stack">
<section class="panel">
<div class="panel-head"><h2>Workflow setup</h2></div>
<div class="panel-body">
<form id="config">
<div class="form-grid">
<label>Technology<select name="language"><option value="auto">Auto-detect</option><option value="dotnet">.NET</option><option value="java">Java</option><option value="cpp">C++</option></select></label>
<label>Execution<select name="delegate"><option value="local">Local</option><option value="cloud">Cloud agent</option></select></label>
<label class="wide">Source<input name="source" required></label>
<label class="wide">Modernization goal<textarea name="goal" required></textarea></label>
<label class="wide">Upgrade target (optional)<input name="upgradeTarget" placeholder="Java 21, Spring Boot 3.2, or .NET 10"></label>
<label class="wide">Plan name<input name="planName" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" required></label>
</div>
<div class="save-row"><button class="small-btn primary" type="submit">Save configuration</button></div>
</form>
</div>
</section>
<section class="panel">
<div class="panel-head"><h2>Repository signal</h2></div>
<div class="panel-body"><dl class="meta" id="meta"></dl></div>
</section>
<section class="panel">
<div class="panel-head"><h2>Detected artifacts</h2></div>
<div class="panel-body"><ul class="artifact-list" id="artifacts"></ul></div>
</section>
<section class="panel">
<div class="panel-head"><h2>Reference</h2></div>
<div class="panel-body links">
<a href="https://github.com/microsoft/modernize-cli" target="_blank" rel="noreferrer">Modernize CLI</a>
<a href="https://learn.microsoft.com/azure/developer/github-copilot-app-modernization/modernization-agent/overview" target="_blank" rel="noreferrer">Overview</a>
<a href="https://learn.microsoft.com/azure/developer/github-copilot-app-modernization/modernization-agent/cli-commands" target="_blank" rel="noreferrer">CLI commands</a>
</div>
</section>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
const TOKEN=${serialize(token)};
let state=${serialize(initialState)};
const definitions={
assess:{title:"Assess",description:"Analyze source, dependencies, risks, and cloud readiness.",number:1},
plan:{title:"Plan",description:"Create and review an ordered plan with tasks and success criteria.",number:2},
execute:{title:"Execute",description:"Apply transformations, validate builds, scan CVEs, and summarize.",number:3}
};
function esc(value){return String(value??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));}
function toast(message){const el=document.querySelector("#toast");el.textContent=message;el.classList.add("show");setTimeout(()=>el.classList.remove("show"),1500);}
async function post(path,body={}){const response=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","x-canvas-token":TOKEN},body:JSON.stringify(body)});const value=await response.json();if(!response.ok)throw new Error(value.error||"Request failed");state=value;render();}
function render(){
const complete=Object.values(state.steps).filter(s=>s.status==="complete").length;
const percent=Math.round(complete/3*100);
document.querySelector("#progressValue").textContent=percent+"%";
document.querySelector("#progressBar").style.width=percent+"%";
const notice=document.querySelector("#notice");
notice.style.display=state.compatibilityNotice?"block":"none";notice.textContent=state.compatibilityNotice||"";
document.querySelector("#workflow").innerHTML=Object.entries(definitions).map(([key,d])=>{
const status=state.steps[key].status;
return '<article class="step '+esc(status)+'"><div class="step-top"><div class="number">'+d.number+'</div><div><h3>'+d.title+'</h3><p>'+d.description+'</p></div><span class="status '+esc(status)+'">'+esc(status)+'</span></div><div class="command"><code>'+esc(state.commands[key])+'</code><button class="copy" data-command="'+key+'">Copy</button></div><div class="actions"><button class="small-btn" data-step="'+key+'" data-status="active">Set active</button><button class="small-btn primary" data-step="'+key+'" data-status="complete">Mark complete</button><button class="small-btn" data-step="'+key+'" data-status="blocked">Blocked</button></div></article>';
}).join("");
document.querySelector("#upgradeCommand").textContent=state.commands.upgrade;
const form=document.querySelector("#config");
for(const name of ["language","delegate","source","goal","upgradeTarget","planName"]) form.elements[name].value=state[name];
document.querySelector("#meta").innerHTML=[
["Workspace",state.workspace],["Detected",state.detectedLanguage],["Using",state.effectiveLanguage],["State",state.stateId]
].map(([a,b])=>"<div><dt>"+esc(a)+"</dt><dd>"+esc(b)+"</dd></div>").join("");
const artifacts=[...state.artifacts.assessment,...state.artifacts.plan,...state.artifacts.execution];
document.querySelector("#artifacts").innerHTML=artifacts.length?artifacts.slice(0,12).map(x=>"<li>"+esc(x)+"</li>").join(""):'<li class="empty">No Modernize CLI output detected yet.</li>';
}
document.addEventListener("click",async event=>{
const copy=event.target.closest("[data-command]");
if(copy){await navigator.clipboard.writeText(state.commands[copy.dataset.command]);toast("Command copied");}
const step=event.target.closest("[data-step]");
if(step){try{await post("/api/step",{step:step.dataset.step,status:step.dataset.status});toast("Progress updated");}catch(error){toast(error.message);}}
});
document.querySelector("#refresh").addEventListener("click",async()=>{try{await post("/api/refresh");toast("Artifacts refreshed");}catch(error){toast(error.message);}});
document.querySelector("#config").addEventListener("submit",async event=>{event.preventDefault();try{await post("/api/configure",Object.fromEntries(new FormData(event.target)));toast("Configuration saved");}catch(error){toast(error.message);}});
const events=new EventSource("/events");events.addEventListener("state",event=>{state=JSON.parse(event.data);render();});
render();
</script>
</body>
</html>`;
}
@@ -0,0 +1,104 @@
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { getWorkflowState } from "./state.mjs";
import { renderHtml } from "./renderer.mjs";
function sendJson(res, status, value) {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(value));
}
async function readJson(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 64 * 1024) throw new Error("Request body is too large.");
chunks.push(chunk);
}
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
}
export async function pushState(rec, state) {
const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
for (const client of rec.clients) client.write(payload);
}
export async function createInstanceServer(options) {
const token = randomBytes(24).toString("hex");
const clients = new Set();
let rec;
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
try {
if (req.method === "GET" && url.pathname === "/") {
const state = await getWorkflowState(options.workspace, options.stateId);
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
});
res.end(renderHtml(state, token));
return;
}
if (req.method === "GET" && url.pathname === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
clients.add(res);
req.on("close", () => clients.delete(res));
return;
}
if (req.method !== "POST" || req.headers["x-canvas-token"] !== token) {
sendJson(res, 403, { error: "Request rejected." });
return;
}
const input = await readJson(req);
let state;
if (url.pathname === "/api/configure") {
state = await options.onConfigure(input);
} else if (url.pathname === "/api/step") {
state = await options.onSetStep(input.step, input.status);
} else if (url.pathname === "/api/refresh") {
state = await options.onRefresh();
} else {
sendJson(res, 404, { error: "Not found." });
return;
}
await pushState(rec, state);
sendJson(res, 200, state);
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
rec = {
...options,
server,
clients,
url: `http://127.0.0.1:${address.port}/`,
};
if (Object.keys(options.initialConfig).some((key) =>
["language", "source", "goal", "planName"].includes(key))) {
await options.onConfigure(options.initialConfig);
}
return rec;
}
@@ -0,0 +1,227 @@
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const DEFAULT_STATE = {
version: 1,
language: "auto",
source: ".",
goal: "modernize this application for Azure",
upgradeTarget: "",
planName: "modernization-plan",
delegate: "local",
steps: {
assess: { status: "pending", updatedAt: null },
plan: { status: "pending", updatedAt: null },
execute: { status: "pending", updatedAt: null },
},
};
function statePath(workspace, stateId) {
return join(workspace, ".github", "modernize", `canvas-${stateId}.json`);
}
function cleanStateId(value) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
throw new Error("Invalid workflow state identifier.");
}
return value;
}
export function resolveWorkspace(input, fallback) {
const candidate = input || fallback || process.cwd();
return resolve(candidate);
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function loadState(workspace, stateId) {
const path = statePath(workspace, cleanStateId(stateId));
try {
const saved = JSON.parse(await readFile(path, "utf8"));
return {
...DEFAULT_STATE,
...saved,
steps: {
...DEFAULT_STATE.steps,
...(saved.steps ?? {}),
},
};
} catch (error) {
if (error.code === "ENOENT") return structuredClone(DEFAULT_STATE);
throw error;
}
}
async function saveState(workspace, stateId, state) {
const path = statePath(workspace, cleanStateId(stateId));
await mkdir(join(workspace, ".github", "modernize"), { recursive: true });
const next = { ...state, updatedAt: new Date().toISOString() };
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
return next;
}
async function walkNames(root, maxDepth = 3, depth = 0) {
if (depth > maxDepth || !(await exists(root))) return [];
const output = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
if ([".git", "node_modules", "bin", "obj", "target", "build"].includes(entry.name)) continue;
const path = join(root, entry.name);
output.push(path);
if (entry.isDirectory()) output.push(...await walkNames(path, maxDepth, depth + 1));
}
return output;
}
function detectLanguage(paths) {
const names = paths.map((path) => path.toLowerCase());
if (names.some((path) => /\.(sln|csproj|fsproj|vbproj)$/.test(path))) return "dotnet";
if (names.some((path) => /\/(pom\.xml|build\.gradle|build\.gradle\.kts)$/.test(path.replaceAll("\\", "/")))) return "java";
if (names.some((path) => /\/(cmakelists\.txt|makefile)$/.test(path.replaceAll("\\", "/")) || /\.(cpp|cc|cxx|vcxproj)$/.test(path))) return "cpp";
return "unknown";
}
async function scanArtifacts(workspace, planName) {
const modernizeRoot = join(workspace, ".github", "modernize");
const assessmentRoot = join(modernizeRoot, "assessment");
const planRoot = join(modernizeRoot, planName);
const assessmentFiles = (await walkNames(assessmentRoot, 3))
.filter((path) => /\.(json|md|html|ya?ml)$/i.test(path));
const planFiles = (await walkNames(planRoot, 2))
.filter((path) => /\.(json|md|html)$/i.test(path));
const assessmentOutputs = assessmentFiles.filter((path) =>
!/config|canvas-/i.test(path) && /\.(json|md|html)$/i.test(path));
const summaryFiles = planFiles.filter((path) => /summary|progress|result/i.test(path));
return {
assessment: assessmentFiles.map((path) => path.slice(workspace.length + 1)),
plan: planFiles.map((path) => path.slice(workspace.length + 1)),
execution: summaryFiles.map((path) => path.slice(workspace.length + 1)),
hasAssessment: assessmentOutputs.length > 0,
hasPlan: planFiles.some((path) => /plan\.md$/i.test(path))
&& planFiles.some((path) => /tasks\.json$/i.test(path)),
hasExecution: summaryFiles.length > 0,
};
}
function quote(value) {
const text = String(value).trim();
if (/^[A-Za-z0-9_./:\\-]+$/.test(text)) return text;
return `"${text.replaceAll('"', '\\"')}"`;
}
function languageFlag(language) {
if (language === "java" || language === "dotnet") return ` --language ${language}`;
return "";
}
function delegateFlag(delegate) {
return delegate === "cloud" ? " --delegate cloud" : "";
}
function commands(state, artifacts) {
const source = quote(state.source);
const name = quote(state.planName);
const goal = quote(state.goal);
const upgradeTarget = state.upgradeTarget ? ` ${quote(state.upgradeTarget)}` : "";
const language = state.language === "auto" ? state.detectedLanguage : state.language;
const assessment = artifacts.assessment.find((path) => /assessment.*\.json$/i.test(path))
?? artifacts.assessment.find((path) => /\.json$/i.test(path));
const assessmentFlag = assessment ? ` --assess-file-path ${quote(assessment)}` : "";
return {
assess: `modernize assess --source ${source}${delegateFlag(state.delegate)}${state.delegate === "cloud" ? " --wait" : ""}`,
plan: `modernize plan create ${goal} --source ${source} --plan-name ${name}${languageFlag(language)}${assessmentFlag}`,
execute: `modernize plan execute --source ${source} --plan-name ${name}${languageFlag(language)}${delegateFlag(state.delegate)}`,
upgrade: `modernize upgrade${upgradeTarget} --source ${source}${delegateFlag(state.delegate)}`,
};
}
function deriveSteps(state, artifacts) {
const steps = structuredClone(state.steps);
if (artifacts.hasAssessment) steps.assess.status = "complete";
if (artifacts.hasPlan) {
steps.assess.status = "complete";
steps.plan.status = "complete";
}
if (artifacts.hasExecution) {
steps.assess.status = "complete";
steps.plan.status = "complete";
steps.execute.status = "complete";
}
return steps;
}
export async function getWorkflowState(workspace, stateId = "default") {
const saved = await loadState(workspace, stateId);
const paths = await walkNames(workspace, 3);
const detectedLanguage = detectLanguage(paths);
const artifacts = await scanArtifacts(workspace, saved.planName);
const state = {
...saved,
stateId,
workspace,
detectedLanguage,
effectiveLanguage: saved.language === "auto" ? detectedLanguage : saved.language,
artifacts,
steps: deriveSteps(saved, artifacts),
};
const notices = [];
if (state.effectiveLanguage === "cpp") {
notices.push("The current CLI reference documents explicit --language values for Java and .NET only. C++ commands use CLI auto-detection; verify support with your installed Modernize CLI version.");
}
if (state.delegate === "cloud" && !/^https:\/\/github\.com\//i.test(state.source)) {
notices.push("Cloud delegation requires a github.com repository URL as the source. Local paths and other Git providers are not supported.");
}
return {
...state,
commands: commands(state, artifacts),
compatibilityNotice: notices.length ? notices.join(" ") : null,
};
}
export async function configureWorkflow(workspace, stateId, input) {
const current = await loadState(workspace, stateId);
const allowed = ["language", "source", "goal", "upgradeTarget", "planName", "delegate"];
const next = { ...current };
for (const key of allowed) {
if (input[key] !== undefined) next[key] = String(input[key]).trim();
}
if (!["auto", "dotnet", "java", "cpp"].includes(next.language)) {
throw new Error("Language must be auto, dotnet, java, or cpp.");
}
if (!["local", "cloud"].includes(next.delegate)) {
throw new Error("Execution must be local or cloud.");
}
if (!next.source || !next.goal) {
throw new Error("Source and modernization goal are required.");
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(next.planName)) {
throw new Error("Plan name must contain only letters, numbers, dots, underscores, and hyphens.");
}
await saveState(workspace, stateId, next);
return getWorkflowState(workspace, stateId);
}
export async function setStepStatus(workspace, stateId, step, status) {
if (!["assess", "plan", "execute"].includes(step)) throw new Error("Unknown workflow step.");
if (!["pending", "active", "complete", "blocked"].includes(status)) throw new Error("Unknown step status.");
const current = await loadState(workspace, stateId);
current.steps[step] = { status, updatedAt: new Date().toISOString() };
await saveState(workspace, stateId, current);
return getWorkflowState(workspace, stateId);
}
export async function refreshWorkflow(workspace, stateId = "default") {
const current = await loadState(workspace, stateId);
const refreshed = await getWorkflowState(workspace, stateId);
current.steps = refreshed.steps;
await saveState(workspace, stateId, current);
return getWorkflowState(workspace, stateId);
}