Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions .claude/skills/incremental-build/architecture.md

Large diffs are not rendered by default.

56 changes: 38 additions & 18 deletions packages/cli/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,23 +147,36 @@ serve.handler = async function(argv) {

const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph");

let graph;
if (argv.dependencyDefinition) {
graph = await graphFromStaticFile({
filePath: argv.dependencyDefinition,
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
});
} else {
graph = await graphFromPackageDependencies({
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
workspaceConfigPath: argv.workspaceConfig,
workspaceName: argv.workspace === false ? null : argv.workspace,
});
}
// Workspace resolution is active unless static-graph mode is used or --workspace is disabled.
// Single source for both the graph resolve (below) and the watcher config, so the two cannot
// drift on whether workspace resolution runs.
const workspaceActive = !argv.dependencyDefinition && argv.workspace !== false;

// One graph-construction site, reused for the initial graph and every re-init the server
// performs on a project-definition change. Captures argv so the server can re-resolve with
// identical parameters.
const buildGraph = async () => {
if (argv.dependencyDefinition) {
return graphFromStaticFile({
filePath: argv.dependencyDefinition,
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
});
} else {
return graphFromPackageDependencies({
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
workspaceConfigPath: argv.workspaceConfig,
workspaceName: workspaceActive ? argv.workspace : null,
});
}
};

// Build the initial graph up front: its root server settings resolve the port and
// live-reload defaults below, before the server binds.
const graph = await buildGraph();

let port = argv.port;
let changePortIfInUse = false;
Expand Down Expand Up @@ -210,6 +223,11 @@ serve.handler = async function(argv) {
cache: argv.cache,
includedTasks: argv["include-task"],
excludedTasks: argv["exclude-task"],
// Threaded to the definition watcher so it watches the same files buildGraph resolves from.
// null selects the watcher's default ui5-workspace.yaml, undefined disables workspace watching.
rootConfigPath: argv.config,
workspaceConfigPath: workspaceActive ? (argv.workspaceConfig ?? null) : undefined,
dependencyDefinitionPath: argv.dependencyDefinition,
};

if (serverConfig.h2) {
Expand All @@ -221,9 +239,11 @@ serve.handler = async function(argv) {

const {promise: pOnError, reject} = Promise.withResolvers();
const {serve: serverServe} = await import("@ui5/server");
// Pass buildGraph as the graphFactory so the server can re-resolve the graph and re-create
// the serving stack when its definition watcher observes a project-definition change.
const {h2, port: actualPort} = await serverServe(graph, serverConfig, function(err) {
reject(err);
});
}, {graphFactory: buildGraph});

if (argv.open !== undefined) {
const protocol = h2 ? "https" : "http";
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/test/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,21 @@ test.serial("ui5 serve: default", async (t) => {
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
rootConfigPath: undefined,
workspaceConfigPath: null,
dependencyDefinitionPath: undefined,
}
]);
t.is(typeof server.serve.getCall(0).args[2], "function");

// The 4th argument carries a graphFactory the server can call to re-resolve the graph on a
// project-definition change. It must produce the same graph via the same builder + args.
const options = server.serve.getCall(0).args[3];
t.is(typeof options.graphFactory, "function");
await options.graphFactory();
t.is(graph.graphFromPackageDependencies.callCount, 2, "graphFactory re-invokes the same builder");
t.deepEqual(graph.graphFromPackageDependencies.getCall(1).args, graph.graphFromPackageDependencies.getCall(0).args,
"graphFactory re-resolves with identical parameters");
});

