Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3286913
refactor(project): Document and trim discardIncrementalState resets
RandomByte Jul 20, 2026
29e1eb3
refactor(server): Re-create the serving stack on definition changes
RandomByte Jul 13, 2026
bdb4c74
refactor(server): Collapse duplicated EADDRINUSE construction in list…
RandomByte Jul 13, 2026
c3795ae
refactor(server): Trim ServeSupervisor to its used surface
RandomByte Jul 13, 2026
a440635
refactor(project): Add DefinitionWatcher for project-definition files
RandomByte Jul 13, 2026
3f26528
refactor(server): Drive reinitialize from a DefinitionWatcher
RandomByte Jul 13, 2026
a419849
refactor(cli): Thread config paths to the definition watcher
RandomByte Jul 13, 2026
5f6b63e
refactor(server): Extract buildServeRouter from buildServeApp
RandomByte Jul 15, 2026
3ce9b66
refactor(server): Add serveMiddleware API for external HTTP servers
RandomByte Jul 15, 2026
3d895f3
refactor(logger): Update project region on repeated project-resolved
RandomByte Jul 15, 2026
08f9cb4
refactor(project): Keep source watcher alive across a branch switch
RandomByte Jul 15, 2026
5cdb149
refactor(logger): Placeholder the project version during re-resolution
RandomByte Jul 16, 2026
af6d95a
refactor(project): Emit definitionChanging on the leading edge
RandomByte Jul 16, 2026
0c9a819
refactor(server): Signal project-resolving across a definition-driven…
RandomByte Jul 16, 2026
1b1c5f5
refactor(project): Hold the deferred restart against a mid-burst read…
RandomByte Jul 20, 2026
55c725c
refactor(project): Extract the subscription-drain helper shared by bo…
RandomByte Jul 21, 2026
9c06fc7
refactor(project): Single-source the watcher burst-settle window
RandomByte Jul 21, 2026
dd8809e
refactor(project): Share the watcher recovery loop-protection budget
RandomByte Jul 21, 2026
a941512
docs(project): Update incremental build skill
RandomByte Jul 24, 2026
2a1a945
refactor(server): Destroy the BuildServer when middleware assembly fails
RandomByte Jul 24, 2026
b45475e
refactor(server): Document the serveMiddleware embedding contract
RandomByte Jul 24, 2026
acaf426
refactor(server): Group the serving internals under a serve/ namespace
RandomByte Jul 24, 2026
baf2e27
refactor: Align project-change APIs introduced on this branch
RandomByte Jul 24, 2026
c36cb57
refactor: Rename the pre-existing ui5.project-resolved signal
RandomByte Jul 24, 2026
22987e5
test: Stabilize watcher tests across platforms
RandomByte Jul 27, 2026
667c5bb
docs(project): Avoid JSDoc tag parsing in watcher docs
RandomByte Jul 27, 2026
758f42b
refactor(project): Add reader suspend mode for definition changes
RandomByte Jul 28, 2026
4b422ee
refactor(project): Add project graph settler for degraded recovery
RandomByte Jul 28, 2026
26764b8
refactor(server): Recover degraded servers by converging the graph
RandomByte Jul 28, 2026
557007e
refactor(logger): Report a degraded server state on a failed re-resolve
RandomByte Jul 28, 2026
6fdc32d
docs(project): Document degraded recovery convergence
RandomByte Jul 28, 2026
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
44 changes: 40 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