test.serial("ui5 serve --h2", async (t) => {
Expand Down Expand Up @@ -169,6 +181,9 @@ test.serial("ui5 serve --h2", async (t) => {
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
rootConfigPath: undefined,
workspaceConfigPath: null,
dependencyDefinitionPath: undefined,
}
]);

Expand Down Expand Up @@ -204,6 +219,9 @@ test.serial("ui5 serve --accept-remote-connections", async (t) => {
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
rootConfigPath: undefined,
workspaceConfigPath: null,
dependencyDefinitionPath: undefined,
}
]);
});
Expand Down
4 changes: 2 additions & 2 deletions packages/logger/lib/writers/Console.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ class Console {
process.on("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
process.on("ui5.build-status", this._handleBuildStatusEvent);
process.on("ui5.project-build-status", this._handleProjectBuildStatusEvent);
process.on("ui5.project-resolved", this._handleProjectResolvedEvent);
process.on("ui5.project-resolve-succeeded", this._handleProjectResolvedEvent);
process.on("ui5.server-listening", this._handleServerListeningEvent);
process.on("ui5.log.stop-console", this._handleStop);
}
Expand All @@ -129,7 +129,7 @@ class Console {
process.off("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
process.off("ui5.build-status", this._handleBuildStatusEvent);
process.off("ui5.project-build-status", this._handleProjectBuildStatusEvent);
process.off("ui5.project-resolved", this._handleProjectResolvedEvent);
process.off("ui5.project-resolve-succeeded", this._handleProjectResolvedEvent);
process.off("ui5.server-listening", this._handleServerListeningEvent);
process.off("ui5.log.stop-console", this._handleStop);
if (this.#progressBarContainer) {
Expand Down
62 changes: 48 additions & 14 deletions packages/logger/lib/writers/InteractiveConsole.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
setProject,
setFramework,
enableProjectPlaceholders,
setVersionResolving,
clearVersionResolving,
} from "./interactiveConsole/state/project.js";
import {createServerState, setListening, enableServerPlaceholders} from "./interactiveConsole/state/server.js";
import {
createBuildState, beginBuild, advanceToProject, setTask, transitionTo, setError, setStale, STATES,
SPINNING_STATES, enableBuildPlaceholders,
SPINNING_STATES, enableBuildPlaceholders, setDegraded, clearDegraded,
} from "./interactiveConsole/state/build.js";
import {
renderHeaderRegion, renderProjectRegion, renderServerRegion, renderBuildRegion,
Expand Down Expand Up @@ -97,8 +99,6 @@ class InteractiveConsole {
stderr: {orig: null, partial: ""},
};

#seenProjectResolved = false;

// Bound listeners so we can `process.off` them on stop().
#onLog;
#onBuildMetadata;
Expand All @@ -109,6 +109,8 @@ class InteractiveConsole {
#onToolMode;
#onProjectResolved;
#onProjectFrameworkResolved;
#onProjectResolving;
#onProjectResolveFailed;
#onServerListening;
#onStopConsole;
#onResize;
Expand Down Expand Up @@ -299,6 +301,8 @@ class InteractiveConsole {
this.#onToolMode = (evt) => this.#handleToolMode(evt);
this.#onProjectResolved = (evt) => this.#handleProjectResolved(evt);
this.#onProjectFrameworkResolved = (evt) => this.#handleProjectFrameworkResolved(evt);
this.#onProjectResolving = () => this.#handleProjectResolving();
this.#onProjectResolveFailed = (evt) => this.#handleProjectResolveFailed(evt);
this.#onServerListening = (evt) => this.#handleServerListening(evt);
this.#onStopConsole = () => this.disable();
this.#onResize = () => this.#handleResize();
Expand All @@ -310,8 +314,10 @@ class InteractiveConsole {
process.on("ui5.serve-status", this.#onServeStatus);
process.on("ui5.tool-info", this.#onToolInfo);
process.on("ui5.tool-mode", this.#onToolMode);
process.on("ui5.project-resolved", this.#onProjectResolved);
process.on("ui5.project-resolve-succeeded", this.#onProjectResolved);
process.on("ui5.project-framework-resolved", this.#onProjectFrameworkResolved);
process.on("ui5.project-resolve-started", this.#onProjectResolving);
process.on("ui5.project-resolve-failed", this.#onProjectResolveFailed);
process.on("ui5.server-listening", this.#onServerListening);
process.on("ui5.log.stop-console", this.#onStopConsole);
if (typeof this.#stderr.on === "function") {
Expand All @@ -327,8 +333,10 @@ class InteractiveConsole {
process.off("ui5.serve-status", this.#onServeStatus);
process.off("ui5.tool-info", this.#onToolInfo);
process.off("ui5.tool-mode", this.#onToolMode);
process.off("ui5.project-resolved", this.#onProjectResolved);
process.off("ui5.project-resolve-succeeded", this.#onProjectResolved);
process.off("ui5.project-framework-resolved", this.#onProjectFrameworkResolved);
process.off("ui5.project-resolve-started", this.#onProjectResolving);
process.off("ui5.project-resolve-failed", this.#onProjectResolveFailed);
process.off("ui5.server-listening", this.#onServerListening);
process.off("ui5.log.stop-console", this.#onStopConsole);
if (typeof this.#stderr.off === "function") {
Expand Down Expand Up @@ -369,16 +377,15 @@ class InteractiveConsole {
}

#handleProjectResolved(evt) {
if (this.#seenProjectResolved) {
// The writer's model is single-root-project. A second
// `ui5.project-resolved` event means the emitter violated that
// invariant, making subsequent event attribution ambiguous, so fail
// fast instead of trying to deduplicate.
throw new Error(
`writers/InteractiveConsole: Received duplicate ui5.project-resolved event`);
}
this.#seenProjectResolved = true;
// The writer's model is single-root-project, but the root can be resolved more
// than once in a process: `Supervisor.reinitialize()` re-resolves the graph
// on a project-definition change (a `git checkout`, a hand-edit of ui5.yaml), and
// re-emits this event for the same root. A repeat updates the project region in
// place; framework data is updated through `ui5.project-framework-resolved`.
setProject(this.#projectState, evt);
// A completed re-resolve lifts any degraded state a prior failed re-resolve left sticky, so
// the next `serve-ready` from the fresh stack renders "ready" normally again.
clearDegraded(this.#buildState);
this.#render();
}

Expand All @@ -387,6 +394,25 @@ class InteractiveConsole {
this.#render();
}

// A definition change is coming: blank the version slot(s) to a "resolving…" placeholder. A
// completed resolve arrives as `ui5.project-resolve-succeeded` and repopulates them via
// #handleProjectResolved; an abandoned/failed resolve arrives as `ui5.project-resolve-failed`.
#handleProjectResolving() {
setVersionResolving(this.#projectState);
this.#render();
}

// A re-resolve was abandoned or failed without a completing `ui5.project-resolve-succeeded`: release the
// placeholder back to the last-known version rather than leaving it on "resolving…". A failed
// re-resolve also means the server is now serving a last-good graph that no longer matches disk;
// mark it degraded and surface the reason on the (sticky) ERROR status line.
#handleProjectResolveFailed(evt) {
clearVersionResolving(this.#projectState);
setDegraded(this.#buildState);
setError(this.#buildState, evt?.message);
this.#render();
}

#handleServerListening(evt) {
setListening(this.#serverState, evt);
this.#render();
Expand Down Expand Up @@ -417,6 +443,14 @@ class InteractiveConsole {
if (evt.status !== "serve-validating") {
this.#buildState.validatingProjects = [];
}
// While degraded (a re-resolve failed and the last-good stack keeps serving), the surviving
// BuildServer still resettles to IDLE/stale on the branch switch's source churn and would
// otherwise repaint "ready" over the degraded ERROR line. Suppress those transitions until a
// successful re-resolve clears the flag (via `ui5.project-resolve-succeeded`). A genuine
// `serve-error` still passes through: it keeps the line on ERROR either way.
if (this.#buildState.degraded && evt.status !== "serve-error") {
return;
}
switch (evt.status) {
case "serve-ready":
this.#transitionTo(STATES.READY);
Expand Down
16 changes: 13 additions & 3 deletions packages/logger/lib/writers/interactiveConsole/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,14 @@ export function renderProjectRegion(projectState) {
if (projectState.project) {
const project = projectState.project;
const framework = projectState.framework;
const resolving = projectState.versionResolving;
const projectType = project.type ? chalk.dim(`(${project.type})`) : "";
const projectVersion = project.version ? chalk.dim("v" + project.version) : "";
// While a re-resolve is pending, the version is about to change: show a placeholder in
// its slot rather than the stale value. The name and type keep showing so the region
// does not collapse.
const projectVersion = resolving ?
placeholder("resolving…") :
(project.version ? chalk.dim("v" + project.version) : "");
lines.push(`${chalk.dim("Project")} ${chalk.bold(project.name)}` +
(projectType ? ` ${projectType}` : "") +
(projectVersion ? ` ${projectVersion}` : ""));
Expand All @@ -56,8 +62,12 @@ export function renderProjectRegion(projectState) {
// making "(none)" misleading; omitting the row also avoids a stale
// "Framework resolving…" placeholder flashing before it disappears.
if (framework?.name) {
const frameworkVersion = framework.version ? ` ${framework.version}` : "";
lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name + frameworkVersion)}`);
// The framework version goes stale alongside the project version on a branch
// switch: placeholder it too, keeping the name.
const frameworkVersion = resolving ?
` ${placeholder("resolving…")}` :
(framework.version ? chalk.bold(` ${framework.version}`) : "");
lines.push(`${chalk.dim("Framework")} ${chalk.bold(framework.name)}${frameworkVersion}`);
}
} else {
// Placeholder mode: reserve only the Project row. The Framework row is
Expand Down
17 changes: 17 additions & 0 deletions packages/logger/lib/writers/interactiveConsole/state/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export function createBuildState() {
// Ordered list of projects announced by build-metadata. Used to compute
// a stable 1-based `currentProjectIndex` when build-status events arrive.
projectOrder: [],
// True while the server is serving a last-good graph after a failed re-resolve (e.g. a
// branch switch to a config the tooling can't resolve). The status line reuses the ERROR
// rendering; this flag makes it sticky so the surviving BuildServer's `serve-ready` from
// source churn can't repaint "ready" over it. Set by `ui5.project-resolve-failed`, cleared
// by `ui5.project-resolve-succeeded`.
degraded: false,
};
}

Expand Down Expand Up @@ -102,6 +108,17 @@ export function setError(state, message) {
transitionTo(state, STATES.ERROR);
}

// Marks the server degraded after a failed re-resolve. The caller pairs this with `setError` to
// render the reason on the ERROR line; this flag keeps that line sticky against later `serve-ready`
// events from the surviving BuildServer until a successful re-resolve calls `clearDegraded`.
export function setDegraded(state) {
state.degraded = true;
}

export function clearDegraded(state) {
state.degraded = false;
}

// Advance the region into a "starting" placeholder state so the Status row is
// visible from the first frame. Called from `ui5.tool-mode`. Real state
// transitions (READY/BUILDING/…) replace it.
Expand Down
21 changes: 20 additions & 1 deletion packages/logger/lib/writers/interactiveConsole/state/project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Region 2 — root project. Populated by `ui5.project-resolved` and, when
// Region 2 — root project. Populated by `ui5.project-resolve-succeeded` and, when
// framework usage is actually resolved for the current run, by
// `ui5.project-framework-resolved`.
export function createProjectState() {
Expand All @@ -9,11 +9,19 @@ export function createProjectState() {
// of real data. Enabled by `ui5.tool-mode` before the graph is built so
// the layout is stable from the very first frame.
showPlaceholders: false,
// When true, the version slot(s) render a dim "resolving…" placeholder while the
// project name and type keep showing. Enabled by `ui5.project-resolve-started` once a
// definition change is known to be coming (a `git checkout`, a ui5.yaml edit) and the
// resolved version is about to change. `setProject` clears it; a failed re-resolve
// releases it via `clearVersionResolving`.
versionResolving: false,
};
}

export function setProject(state, evt) {
state.project = {name: evt.name, type: evt.type, version: evt.version};
// A resolve completed: drop the placeholder in favour of the new version.
state.versionResolving = false;
}

export function setFramework(state, framework) {
Expand All @@ -26,3 +34,14 @@ export function setFramework(state, framework) {
export function enableProjectPlaceholders(state) {
state.showPlaceholders = true;
}

// Blanks the version slot(s) to a "resolving…" placeholder: a re-resolve is coming.
export function setVersionResolving(state) {
state.versionResolving = true;
}

// Releases the placeholder back to the last-known version: a re-resolve was abandoned or failed
// without a completing `ui5.project-resolve-succeeded`.
export function clearVersionResolving(state) {
state.versionResolving = false;
}
Loading
Loading