diff --git a/+labkit/+app/+diagnostic/Options.m b/+labkit/+app/+diagnostic/Options.m deleted file mode 100644 index d7a16e2d1..000000000 --- a/+labkit/+app/+diagnostic/Options.m +++ /dev/null @@ -1,86 +0,0 @@ -classdef (Sealed) Options - %OPTIONS Configure one App SDK diagnostic session. - % - % Usage: - % options = labkit.app.diagnostic.Options() - % options = labkit.app.diagnostic.Options(Name=Value) - % - % Description: - % Options selects standard or verbose runtime recording and whether a - % Definition should build its declared anonymous synthetic sample. - % Omitting Diagnostics from Definition.launch is equivalent to the - % default standard options. This value never exposes a runtime, - % recorder, figure registry, or callback transport. - % - % Optional Name-Value Arguments: - % Level - "standard" or "verbose". Standard keeps a bounded in-memory - % diagnostic history; verbose additionally writes structured - % artifacts when ArtifactFolder is nonempty. Default: "standard". - % ArtifactFolder - Scalar diagnostic-session folder. Empty keeps - % recording in memory only. Default: "". - % Sample - "none" or "synthetic". Synthetic requires the Definition's - % BuildDebugSample contract. Default: "none". - % - % Outputs: - % options - Immutable diagnostic configuration. - % - % Errors: - % labkit:app:contract:UnknownArgument - An option is unknown, - % duplicated, or unpaired. - % labkit:app:contract:InvalidValue - A supplied value is malformed or - % outside its documented legal set. - % - % Example: - % options = labkit.app.diagnostic.Options(Level="verbose"); - % assert(options.Level == "verbose") - % - % See also labkit.app.Definition, - % labkit.app.diagnostic.SampleContext, - % labkit.app.diagnostic.SamplePack - - properties (SetAccess = immutable) - Level (1, 1) string - ArtifactFolder (1, 1) string - Sample (1, 1) string - end - - methods - function obj = Options(varargin) - names = ["Level", "ArtifactFolder", "Sample"]; - options = labkit.app.internal.OptionParser.parse( ... - "labkit.app.diagnostic.Options", names, varargin{:}); - obj.Level = oneOf(optionValue( ... - options, "Level", "standard"), ... - ["standard", "verbose"], "Level"); - obj.ArtifactFolder = scalarText(optionValue( ... - options, "ArtifactFolder", ""), "ArtifactFolder"); - obj.Sample = oneOf(optionValue( ... - options, "Sample", "none"), ... - ["none", "synthetic"], "Sample"); - end - end -end - -function value = oneOf(value, legal, name) -value = scalarText(value, name); -if ~any(value == legal) - error("labkit:app:contract:InvalidValue", ... - "Diagnostic Options %s must be %s.", ... - name, strjoin(legal, " or ")); -end -end - -function value = scalarText(value, name) -if ~(ischar(value) || (isstring(value) && isscalar(value))) - error("labkit:app:contract:InvalidValue", ... - "Diagnostic Options %s must be scalar text.", name); -end -value = string(value); -end - -function value = optionValue(options, name, defaultValue) -value = defaultValue; -if isfield(options, name) - value = options.(name); -end -end diff --git a/+labkit/+app/+interaction/rectangle.m b/+labkit/+app/+interaction/rectangle.m index d5ea0c445..4c252a802 100644 --- a/+labkit/+app/+interaction/rectangle.m +++ b/+labkit/+app/+interaction/rectangle.m @@ -17,8 +17,10 @@ % Style - Scalar visual-option struct. Default: struct(). % Instruction - Scalar guidance text. Default: "". % ViewportPolicy - "preserve" or "fit". Default: "preserve". -% OnBackgroundPressed - Optional callback -% state = callback(state,point,context). Default: []. +% OnBackgroundPressed - Optional point callback +% state = callback(state,point,context). It receives clicks on blank +% plot space and clicks on the rectangle that do not move it. +% Default: []. % % Outputs: % spec - Immutable interaction declaration. diff --git a/+labkit/+app/+internal/+launcher/dispatch.m b/+labkit/+app/+internal/+launcher/dispatch.m new file mode 100644 index 000000000..169921e29 --- /dev/null +++ b/+labkit/+app/+internal/+launcher/dispatch.m @@ -0,0 +1,984 @@ +function varargout = dispatch(root, varargin) +%DISPATCH Own the installed LabKit launcher composition and entry routing. +% Private capability. The root launcher owns repair only. + + [mode, modeArgs] = parseMode(varargin); + switch mode + case "list" + varargout = {appCatalogTable(discoverApps(root))}; + case "documentation" + varargout = {documentationPage(root, modeArgs)}; + case "version" + varargout = {launcherVersion()}; + otherwise + if nargout > 1 + error("labkit:app:internal:launcher:TooManyOutputs", ... + "Launcher dispatch returns at most one figure."); + end + fig = createLauncher(root); + if nargout == 1 + varargout = {fig}; + end + end +end + +function [mode, appCommand] = parseMode(args) +mode = "gui"; +appCommand = ""; +if isempty(args) + return; +end +if numel(args) == 2 && isTextScalar(args{1}) && strcmpi(string(args{1}), "documentation") + appCommand = string(args{2}); + if ~isTextScalar(appCommand) || strlength(strtrim(appCommand)) == 0 + error("labkit:app:internal:launcher:InvalidInput", ... + "Documentation mode requires one nonempty app command."); + end + mode = "documentation"; + return; +end +if numel(args) ~= 1 || ~isTextScalar(args{1}) + error("labkit:app:internal:launcher:InvalidInput", ... + "Use no input, list, version, or documentation plus one app command."); +end +mode = lower(string(args{1})); +if ~ismember(mode, ["list", "version"]) + error("labkit:app:internal:launcher:InvalidInput", "Unsupported launcher mode: %s", mode); +end +end + +function fig = createLauncher(root) +panelFontSize = 15; +tableFontSize = 12; +version = launcherVersion(); +position = defaultLauncherPosition(); +figArgs = { ... + "Name", version.displayName + " v" + version.version + ... + " (" + version.updated + ")", ... + "Tag", "labkitLauncher", ... + "Position", position, ... + "AutoResizeChildren", "off", ... + "Color", [0.97 0.98 0.99]}; +if launcherGuiTestMode() == "hidden" + figArgs = [figArgs, {"Visible", "off"}]; +end +close(findall(groot, "Type", "figure", "Tag", "labkitLauncher")); +fig = uifigure(figArgs{:}); +rootPanel = uipanel(fig, ... + "BorderType", "none", ... + "BackgroundColor", fig.Color, ... + "Units", "pixels", ... + "Position", [1 1 position(3:4)]); +main = uigridlayout(rootPanel, [1 3]); +leftWidth = launcherControlWidth(position(3)); +main.ColumnWidth = {leftWidth, 5, "1x"}; +main.RowHeight = {"1x"}; +main.Padding = [6 6 6 6]; +main.ColumnSpacing = 0; + +left = uipanel(main, "Title", "Launcher", "FontSize", panelFontSize); +left.Layout.Column = 1; +divider = uipanel(main, "BorderType", "none", ... + "BackgroundColor", [0.78 0.80 0.82]); +divider.Layout.Column = 2; +right = uipanel(main, "Title", "Applications", "FontSize", panelFontSize); +right.Layout.Column = 3; + +controls = uigridlayout(left, [5 1]); +controls.RowHeight = {108, 72, 108, 72, "1x"}; +controls.Padding = [6 6 6 6]; +controls.RowSpacing = 6; + +runPanel = uipanel(controls, "Title", "Run Apps"); +runPanel.Layout.Row = 1; +runGrid = uigridlayout(runPanel, [2 2]); +runGrid.RowHeight = {"1x", "1x"}; +runGrid.ColumnWidth = {"1x", "1x"}; +runGrid.Padding = [5 5 5 5]; +runGrid.RowSpacing = 5; +runGrid.ColumnSpacing = 6; +openButton = uibutton(runGrid, "Text", "Open Selected App"); +openButton.Layout.Row = 1; +openButton.Layout.Column = [1 2]; +refreshButton = uibutton(runGrid, "Text", "Refresh App List"); +refreshButton.Layout.Row = 2; +refreshButton.Layout.Column = 1; +appDocsButton = uibutton(runGrid, "Text", "Documentation and History"); +appDocsButton.Layout.Row = 2; +appDocsButton.Layout.Column = 2; +appDocsButton.Tooltip = ... + "Open the generated documentation page for the selected app."; + +versionPanel = uipanel(controls, "Title", "Versions and Install"); +versionPanel.Layout.Row = 2; +versionGrid = uigridlayout(versionPanel, [1 3]); +versionGrid.ColumnWidth = {"1x", "1x", "1x"}; +versionGrid.Padding = [5 5 5 5]; +versionGrid.ColumnSpacing = 6; +latestButton = uibutton(versionGrid, "Text", "Latest"); +releaseButton = uibutton(versionGrid, "Text", "Release"); +versionsButton = uibutton(versionGrid, "Text", "Versions"); +latestButton.Tooltip = "Download and apply the latest main branch ZIP."; +releaseButton.Tooltip = "Download and apply the latest stable release."; +versionsButton.Tooltip = ... + "Choose a recent release, tag, or main-branch commit."; + +maintenancePanel = uipanel(controls, ... + "Title", "Development and Maintenance"); +maintenancePanel.Layout.Row = 3; +maintenanceGrid = uigridlayout(maintenancePanel, [2 2]); +maintenanceGrid.ColumnWidth = {"1x", "1x"}; +maintenanceGrid.RowHeight = {"1x", "1x"}; +maintenanceGrid.Padding = [5 5 5 5]; +maintenanceGrid.RowSpacing = 5; +maintenanceGrid.ColumnSpacing = 6; +docsToolButton = uibutton(maintenanceGrid, "Text", "Update Documentation"); +codeButton = uibutton(maintenanceGrid, "Text", "Run Code Analyzer"); +profileButton = uibutton(maintenanceGrid, "Text", "Profile Selected App"); +cleanButton = uibutton(maintenanceGrid, "Text", "Clean Artifacts"); +docsToolButton.Tooltip = ... + "Rebuild the documentation site from docs and public MATLAB help."; +codeButton.Tooltip = ... + "Run MATLAB Code Analyzer and write the repository report."; +profileButton.Tooltip = ... + "Profile the selected app and save its report without opening a browser."; +cleanButton.Tooltip = ... + "Remove generated artifacts through the maintenance tool."; + +packagePanel = uipanel(controls, "Title", "Package and Publish"); +packagePanel.Layout.Row = 4; +packageGrid = uigridlayout(packagePanel, [1 2]); +packageGrid.ColumnWidth = {"1x", "1x"}; +packageGrid.Padding = [5 5 5 5]; +packageGrid.ColumnSpacing = 6; +packageButton = uibutton(packageGrid, "Text", "Package Checked"); +pcodeButton = uibutton(packageGrid, "Text", "Checked P-code"); +packageButton.Tooltip = ... + "Create one standalone source package containing every checked app."; +pcodeButton.Tooltip = ... + "Create the same multi-app package with MATLAB code encoded as P-code."; + +status = uitextarea(controls, "Editable", "off", "Value", "Ready."); +status.Layout.Row = 5; +tableGrid = uigridlayout(right, [1 1]); +tableGrid.Padding = [4 4 4 4]; +appTable = uitable(tableGrid, ... + "ColumnName", { ... + "Package", "App", "Family", "Version", "Access", "Updated"}, ... + "ColumnEditable", [true false false false false false], ... + "RowName", {}, ... + "FontSize", tableFontSize); +if isprop(appTable, "ColumnFormat") + appTable.ColumnFormat = { ... + 'logical', 'char', 'char', 'char', 'char', 'char'}; +end +appTable.ColumnWidth = launcherTableWidths(position(3), leftWidth); +configureTable(appTable, @selectRow, @doubleClickRow); +appTable.CellEditCallback = @changePackageSelection; + +setappdata(groot, "labkitFigureStudioLauncher", ... + @(ax) launchFigureStudioFromAxes(root, ax)); +view = struct( ... + "figure", fig, ... + "controls", struct( ... + "selectedDetails", struct("textArea", status), ... + "statusLine", struct("textArea", status), ... + "appTable", struct("table", appTable))); +setappdata(fig, "labkitLauncherView", view); +state = struct( ... + "apps", emptyApps(), ... + "selected", 1, ... + "checkedCommands", strings(0, 1), ... + "status", "Loading app list...", ... + "busy", false, ... + "tools", launcherToolAvailability(root)); + +openButton.ButtonPushedFcn = @(~, ~) launchSelected(); +refreshButton.ButtonPushedFcn = @(~, ~) refreshApps(); +appDocsButton.ButtonPushedFcn = @(~, ~) openDocumentation(); +latestButton.ButtonPushedFcn = @(~, ~) manageVersion("main"); +releaseButton.ButtonPushedFcn = @(~, ~) manageVersion("stable"); +versionsButton.ButtonPushedFcn = @(~, ~) manageVersion("browse"); +cleanButton.ButtonPushedFcn = @(~, ~) runMaintenance("clean"); +docsToolButton.ButtonPushedFcn = @(~, ~) runMaintenance("docs"); +codeButton.ButtonPushedFcn = @(~, ~) runMaintenance("codecheck"); +profileButton.ButtonPushedFcn = @(~, ~) runMaintenance("profile"); +packageButton.ButtonPushedFcn = @(~, ~) packageChecked("source"); +pcodeButton.ButtonPushedFcn = @(~, ~) packageChecked("pcode"); +refreshApps(); +fig.SizeChangedFcn = @(~, ~) resizeLauncher(); +resizeLauncher(); + + function refreshApps() + if state.busy + return; + end + selectedCommand = currentSelectedCommand(); + beginAction("Refreshing app list..."); + try + state.apps = discoverApps(root); + state.tools = launcherToolAvailability(root); + state.checkedCommands = retainedCommands( ... + state.apps, state.checkedCommands); + state.selected = appRowByCommand(state.apps, selectedCommand); + appTable.Data = launcherRows(state.apps, state.checkedCommands); + setStatus(appAvailabilityStatus(state.apps)); + catch cause + setStatus("Refresh app list failed: " + failureText(cause)); + end + endAction(); + end + + function resizeLauncher() + if ~isvalid(fig) || ~isvalid(appTable) + return; + end + figureWidth = fig.Position(3); + rootPanel.Position = [1 1 fig.Position(3:4)]; + resizedControlWidth = launcherControlWidth(figureWidth); + main.ColumnWidth = {resizedControlWidth, 5, "1x"}; + appTable.ColumnWidth = launcherTableWidths( ... + figureWidth, resizedControlWidth); + end + + function selectRow(~, event) + row = eventRow(event); + if ~isnan(row) + state.selected = row; + updateInfo(); + end + end + + function doubleClickRow(~, event) + row = eventRow(event); + if ~isnan(row) + state.selected = row; + end + launchSelected(); + end + + function changePackageSelection(~, event) + if isempty(event.Indices) || isempty(state.apps) + return; + end + row = event.Indices(1, 1); + if row < 1 || row > numel(state.apps) + return; + end + command = string(state.apps(row).command); + state.checkedCommands(state.checkedCommands == command) = []; + if logical(event.NewData) + state.checkedCommands(end + 1, 1) = command; + end + updateInfo(); + end + + function launchSelected() + if state.busy || isempty(state.apps) + return; + end + app = selectedApp(); + beginAction("Opening " + app.command + "..."); + try + addPathIfMissing(app.folder, "-end"); + feval(app.command); + setStatus("Opened " + app.command + "."); + catch cause + if isStructuralStartupFailure(cause) + setStatus([ ... + "Could not start " + app.command + ": " + ... + failureText(cause) + "The installation may be incomplete. Run " + ... + "labkit_launcher(""repair"") to reinstall." + ]); + else + setStatus("App " + app.command + " reported: " + ... + failureText(cause)); + end + end + endAction(); + end + + function openDocumentation() + if state.busy || isempty(state.apps) + return; + end + app = selectedApp(); + beginAction("Opening documentation for " + app.command + "..."); + try + page = documentationPage(root, app.command); + if launcherGuiTestMode() ~= "hidden" + web(page, "-browser"); + end + setStatus("Opened documentation for " + app.command + "."); + catch cause + setStatus("Documentation unavailable: " + failureText(cause)); + end + endAction(); + end + + function manageVersion(mode) + if state.busy + return; + end + beginAction("Preparing LabKit version tools..."); + try + if mode == "browse" + callTool(root, fullfile("tools", "deployment"), ... + "manageLabKitVersions", root, mode, ... + "ProgressFcn", @reportProgress); + setStatus("Opened LabKit Version Manager."); + else + result = callTool(root, fullfile("tools", "deployment"), ... + "manageLabKitVersions", root, mode, ... + "ProgressFcn", @reportProgress); + setStatus(result.message); + end + catch cause + setStatus("Version action failed: " + failureText(cause)); + end + endAction(); + end + + function runMaintenance(kind) + if state.busy + return; + end + beginAction("Running " + kind + "..."); + try + switch kind + case "clean" + result = callTool(root, fullfile("tools", "maintenance"), ... + "cleanLabKitArtifacts", root, ... + "ProgressFcn", @reportProgress); + setStatus("Clean Artifacts complete: " + ... + string(result.removedCount) + " target(s) removed."); + case "docs" + callTool(root, fullfile("tools", "docs"), ... + "renderLabKitDocs", fullfile(root, "docs"), ... + fullfile(root, "site")); + setStatus("Documentation site updated."); + case "codecheck" + callTool(root, fullfile("tools", "codecheck"), ... + "runCodecheckReport", root, ... + "ProgressFcn", @reportProgress); + setStatus("Code Analyzer report completed."); + case "profile" + app = selectedApp(); + callTool(root, fullfile("tools", "profiling"), ... + "profileLabKitTarget", app.command, [], ... + "OpenReport", false, "WaitForGuiClose", false); + setStatus("Performance profile completed for " + ... + app.command + "."); + end + catch cause + setStatus("Tool failed: " + failureText(cause)); + end + endAction(); + end + + function packageChecked(codeFormat) + if state.busy + return; + end + apps = checkedApps(state.apps, state.checkedCommands); + if isempty(apps) + setStatus("Check one or more apps in the Package column first."); + return; + end + commands = string({apps.command}); + beginAction("Packaging " + packageSummary(commands) + "..."); + try + result = callTool(root, fullfile("tools", "deployment"), ... + "packageLabKitApp", commands, [], ... + "Root", root, "CodeFormat", codeFormat, ... + "ProgressFcn", @reportProgress); + setStatus("Packaged " + packageSummary(commands) + ... + " at " + string(result.zipFile) + "."); + catch cause + setStatus("Package failed: " + failureText(cause)); + end + endAction(); + end + + function reportProgress(message, ~) + setStatus(message); + drawnow limitrate; + end + + function app = selectedApp() + if isempty(state.apps) + error("labkit:app:internal:launcher:NoAppSelected", ... + "Select an app before using this action."); + end + row = min(max(state.selected, 1), numel(state.apps)); + app = state.apps(row); + end + + function command = currentSelectedCommand() + command = ""; + if ~isempty(state.apps) + command = string(selectedApp().command); + end + end + + function beginAction(message) + state.busy = true; + setControlsEnabled(false); + setStatus(message); + end + + function endAction() + state.busy = false; + setControlsEnabled(true); + updateInfo(); + end + + function setControlsEnabled(enabled) + value = matlab.lang.OnOffSwitchState(enabled); + refreshButton.Enable = value; + latestButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.version); + releaseButton.Enable = latestButton.Enable; + versionsButton.Enable = latestButton.Enable; + cleanButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.clean); + docsToolButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.docs); + codeButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && state.tools.codecheck); + hasApps = ~isempty(state.apps); + openButton.Enable = matlab.lang.OnOffSwitchState(enabled && hasApps); + appDocsButton.Enable = openButton.Enable; + profileButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && hasApps && state.tools.profile); + packageButton.Enable = matlab.lang.OnOffSwitchState( ... + enabled && hasApps && state.tools.package); + pcodeButton.Enable = packageButton.Enable; + end + + function setStatus(message) + state.status = string(message); + updateInfo(); + end + + function updateInfo() + details = selectedAppDetails(state.apps, state.selected); + details(end + 1, 1) = "Checked for package: " + ... + numel(state.checkedCommands) + " app(s)"; + status.Value = [ ... + "Status: " + state.status + "" + details + ]; + end +end + +function info = launcherVersion() +info = struct( ... + "name", "labkit_launcher", ... + "displayName", "LabKit App Launcher", ... + "version", "1.7.0", ... + "updated", "2026-07-26"); +end + +function position = defaultLauncherPosition() +screen = double(get(groot, "ScreenSize")); +screenWidth = screen(3); +screenHeight = screen(4); +width = min(screenWidth, max(800, min(1280, screenWidth - 80))); +height = min(screenHeight, max(560, min(720, screenHeight - 120))); +x = screen(1) + max(0, (screenWidth - width) / 2); +y = screen(2) + max(0, (screenHeight - height) / 2); +position = round([x y width height]); +end + +function width = launcherControlWidth(figureWidth) +width = min(390, max(350, round(double(figureWidth) * 0.29))); +end + +function widths = launcherTableWidths(figureWidth, controlWidth) +tableWidth = max(640, double(figureWidth) - double(controlWidth) - 36); +minimum = [62 180 120 70 72 90]; +preferred = [72 240 150 78 80 100]; +if tableWidth <= sum(minimum) + values = minimum; +elseif tableWidth < sum(preferred) + fraction = (tableWidth - sum(minimum)) / ... + (sum(preferred) - sum(minimum)); + values = minimum + fraction .* (preferred - minimum); +else + extra = tableWidth - sum(preferred); + values = preferred + extra .* [0 0.60 0.25 0 0 0.15]; +end +widths = num2cell(round(values)); +end + +function configureTable(tableHandle, selectionCallback, doubleClickCallback) +if isprop(tableHandle, "SelectionChangedFcn") + tableHandle.SelectionChangedFcn = selectionCallback; +else + tableHandle.CellSelectionCallback = selectionCallback; +end +if isprop(tableHandle, "SelectionType") + tableHandle.SelectionType = "row"; +end +if isprop(tableHandle, "DoubleClickedFcn") + tableHandle.DoubleClickedFcn = doubleClickCallback; +elseif isprop(tableHandle, "CellDoubleClickedFcn") + tableHandle.CellDoubleClickedFcn = doubleClickCallback; +end +end + +function row = eventRow(event) +row = NaN; +if isprop(event, "Indices") && ~isempty(event.Indices) + row = event.Indices(1, 1); +elseif isprop(event, "Selection") && ~isempty(event.Selection) + row = event.Selection(1, 1); +elseif isstruct(event) && isfield(event, "Indices") && ~isempty(event.Indices) + row = event.Indices(1, 1); +elseif isstruct(event) && isfield(event, "Selection") && ~isempty(event.Selection) + row = event.Selection(1, 1); +end +end + +function row = appRowByCommand(apps, command) +row = 1; +if isempty(apps) || strlength(string(command)) == 0 + return; +end +match = find(string({apps.command}) == string(command), 1); +if ~isempty(match) + row = match; +end +end + +function rows = launcherRows(apps, checkedCommands) +rows = cell(numel(apps), 6); +checked = ismember(string({apps.command}), string(checkedCommands)); +for index = 1:numel(apps) + rows(index, :) = { ... + checked(index), ... + char(apps(index).name), ... + char(apps(index).family), ... + char(apps(index).version), ... + char(apps(index).visibility), ... + char(apps(index).updated)}; +end +end + +function commands = retainedCommands(apps, commands) +available = string({apps.command}); +commands = string(commands(:)); +commands = commands(ismember(commands, available)); +end + +function apps = checkedApps(apps, commands) +if isempty(apps) + return; +end +apps = apps(ismember(string({apps.command}), string(commands))); +end + +function summary = packageSummary(commands) +if isscalar(commands) + summary = string(commands); +else + summary = string(numel(commands)) + " checked apps"; +end +end + +function details = selectedAppDetails(apps, selected) +if isempty(apps) + details = [ + "No app entry points found." + "Run labkit_launcher(""repair"") if the installation is incomplete." + ]; + return; +end +row = min(max(selected, 1), numel(apps)); +app = apps(row); +details = [ + string(app.name) + "Family: " + string(app.family) + "Visibility: " + string(app.visibility) + "Version: " + string(app.version) + "Updated: " + string(app.updated) + "Command: " + string(app.command) + "Path: " + string(app.folder) + ]; +end + +function message = appAvailabilityStatus(apps) +if isempty(apps) + message = "No app entry points found. Run labkit_launcher(""repair"") " + ... + "if the installation is incomplete."; +else + message = string(numel(apps)) + " app entry point(s) available."; +end +end + +function tools = launcherToolAvailability(root) +tools = struct( ... + "version", toolExists(root, "deployment", "manageLabKitVersions"), ... + "clean", toolExists(root, "maintenance", "cleanLabKitArtifacts"), ... + "docs", toolExists(root, "docs", "renderLabKitDocs"), ... + "codecheck", toolExists(root, "codecheck", "runCodecheckReport"), ... + "profile", toolExists(root, "profiling", "profileLabKitTarget"), ... + "package", toolExists(root, "deployment", "packageLabKitApp")); +end + +function tf = toolExists(root, area, name) +base = fullfile(root, "tools", area, name); +tf = exist(base + ".m", "file") == 2 || exist(base + ".p", "file") == 2; +end + +function addPathIfMissing(folder, varargin) +if exist(folder, "dir") == 7 && ~pathContains(folder) + addpath(folder, varargin{:}); +end +end + +function text = failureText(cause) +text = string(cause.message); +if strlength(string(cause.identifier)) > 0 + text = string(cause.identifier) + ": " + text; +end +end + +function varargout = callTool(root, relativeFolder, name, varargin) +folder = fullfile(root, relativeFolder); +if exist(fullfile(folder, name + ".m"), "file") ~= 2 && ... + exist(fullfile(folder, name + ".p"), "file") ~= 2 + error("labkit:app:internal:launcher:ToolUnavailable", "Tool is unavailable: %s", name); +end +added = ~pathContains(folder); +if added + addpath(folder, "-begin"); + cleanup = onCleanup(@() rmpath(folder)); +end +if nargout > 0 + [varargout{1:nargout}] = feval(name, varargin{:}); +else + feval(name, varargin{:}); +end +clear cleanup +end + +function apps = discoverApps(root) +apps = emptyApps(); +roots = [string(fullfile(root, "apps")); privateAppRoots(root)]; +entrySets = cell(numel(roots), 1); +entryCount = 0; +for rootIndex = 1:numel(roots) + entrySets{rootIndex} = appEntryFiles(roots(rootIndex)); + entryCount = entryCount + numel(entrySets{rootIndex}); +end +records = cell(entryCount, 1); +recordCount = 0; +for rootIndex = 1:numel(roots) + appRoot = roots(rootIndex); + if exist(appRoot, "dir") ~= 7 + continue; + end + entries = entrySets{rootIndex}; + for entryIndex = 1:numel(entries) + entry = entries(entryIndex); + if isHiddenImplementationPath(relativePath(appRoot, entry.folder)) + continue; + end + [~, command] = fileparts(entry.name); + filepath = fullfile(entry.folder, entry.name); + metadata = appVersionInfo(entry.folder); + family = familyName(appRoot, entry.folder); + if strlength(metadata.family) > 0 + family = metadata.family; + end + name = displayName(command); + if strlength(metadata.displayName) > 0 + name = metadata.displayName; + end + app = struct("command", scalarText(command, "command"), ... + "folder", scalarText(entry.folder, "folder"), ... + "relativePath", scalarText(relativePath(root, filepath), "relativePath"), ... + "family", scalarText(family, "family"), ... + "name", scalarText(name, "name"), ... + "description", scalarText(appDescription(filepath, command), "description"), ... + "visibility", scalarText(visibilityFor(root, entry.folder), "visibility"), ... + "version", scalarText(metadata.version, "version"), ... + "updated", scalarText(metadata.updated, "updated")); + recordCount = recordCount + 1; + records{recordCount} = app; + end +end +if recordCount > 0 + apps = [records{1:recordCount}]; +end +if ~isempty(apps) + keys = [reshape(string({apps.visibility}) == "private", [], 1), ... + reshape(string({apps.family}), [], 1), reshape(string({apps.name}), [], 1)]; + [~, order] = sortrows(keys); + apps = apps(order); +end +end + +function apps = emptyApps() +apps = struct("command", {}, "folder", {}, "relativePath", {}, ... + "family", {}, "name", {}, "description", {}, "visibility", {}, ... + "version", {}, "updated", {}); +end + +function entries = appEntryFiles(appRoot) +entries = [dir(fullfile(char(appRoot), "**", "labkit_*_app.m")); ... + dir(fullfile(char(appRoot), "**", "labkit_*_app.p"))]; +entries = entries(~[entries.isdir]); +if isempty(entries), return; end +count = numel(entries); +paths = strings(count, 1); commands = strings(count, 1); isSource = false(count, 1); +for index = 1:count + paths(index) = string(fullfile(entries(index).folder, entries(index).name)); + [~, commands(index), extension] = fileparts(paths(index)); + isSource(index) = string(extension) == ".m"; +end +[~, order] = sortrows([commands, string(~isSource), paths]); +entries = entries(order); commands = string(commands(order)); +[~, keep] = unique(commands, "stable"); +entries = entries(keep); +end + +function roots = privateAppRoots(root) +parts = split(string(getenv("LABKIT_PRIVATE_APP_ROOTS")), pathsep); +localCandidates = strings(numel(parts) + 1, 1); +candidateCount = 0; +localRoot = fullfile(root, "private_apps", "apps"); +if exist(localRoot, "dir") == 7 + candidateCount = candidateCount + 1; localCandidates(candidateCount) = localRoot; +end +environmentRoots = string(getenv("LABKIT_PRIVATE_APP_ROOTS")); +if strlength(environmentRoots) == 0 + roots = unique(localCandidates(1:candidateCount), "stable"); + return; +end +for part = split(environmentRoots, pathsep).' + candidate = string(part); + if ~endsWith(replace(candidate, "\\", "/"), "/apps") + candidate = fullfile(candidate, "apps"); + end + if exist(candidate, "dir") == 7 + candidateCount = candidateCount + 1; + localCandidates(candidateCount) = candidate; + end +end +roots = unique(localCandidates(1:candidateCount), "stable"); +end + +function value = visibilityFor(root, folder) +if isDescendantPath(folder, fullfile(root, "apps")) + value = "public"; +else + value = "private"; +end +end + +function value = familyName(appRoot, folder) +parts = split(relativePath(appRoot, folder), "/"); +if isempty(parts) || strlength(parts(1)) == 0 + value = "Other"; +else + value = displayToken(parts(1)); +end +end + +function tf = isHiddenImplementationPath(relative) +parts = split(string(relative), "/"); +tf = any(parts == "private" | startsWith(parts, "+")); +end + +function relative = relativePath(root, folder) +root = string(root); folder = string(folder); +prefix = root + filesep; +if startsWith(folder, prefix, "IgnoreCase", ispc) + relative = extractAfter(folder, strlength(prefix)); +else + relative = folder; +end +relative = replace(relative, string(filesep), "/"); +end + +function tf = isDescendantPath(folder, ancestor) +folder = lower(replace(string(folder), string(filesep), "/")); +ancestor = lower(replace(string(ancestor), string(filesep), "/")); +tf = folder == ancestor || startsWith(folder, ancestor + "/"); +end + +function value = displayName(command) +value = erase(string(command), "labkit_"); +value = erase(value, "_app"); +value = displayToken(value); +end + +function value = displayToken(value) +words = split(replace(string(value), "_", " ")); +for index = 1:numel(words) + if lower(words(index)) == "labkit" + words(index) = "LabKit"; + else + words(index) = upper(extractBefore(words(index), 2)) + extractAfter(words(index), 1); + end +end +value = strjoin(cellstr(words), " "); +end + +function description = appDescription(filepath, command) +description = ""; +try + text = fileread(filepath); +catch + return; +end +lines = splitlines(string(text)); +prefix = "%" + upper(string(command)); +for index = 1:min(numel(lines), 20) + line = strtrim(lines(index)); + if startsWith(line, prefix) + description = strtrim(erase(extractAfter( ... + line, strlength(prefix)), "-")); + return; + elseif startsWith(line, "%") + cleaned = strtrim(extractAfter(line, 1)); + if strlength(cleaned) > 0 && ~startsWith(cleaned, "Usage") + description = cleaned; + return; + end + end +end +end + +function catalog = appCatalogTable(apps) +count = numel(apps); +commandColumn = strings(count, 1); displayNameColumn = strings(count, 1); +familyColumn = strings(count, 1); visibilityColumn = strings(count, 1); +folderColumn = strings(count, 1); relativePathColumn = strings(count, 1); +descriptionColumn = strings(count, 1); versionColumn = strings(count, 1); +updatedColumn = strings(count, 1); +for index = 1:count + commandColumn(index) = apps(index).command; + displayNameColumn(index) = apps(index).name; + familyColumn(index) = apps(index).family; + visibilityColumn(index) = apps(index).visibility; + folderColumn(index) = apps(index).folder; + relativePathColumn(index) = apps(index).relativePath; + descriptionColumn(index) = apps(index).description; + versionColumn(index) = apps(index).version; + updatedColumn(index) = apps(index).updated; +end +catalog = table(commandColumn, displayNameColumn, familyColumn, ... + visibilityColumn, folderColumn, relativePathColumn, descriptionColumn, ... + versionColumn, updatedColumn, ... + 'VariableNames', {'Command', 'DisplayName', 'Family', 'Visibility', ... + 'Folder', 'RelativePath', 'Description', 'Version', 'Updated'}); +end + +function page = documentationPage(root, command) +apps = discoverApps(root); +match = find(string({apps.command}) == string(command), 1); +if isempty(match) || apps(match).visibility ~= "public" + error("labkit:app:internal:launcher:DocumentationUnavailable", ... + "No public documentation page is available for %s.", command); +end +[~, appId] = fileparts(apps(match).folder); +appId = replace(string(appId), "_", "-"); +manuals = dir(fullfile(root, "docs", "apps", "*", appId, "README.md")); +if numel(manuals) ~= 1 + error("labkit:app:internal:launcher:DocumentationUnavailable", ... + "No generated documentation page is available for %s.", command); +end +[~, family] = fileparts(fileparts(manuals(1).folder)); +page = fullfile(root, "site", "apps", family, appId + ".html"); +if exist(page, "file") ~= 2 + error("labkit:app:internal:launcher:DocumentationUnavailable", "Generated documentation is missing for %s.", command); +end +end + +function tf = isStructuralStartupFailure(cause) +id = string(cause.identifier); +tf = startsWith(id, "MATLAB:UndefinedFunction") || ... + startsWith(id, "MATLAB:parse") || startsWith(id, "MATLAB:dispatcher"); +end + +function info = appVersionInfo(folder) +info = struct("version", "", "updated", "", ... + "displayName", "", "family", ""); +definitions = dir(fullfile(folder, "+*", "definition.m")); +if isempty(definitions) + return; +end +try + text = fileread(fullfile(definitions(1).folder, definitions(1).name)); + info.version = literalField(text, "AppVersion"); + info.updated = literalField(text, "Updated"); + info.displayName = literalField(text, "DisplayName"); + info.family = literalField(text, "Family"); +catch +end +end + +function value = literalField(text, field) +value = ""; +patterns = {[char(field) '\s*=\s*"([^"]+)"'], ... + ['"' char(field) '"\s*,\s*"([^"]+)"']}; +for index = 1:numel(patterns) + tokens = regexp(text, patterns{index}, "tokens", "once"); + if ~isempty(tokens) + value = string(tokens{1}); + return; + end +end +end + +function value = scalarText(value, field) +value = string(value); +if ~isscalar(value) + error("labkit:app:internal:launcher:InvalidMetadataShape", ... + "App metadata field %s must be scalar.", field); +end +end + +function tf = pathContains(folder) +entries = string(strsplit(path, pathsep)); +target = normalizePathEntry(folder); +tf = any(normalizePathEntry(entries) == target); +end + +function value = normalizePathEntry(value) +values = string(value); +for index = 1:numel(values) + pathValue = java.nio.file.Paths.get(char(values(index)), javaArray("java.lang.String", 0)); + values(index) = string(pathValue.toAbsolutePath().normalize().toString()); +end +value = values; +if ispc, value = lower(value); end +end + +function launchFigureStudioFromAxes(root, ax) +folder = fullfile(root, "apps", "labkit_core", "figure_studio"); +if exist(folder, "dir") ~= 7 + error("labkit:app:internal:launcher:FigureStudioUnavailable", "Figure Studio is unavailable."); +end +if ~pathContains(folder) + addpath(folder, "-end"); +end +labkit_FigureStudio_app("axes", ax); +end + +function tf = isTextScalar(value) +tf = ischar(value) || (isstring(value) && isscalar(value)); +end + +function mode = launcherGuiTestMode() +mode = "visible"; +if isappdata(groot, "labkitLauncherGuiTestMode") + mode = string(getappdata(groot, "labkitLauncherGuiTestMode")); +end +end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m index 7b04d5c69..3075f7109 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/MatlabPlatformAdapter.m @@ -27,6 +27,8 @@ StartupStarted StartupPanel StartupLabel + LogViewer + TraceCaptureMenu end methods (Access = { ... @@ -105,6 +107,10 @@ function reconcile(obj, previous, view) end function close(obj) + if ~isempty(obj.LogViewer) && isvalid(obj.LogViewer) + obj.LogViewer.close(); + obj.LogViewer = []; + end if ~isempty(obj.InteractionController) obj.InteractionController.delete(); obj.InteractionController = []; @@ -395,6 +401,37 @@ function runUtility(obj, callback) end end + function openSessionLog(obj) + if isempty(obj.LogViewer) || ~isvalid(obj.LogViewer) || ... + ~obj.LogViewer.isOpen() + obj.LogViewer = ... + labkit.app.internal.SessionLogViewer(obj.Runtime); + else + obj.LogViewer.refresh(); + end + obj.LogViewer.show(); + end + + function toggleTraceCapture(obj) + enabled = true; + if ~isempty(obj.TraceCaptureMenu) && ... + isvalid(obj.TraceCaptureMenu) + enabled = string(obj.TraceCaptureMenu.Checked) ~= "on"; + end + obj.Runtime.setTraceCapture(enabled); + if ~isempty(obj.TraceCaptureMenu) && ... + isvalid(obj.TraceCaptureMenu) + if enabled + obj.TraceCaptureMenu.Checked = "on"; + else + obj.TraceCaptureMenu.Checked = "off"; + end + end + if ~isempty(obj.LogViewer) && isvalid(obj.LogViewer) + obj.LogViewer.refresh(); + end + end + function handles = allAxes(obj) values = obj.Axes.values; if isempty(values) diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m index 9f34b271a..0bbf7fc88 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/applyEnabled.m @@ -2,13 +2,9 @@ function applyEnabled(~, component, enabled) % Class-folder implementation of MatlabPlatformAdapter.applyEnabled. value = labkit.app.internal.NativeAdapterValues.onOff(enabled); labkit.app.internal.NativeAdapterValues.setIfProperty(component, "Enable", value); - if isprop(component, "Tag") && strlength(string(component.Tag)) > 0 - figure = ancestor(component, "figure"); - labels = findall(figure, "Tag", ... - char(string(component.Tag) + ".label")); - for k = 1:numel(labels) - labkit.app.internal.NativeAdapterValues.setIfProperty(labels(k), "Enable", value); - end + label = labkit.app.internal.NativeAdapterValues.linkedLabel(component); + if ~isempty(label) + labkit.app.internal.NativeAdapterValues.setIfProperty(label, "Enable", value); end mode = labkit.app.internal.NativeAdapterValues.linkedPlotMode(component); if ~isempty(mode) diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m index 1eaaa9d98..ffe01fe2a 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/createComponent.m @@ -46,9 +46,8 @@ component = obj.createFilePanel(node, parent); case "dataTable" component = obj.createDataTable(node, parent); - case {"logPanel", "statusPanel"} - component = obj.createTextPanel( ... - node, parent, config, node.Kind == "logPanel"); + case "statusPanel" + component = obj.createTextPanel(node, parent, config, false); case "plotArea" plotTitle = config.Title; owner = obj.owningNode(node.Id); diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m index bfae8addb..e54774d1d 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/installUtilityMenus.m @@ -2,6 +2,31 @@ function installUtilityMenus(obj) % Class-folder implementation of MatlabPlatformAdapter.installUtilityMenus. toolsMenu = uimenu(obj.Figure, Text="Tools", ... Tag="labkitAppUtilityToolsMenu"); + diagnosticsMenu = uimenu(toolsMenu, Text="Diagnostics", ... + Tag="labkitAppUtilityDiagnosticsMenu"); + uimenu(diagnosticsMenu, Text="Open Session Log...", ... + Tag="labkitAppUtilitySessionLog", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.openSessionLog())); + obj.TraceCaptureMenu = uimenu( ... + diagnosticsMenu, Text="Trace Capture", Checked="off", ... + Tag="labkitAppUtilityTraceCapture", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.toggleTraceCapture())); + uimenu(diagnosticsMenu, Text="Export Diagnostic Bundle...", ... + Tag="labkitAppUtilityExportDiagnostics", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.Runtime.exportDiagnosticBundleInteractive())); + + if obj.Runtime.supportsSyntheticInputs() + developerMenu = uimenu(toolsMenu, Text="Developer Tools", ... + Tag="labkitAppUtilityDeveloperMenu"); + uimenu(developerMenu, Text="Generate Synthetic Inputs...", ... + Tag="labkitAppUtilitySyntheticInputs", ... + MenuSelectedFcn=@(~, ~) obj.runUtility( ... + @() obj.Runtime.generateSyntheticInputsInteractive())); + end + plotMenu = uimenu(toolsMenu, Text="Plots", ... Tag="labkitAppUtilityPlotMenu"); uimenu(plotMenu, Text="Pop out all plots", ... diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m index f143f358c..1ee571c5b 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/isGrowableTabChild.m @@ -9,6 +9,5 @@ node.Configuration.MaxFiles > 1; return end - tf = any(node.Kind == [ ... - "plotArea", "dataTable", "logPanel", "statusPanel"]); + tf = any(node.Kind == ["plotArea", "dataTable", "statusPanel"]); end diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m index fad14b387..1fe9901a0 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/preferredRowHeight.m @@ -15,8 +15,6 @@ end case "dataTable" height = policy.TableHeight; - case "logPanel" - height = policy.LogHeight; case "statusPanel" if node.Id == "applicationUsage" height = policy.UsageHeight; diff --git a/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m b/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m index 0ffefa716..8e69aff83 100644 --- a/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m +++ b/+labkit/+app/+internal/@MatlabPlatformAdapter/sectionDrawsOwnTitle.m @@ -6,6 +6,5 @@ end child = obj.node(node.ChildIds(1)); tf = ~any(child.Kind == [ ... - "plotArea", "dataTable", "logPanel", ... - "statusPanel", "fileList"]); + "plotArea", "dataTable", "statusPanel", "fileList"]); end diff --git a/+labkit/+app/+internal/DiagnosticRecorder.m b/+labkit/+app/+internal/DiagnosticRecorder.m deleted file mode 100644 index ce17796c8..000000000 --- a/+labkit/+app/+internal/DiagnosticRecorder.m +++ /dev/null @@ -1,393 +0,0 @@ -classdef (Hidden, Sealed) DiagnosticRecorder < handle - %DIAGNOSTICRECORDER Record sanitized App SDK semantic operations. - % - % Expected callers are RuntimeKernel and focused framework tests. Inputs - % are one compiled Definition and one diagnostic Options value. Events use - % a fixed schema, relative elapsed time, semantic IDs, and sanitized error - % details. Verbose sessions append JSON lines and atomically maintain the - % active-operation marker. Recorder failures never alter App execution. - - properties (Access = private) - Application - Options - StartTimer - Sequence (1, 1) double = 0 - OperationSequence (1, 1) double = 0 - EventBuffer (1, :) cell = {} - OperationStack (1, :) cell = {} - EventsFile (1, 1) string = "" - ActiveFile (1, 1) string = "" - ManifestFile (1, 1) string = "" - DiskEnabled (1, 1) logical = false - Closed (1, 1) logical = false - end - - methods - function obj = DiagnosticRecorder(application, options) - if ~isa(application, "labkit.app.Definition") || ... - ~isscalar(application) - error("labkit:app:runtime:InvariantFailure", ... - "DiagnosticRecorder requires one Definition."); - end - if nargin < 2 || isempty(options) - options = labkit.app.diagnostic.Options(); - end - if ~isa(options, "labkit.app.diagnostic.Options") || ... - ~isscalar(options) - error("labkit:app:runtime:InvariantFailure", ... - "DiagnosticRecorder requires diagnostic Options."); - end - obj.Application = application; - obj.Options = options; - obj.StartTimer = tic; - obj.initializeDisk(); - obj.note("lifecycle", "runtime", "created", "completed"); - end - - function operation = begin(obj, category, targetId, signal) - operation = struct( ... - "Id", obj.nextOperationId(), ... - "ParentId", obj.currentOperationId(), ... - "Category", semanticText(category), ... - "TargetId", semanticText(targetId), ... - "Signal", semanticText(signal), ... - "Timer", tic); - obj.OperationStack{end + 1} = operation; - if obj.Options.Level == "verbose" - obj.appendEvent(operation.Category, operation.Id, ... - operation.ParentId, operation.TargetId, ... - operation.Signal, "begin", [], [], []); - obj.writeActiveOperation(operation); - end - end - - function finish(obj, operation, outcome, exception) - if nargin < 4 - exception = []; - end - duration = elapsed(operation); - outcome = semanticText(outcome); - failed = outcome ~= "completed"; - if obj.Options.Level == "verbose" || failed || duration >= 30 - obj.appendEvent(operation.Category, operation.Id, ... - operation.ParentId, operation.TargetId, ... - operation.Signal, outcome, duration, exception, []); - end - obj.removeOperation(operation.Id); - obj.refreshActiveOperation(); - end - - function note(obj, category, targetId, signal, outcome) - category = semanticText(category); - outcome = semanticText(outcome); - if obj.Options.Level == "verbose" || ... - category == "lifecycle" || outcome ~= "completed" - obj.appendEvent(category, "", obj.currentOperationId(), ... - semanticText(targetId), semanticText(signal), ... - outcome, [], [], []); - end - end - - function count(obj, id, value) - if ~(isnumeric(value) && isscalar(value) && isfinite(value) && ... - value >= 0 && value == fix(value)) - error("labkit:app:contract:InvalidValue", ... - "Diagnostic count must be a nonnegative integer."); - end - if obj.Options.Level == "verbose" - obj.appendEvent("app", "", obj.currentOperationId(), ... - semanticText(id), "count", "completed", ... - [], [], double(value)); - end - end - - function events = events(obj) - if isempty(obj.EventBuffer) - events = repmat(eventTemplate(), 0, 1); - else - events = vertcat(obj.EventBuffer{:}); - end - end - - function folder = artifactFolder(obj) - folder = obj.Options.ArtifactFolder; - end - - function close(obj) - if obj.Closed - return; - end - obj.note("lifecycle", "runtime", "closed", "completed"); - obj.Closed = true; - obj.OperationStack = {}; - obj.removeActiveFile(); - obj.writeManifest("closed"); - end - - function delete(obj) - obj.close(); - end - end - - methods (Access = private) - function initializeDisk(obj) - if obj.Options.Level ~= "verbose" || ... - strlength(obj.Options.ArtifactFolder) == 0 - return; - end - try - folder = obj.Options.ArtifactFolder; - if exist(char(folder), "dir") ~= 7 - mkdir(char(folder)); - end - obj.EventsFile = string(fullfile(folder, "events.jsonl")); - obj.ActiveFile = string(fullfile( ... - folder, "active-operation.json")); - obj.ManifestFile = string(fullfile(folder, "manifest.json")); - initializeTextFile(obj.EventsFile); - obj.DiskEnabled = true; - obj.writeManifest("active"); - catch - obj.DiskEnabled = false; - obj.EventsFile = ""; - obj.ActiveFile = ""; - obj.ManifestFile = ""; - end - end - - function id = nextOperationId(obj) - obj.OperationSequence = obj.OperationSequence + 1; - id = "op-" + string(obj.OperationSequence); - end - - function id = currentOperationId(obj) - id = ""; - if ~isempty(obj.OperationStack) - id = string(obj.OperationStack{end}.Id); - end - end - - function appendEvent(obj, category, operationId, parentId, ... - targetId, signal, outcome, duration, exception, count) - obj.Sequence = obj.Sequence + 1; - event = eventTemplate(); - event.Sequence = obj.Sequence; - event.ElapsedSeconds = toc(obj.StartTimer); - event.Level = obj.Options.Level; - event.Category = string(category); - event.OperationId = string(operationId); - event.ParentOperationId = string(parentId); - event.TargetId = string(targetId); - event.Signal = string(signal); - event.Outcome = string(outcome); - event.DurationSeconds = duration; - event.Count = count; - if isa(exception, "MException") && isscalar(exception) - event.ErrorId = string(exception.identifier); - event.ErrorMessage = obj.sanitizeText(exception.message); - stack = exception.stack; - event.ErrorStack = strings(numel(stack), 1); - for k = 1:numel(stack) - event.ErrorStack(k) = string(stack(k).name) + ... - ":" + string(stack(k).line); - end - end - obj.EventBuffer{1, end + 1} = event; - if numel(obj.EventBuffer) > 512 - obj.EventBuffer(1) = []; - end - obj.appendJsonLine(event); - end - - function appendJsonLine(obj, event) - if ~obj.DiskEnabled - return; - end - try - file = fopen(char(obj.EventsFile), "a", "n", "UTF-8"); - if file < 0 - obj.DiskEnabled = false; - return; - end - cleanup = onCleanup(@() fclose(file)); - fprintf(file, "%s\n", jsonencode(event)); - clear cleanup - catch - obj.DiskEnabled = false; - end - end - - function writeActiveOperation(obj, operation) - if ~obj.DiskEnabled - return; - end - payload = struct( ... - "operationId", operation.Id, ... - "parentOperationId", operation.ParentId, ... - "category", operation.Category, ... - "targetId", operation.TargetId, ... - "signal", operation.Signal, ... - "elapsedSeconds", toc(obj.StartTimer)); - obj.writeJsonAtomically(obj.ActiveFile, payload); - end - - function refreshActiveOperation(obj) - if ~obj.DiskEnabled - return; - end - if isempty(obj.OperationStack) - obj.removeActiveFile(); - else - obj.writeActiveOperation(obj.OperationStack{end}); - end - end - - function removeOperation(obj, id) - if isempty(obj.OperationStack) - return; - end - ids = string(cellfun(@(value) value.Id, obj.OperationStack, ... - "UniformOutput", false)); - index = find(ids == string(id), 1, "last"); - if ~isempty(index) - obj.OperationStack(index) = []; - end - end - - function removeActiveFile(obj) - if strlength(obj.ActiveFile) == 0 - return; - end - try - if exist(char(obj.ActiveFile), "file") == 2 - delete(char(obj.ActiveFile)); - end - catch - end - end - - function writeManifest(obj, status) - if ~obj.DiskEnabled && status ~= "active" - return; - end - try - sdk = labkit.app.version(); - payload = struct( ... - "schemaVersion", 1, ... - "status", string(status), ... - "appId", obj.Application.AppId, ... - "entrypoint", obj.Application.Entrypoint, ... - "appVersion", obj.Application.AppVersion, ... - "appSdkVersion", sdk.current, ... - "matlabRelease", string(version("-release")), ... - "platform", string(computer), ... - "level", obj.Options.Level, ... - "sample", obj.Options.Sample, ... - "eventCount", obj.Sequence); - obj.writeJsonAtomically(obj.ManifestFile, payload); - catch - obj.DiskEnabled = false; - end - end - - function writeJsonAtomically(obj, filepath, payload) - if ~obj.DiskEnabled || strlength(filepath) == 0 - return; - end - temporary = string(tempname(fileparts(filepath))); - try - file = fopen(char(temporary), "w", "n", "UTF-8"); - if file < 0 - obj.DiskEnabled = false; - return; - end - cleanup = onCleanup(@() fclose(file)); - fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); - clear cleanup - [moved, ~] = movefile(char(temporary), char(filepath), "f"); - if ~moved - obj.DiskEnabled = false; - end - catch - obj.DiskEnabled = false; - end - if exist(char(temporary), "file") == 2 - try - delete(char(temporary)); - catch - end - end - end - - function text = sanitizeText(obj, value) - text = string(value); - roots = [ ... - string(getenv("USERPROFILE")) - string(getenv("HOME")) - string(userpath) - string(tempdir) - obj.Options.ArtifactFolder]; - roots = unique(roots(strlength(roots) > 0)); - for root = reshape(roots, 1, []) - pattern = regexptranslate("escape", root); - if ispc - text = regexprep(text, pattern, "", "ignorecase"); - else - text = regexprep(text, pattern, ""); - end - end - text = regexprep(text, ... - '(?'); - text = regexprep(text, ... - '(?'); - text = regexprep(text, ... - '(?i)\b[^\s\\/]+\.(csv|mat|json|txt|png|jpg|jpeg|tif|tiff|avi|xlsx|dta|rhs)\b', ... - ''); - end - end -end - -function event = eventTemplate() -event = struct( ... - "Sequence", 0, ... - "ElapsedSeconds", 0, ... - "Level", "", ... - "Category", "", ... - "OperationId", "", ... - "ParentOperationId", "", ... - "TargetId", "", ... - "Signal", "", ... - "Outcome", "", ... - "DurationSeconds", [], ... - "ErrorId", "", ... - "ErrorMessage", "", ... - "ErrorStack", strings(0, 1), ... - "Count", []); -end - -function value = semanticText(value) -if ~(ischar(value) || (isstring(value) && isscalar(value))) - error("labkit:app:runtime:InvariantFailure", ... - "Diagnostic semantic values must be scalar text."); -end -value = string(value); -end - -function seconds = elapsed(operation) -seconds = 0; -try - seconds = toc(operation.Timer); -catch -end -end - -function initializeTextFile(filepath) -file = fopen(char(filepath), "w", "n", "UTF-8"); -if file < 0 - error("labkit:app:runtime:InvariantFailure", ... - "Could not initialize diagnostic events file."); -end -cleanup = onCleanup(@() fclose(file)); -clear cleanup -end diff --git a/+labkit/+app/+internal/LayoutNode.m b/+labkit/+app/+internal/LayoutNode.m index a03047ee6..ee8bf7311 100644 --- a/+labkit/+app/+internal/LayoutNode.m +++ b/+labkit/+app/+internal/LayoutNode.m @@ -9,7 +9,6 @@ % node = labkit.app.internal.LayoutNode.fileList(id, Name=Value) % node = labkit.app.internal.LayoutNode.plotArea(id, Name=Value) % node = labkit.app.internal.LayoutNode.dataTable(id, Name=Value) - % node = labkit.app.internal.LayoutNode.logPanel(id) % node = labkit.app.internal.LayoutNode.statusPanel(id) % node = labkit.app.layout.group(id, children, Name=Value) % node = labkit.app.layout.section(id, title, children, Name=Value) @@ -392,15 +391,6 @@ signals, configuration); end - function obj = logPanel(id, varargin) - options = labkit.app.internal.OptionParser.parse( ... - "labkit.app.layout.logPanel", "Title", varargin{:}); - configuration = struct("Title", labkit.app.internal.LayoutNodeValues.nonemptyText(labkit.app.internal.LayoutNodeValues.optionValue( ... - options, "Title", "Log"), "log panel Title")); - obj = makeLeaf("logPanel", id, ["text", "visible"], {}, ... - configuration); - end - function obj = statusPanel(id, varargin) options = labkit.app.internal.OptionParser.parse( ... "labkit.app.layout.statusPanel", ... diff --git a/+labkit/+app/+internal/LayoutNodeValues.m b/+labkit/+app/+internal/LayoutNodeValues.m index ae9ca742e..7c411b995 100644 --- a/+labkit/+app/+internal/LayoutNodeValues.m +++ b/+labkit/+app/+internal/LayoutNodeValues.m @@ -49,7 +49,7 @@ function validateChildKinds(children, allowed, parent) function kinds = leafAndGroupKinds() kinds = ["button", "field", "rangeField", "slider", "fileList", ... - "plotArea", "dataTable", "logPanel", "statusPanel", "group"]; + "plotArea", "dataTable", "statusPanel", "group"]; end function kinds = controlGroupKinds() diff --git a/+labkit/+app/+internal/NativeAdapterValues.m b/+labkit/+app/+internal/NativeAdapterValues.m index 83202ed10..5e73c83fc 100644 --- a/+labkit/+app/+internal/NativeAdapterValues.m +++ b/+labkit/+app/+internal/NativeAdapterValues.m @@ -52,6 +52,7 @@ function installRowDivider(figureHandle, grid, upper, lower) labelHandle = uilabel(grid, Text=char(string(label)), Tag=char(tag), ... HorizontalAlignment="right"); applyTextFit(labelHandle); + grid.UserData = struct("Label", labelHandle); parent = grid; end @@ -101,8 +102,30 @@ function installRowDivider(figureHandle, grid, upper, lower) end function setIfProperty(component, name, value) - if isprop(component, name) - component.(name) = value; + if ~isprop(component, name) || isequaln(component.(name), value) + return + end + component.(name) = value; + end + + function label = linkedLabel(component) + label = []; + if isempty(component) || ~isvalid(component) || ... + ~isprop(component, "UserData") || ... + ~isstruct(component.UserData) || ... + ~isfield(component.UserData, "LayoutContainer") + return + end + container = component.UserData.LayoutContainer; + if isempty(container) || ~isvalid(container) || ... + ~isprop(container, "UserData") || ... + ~isstruct(container.UserData) || ... + ~isfield(container.UserData, "Label") + return + end + candidate = container.UserData.Label; + if ~isempty(candidate) && isvalid(candidate) + label = candidate; end end diff --git a/+labkit/+app/+internal/RuntimeFactory.m b/+labkit/+app/+internal/RuntimeFactory.m index eb6b68297..85ccbbcbf 100644 --- a/+labkit/+app/+internal/RuntimeFactory.m +++ b/+labkit/+app/+internal/RuntimeFactory.m @@ -1,9 +1,9 @@ classdef (Hidden, Sealed) RuntimeFactory - % Internal runtime and synthetic-diagnostic construction boundary. + % Internal runtime construction boundary. methods (Static) function runtime = createHeadless( ... - definition, initialProject, backend, diagnostics) + definition, initialProject, backend, journal, varargin) if nargin < 2 initialProject = []; end @@ -11,15 +11,16 @@ backend = struct(); end if nargin < 4 - diagnostics = labkit.app.diagnostic.Options(); + journal = []; end + journalRoot = parseJournalRoot(journal, varargin{:}); runtime = labkit.app.internal.RuntimeFactory.create( ... definition, initialProject, backend, ... - "headless", diagnostics); + "headless", journal, journalRoot); end function runtime = createMatlab( ... - definition, initialProject, backend, diagnostics) + definition, initialProject, backend, journal, varargin) if nargin < 2 initialProject = []; end @@ -27,41 +28,44 @@ backend = struct(); end if nargin < 4 - diagnostics = labkit.app.diagnostic.Options(); + journal = []; end + journalRoot = parseJournalRoot(journal, varargin{:}); runtime = labkit.app.internal.RuntimeFactory.create( ... definition, initialProject, backend, ... - "matlab", diagnostics); + "matlab", journal, journalRoot); end end methods (Static, Access = private) function runtime = create( ... - definition, initialProject, backend, platform, diagnostics) + definition, initialProject, backend, platform, journal, journalRoot) if ~isa(definition, "labkit.app.Definition") || ... ~isscalar(definition) error("labkit:app:runtime:InvariantFailure", ... "RuntimeFactory requires one Definition."); end - recorder = labkit.app.internal.DiagnosticRecorder( ... - definition, diagnostics); - sampleOperation = []; + journal = prepareJournal(definition, journal, journalRoot); try - if diagnostics.Sample == "synthetic" - sampleOperation = recorder.begin( ... - "sample", "synthetic", "build"); - initialProject = buildSyntheticProject( ... - definition, initialProject, diagnostics); - recorder.finish(sampleOperation, "completed", []); - sampleOperation = []; + projection = labkit.app.internal.SessionJournalProjection(journal); + stream = labkit.app.internal.SessionEventStream(definition, ... + SessionId=journal.sessionId(), ProjectionHook=@projection.project, ... + ProjectionHealthHook=@projection.drainHealth); + recorder = labkit.app.internal.SessionDiagnostics( ... + definition, stream, projection, journal); + catch cause + try + journal.close(); + catch + % Journal teardown must not hide the construction failure. end + rethrow(cause); + end + try runtime = labkit.app.internal.RuntimeKernel( ... definition, definition.Compiled, initialProject, ... - backend, platform, diagnostics, recorder); + backend, platform, recorder); catch cause - if ~isempty(sampleOperation) - recorder.finish(sampleOperation, "failed", cause); - end recorder.close(); rethrow(cause); end @@ -69,96 +73,41 @@ end end -function initialProject = buildSyntheticProject( ... - definition, initialProject, diagnostics) -if ~isempty(initialProject) - error("labkit:app:contract:InvalidValue", ... - "Definition launch cannot combine InitialProject with " + ... - "a synthetic diagnostic sample."); -end -if strlength(diagnostics.ArtifactFolder) == 0 - error("labkit:app:contract:InvalidValue", ... - "A synthetic diagnostic sample requires ArtifactFolder."); -end -if isempty(definition.BuildDebugSample) - error("labkit:app:contract:UnsupportedOperation", ... - "Definition does not declare BuildDebugSample."); -end -if isempty(definition.ProjectSchema) - error("labkit:app:contract:UnsupportedOperation", ... - "A synthetic diagnostic sample requires ProjectSchema."); -end -context = labkit.app.diagnostic.SampleContext(diagnostics.ArtifactFolder); -pack = definition.BuildDebugSample(context); -if ~isa(pack, "labkit.app.diagnostic.SamplePack") || ~isscalar(pack) - error("labkit:app:contract:InvalidValue", ... - "BuildDebugSample must return one " + ... - "labkit.app.diagnostic.SamplePack value."); -end -try - accepted = definition.ProjectSchema.Validate(pack.InitialProject); -catch cause - failure = MException( ... - "labkit:app:contract:InvalidValue", ... - "BuildDebugSample returned an invalid current project."); - failure = addCause(failure, cause); - throw(failure); +function journal = prepareJournal(definition, journal, journalRoot) +if isempty(journal) + if strlength(journalRoot) == 0 + journal = labkit.app.internal.SessionJournal(definition); + else + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=journalRoot); + end + return; end -if ~isequal(accepted, true) - error("labkit:app:contract:InvalidValue", ... - "BuildDebugSample returned an invalid current project."); +if ~isa(journal, "labkit.app.internal.SessionJournal") || ~isscalar(journal) + error("labkit:app:runtime:InvariantFailure", ... + "RuntimeFactory journal seam requires one SessionJournal."); end -verifySampleArtifacts(context, pack); -writeSampleManifest(context, pack); -initialProject = pack.InitialProject; end -function verifySampleArtifacts(context, pack) -for k = 1:numel(pack.Artifacts) - artifact = pack.Artifacts{k}; - if artifact.Expectation == "exports" - continue; - end - pathParts = cellstr(split(artifact.RelativePath, "/")); - filepath = string(fullfile( ... - char(context.ArtifactFolder), pathParts{:})); - if exist(char(filepath), "file") ~= 2 && ... - exist(char(filepath), "dir") ~= 7 - error("labkit:app:contract:InvalidValue", ... - "BuildDebugSample did not create artifact %s.", artifact.Id); - end +function journalRoot = parseJournalRoot(journal, varargin) +journalRoot = ""; +if isempty(varargin) + return; end +options = labkit.app.internal.OptionParser.parse( ... + "RuntimeFactory", "JournalRoot", varargin{:}); +if ~isfield(options, "JournalRoot") + return; end - -function writeSampleManifest(context, pack) -artifacts = repmat(struct( ... - "id", "", "role", "", "relativePath", "", ... - "expectation", ""), 1, numel(pack.Artifacts)); -for k = 1:numel(pack.Artifacts) - artifact = pack.Artifacts{k}; - artifacts(k) = struct( ... - "id", artifact.Id, ... - "role", artifact.Role, ... - "relativePath", artifact.RelativePath, ... - "expectation", artifact.Expectation); +if ~isempty(journal) + error("labkit:app:runtime:InvariantFailure", ... + "RuntimeFactory cannot combine an explicit journal with JournalRoot."); end -payload = struct( ... - "type", "labkit.diagnostic.sample-pack", ... - "scenario", pack.Scenario, ... - "artifacts", artifacts); -filepath = string(fullfile(context.ArtifactFolder, "sample-pack.json")); -temporary = filepath + ".tmp"; -file = fopen(char(temporary), "w"); -if file < 0 - error("labkit:app:runtime:DiagnosticWriteFailed", ... - "Could not write the diagnostic sample manifest."); -end -cleanup = onCleanup(@() fclose(file)); -fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); -clear cleanup -[moved, message] = movefile(char(temporary), char(filepath), "f"); -if ~moved - error("labkit:app:runtime:DiagnosticWriteFailed", ... - "Could not publish the diagnostic sample manifest: %s", message); +value = options.JournalRoot; +if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + strlength(strip(string(value))) == 0 + error("labkit:app:contract:InvalidValue", ... + "RuntimeFactory JournalRoot must be nonempty scalar text."); end +journalRoot = string(value); end diff --git a/+labkit/+app/+internal/RuntimeKernel.m b/+labkit/+app/+internal/RuntimeKernel.m index 6e6b9d204..581a01144 100644 --- a/+labkit/+app/+internal/RuntimeKernel.m +++ b/+labkit/+app/+internal/RuntimeKernel.m @@ -4,7 +4,6 @@ State (1, 1) struct Presentation StatusLog (1, :) string = "Ready." - Diagnostics (1, :) cell = {} Closed (1, 1) logical = false StartupFailed (1, 1) logical = false end @@ -26,19 +25,18 @@ methods (Access = ?labkit.app.internal.RuntimeFactory) function obj = RuntimeKernel( ... application, contract, initialProject, backend, platform, ... - diagnostics, recorder) + recorder) obj.Application = application; obj.Contract = contract; - if nargin < 6 - diagnostics = labkit.app.diagnostic.Options(); - end - if nargin < 7 - recorder = labkit.app.internal.DiagnosticRecorder( ... - application, diagnostics); + if ~isa(recorder, "labkit.app.internal.SessionDiagnostics") || ... + ~isscalar(recorder) + error("labkit:app:runtime:InvariantFailure", ... + "RuntimeKernel requires one SessionDiagnostics service."); end obj.Recorder = recorder; startupOperation = obj.Recorder.begin( ... - "lifecycle", "runtime", "construct"); + "runtime.lifecycle", "runtime.construct", ... + "Constructing application runtime."); obj.Resources = labkit.app.internal.ResourceStore(); obj.Sources = labkit.app.internal.PortableSourceStore(); if nargin < 4 @@ -82,13 +80,13 @@ contract.onStartBinding(), []); end obj.Recorder.finish( ... - startupOperation, "completed", []); + startupOperation, "completed", "committed", []); if isa(obj.Adapter, "labkit.app.internal.MatlabPlatformAdapter") obj.Adapter.finishStartup(); end catch cause obj.Recorder.finish( ... - startupOperation, "failed", cause); + startupOperation, "failed", "notApplicable", cause); if isa(obj.Adapter, ... "labkit.app.internal.MatlabPlatformAdapter") obj.StartupFailed = true; @@ -127,7 +125,11 @@ function dispatch(obj, binding, payload) end function setResource(obj, scope, id, value, cleanup) - obj.Resources.set(scope, id, value, cleanup); + obj.recordOperation( ... + "runtime.resource", "resource.set", ... + "Setting runtime resource.", ... + "committed", "notApplicable", ... + @() obj.Resources.set(scope, id, value, cleanup)); end function value = getResource(obj, scope, id) @@ -135,11 +137,19 @@ function setResource(obj, scope, id, value, cleanup) end function removeResource(obj, scope, id) - obj.Resources.remove(scope, id); + obj.recordOperation( ... + "runtime.resource", "resource.removed", ... + "Removing runtime resource.", ... + "committed", "notApplicable", ... + @() obj.Resources.remove(scope, id)); end function clearResourceScope(obj, scope) - obj.Resources.clearScope(scope); + obj.recordOperation( ... + "runtime.resource", "resource.scope_cleared", ... + "Clearing runtime resource scope.", ... + "committed", "notApplicable", ... + @() obj.Resources.clearScope(scope)); end function failNextCommit(obj) @@ -154,8 +164,79 @@ function failNextCommit(obj) events = obj.Recorder.events(); end - function folder = diagnosticFolder(obj) - folder = obj.Recorder.artifactFolder(); + function snapshot = diagnosticSnapshot(obj) + snapshot = obj.Recorder.captureSnapshot(); + end + + function token = subscribeDiagnostics(obj, callback) + token = obj.Recorder.subscribe(callback); + end + + function unsubscribeDiagnostics(obj, token) + obj.Recorder.unsubscribe(token); + end + + function setTraceCapture(obj, enabled) + obj.Recorder.setTraceEnabled(enabled); + end + + function destination = exportDiagnosticBundle(obj, destination) + operation = obj.Recorder.begin( ... + "runtime.lifecycle", "diagnostics.bundle_exported", ... + "Exporting diagnostic bundle."); + try + destination = obj.Recorder.exportBundle( ... + destination, operation.Id); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end + end + + function destination = exportDiagnosticBundleInteractive(obj) + choice = obj.Context.chooseOutputFile( ... + {"*.zip", "Diagnostic bundle (*.zip)"}, ... + "labkit-diagnostics.zip"); + destination = ""; + if ~choice.Cancelled + destination = obj.exportDiagnosticBundle(choice.Value); + end + end + + function supported = supportsSyntheticInputs(obj) + supported = ~isempty(obj.Application.BuildSyntheticSample); + end + + function pack = generateSyntheticInputs(obj, folder) + operation = obj.Recorder.begin( ... + "runtime.source", "synthetic_inputs.generated", ... + "Generating synthetic inputs."); + try + pack = labkit.app.internal.SyntheticInputGenerator.generate( ... + obj.Application, folder); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end + end + + function folder = generateSyntheticInputsInteractive(obj) + choice = obj.Context.chooseOutputFolder(""); + folder = ""; + if choice.Cancelled + return; + end + folder = obj.uniqueSyntheticInputFolder(choice.Value); + obj.generateSyntheticInputs(folder); + obj.Context.alert( ... + "Synthetic inputs were written to the selected folder.", ... + "Synthetic Inputs"); end function figure = figureHandle(obj) @@ -174,26 +255,71 @@ function showFigure(obj) function result = saveProject(obj, state, filepath) obj.assertProjectStore(); - result = obj.Documents.save(state, filepath); - obj.refreshWindowTitle(); + operation = obj.Recorder.begin( ... + "runtime.project", "project.saved", ... + "Saving project document."); + try + result = obj.Documents.save(state, filepath); + obj.refreshWindowTitle(); + obj.Recorder.finish( ... + operation, "completed", "committed", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); + rethrow(cause); + end end function state = prepareProjectRestore(obj, filepath) obj.assertOpen(); obj.assertProjectStore(); - [state, obj.PendingDocumentMetadata] = ... - obj.Documents.restore(filepath, false); + operation = obj.Recorder.begin( ... + "runtime.project", "project.restore_prepared", ... + "Preparing project restore."); + try + [state, obj.PendingDocumentMetadata] = ... + obj.Documents.restore(filepath, false); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end end function state = prepareNewProject(obj) obj.assertOpen(); obj.assertProjectStore(); - [state, obj.PendingDocumentMetadata] = obj.Documents.createNew(); + operation = obj.Recorder.begin( ... + "runtime.project", "project.new_prepared", ... + "Preparing a new project."); + try + [state, obj.PendingDocumentMetadata] = ... + obj.Documents.createNew(); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end end function saveRecovery(obj, state, filepath) obj.assertProjectStore(); - obj.Documents.saveRecovery(state, filepath); + operation = obj.Recorder.begin( ... + "runtime.project", "project.recovery_saved", ... + "Saving project recovery document."); + try + obj.Documents.saveRecovery(state, filepath); + obj.Recorder.finish( ... + operation, "completed", "committed", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); + rethrow(cause); + end end function restoreProject(obj, filepath, asRecovery) @@ -204,7 +330,17 @@ function restoreProject(obj, filepath, asRecovery) obj.assertProjectStore(); previousState = obj.State; previousPresentation = obj.Presentation; - [candidate, metadata] = obj.Documents.restore(filepath, asRecovery); + operation = obj.Recorder.begin( ... + "runtime.project", "project.restored", ... + "Restoring project document."); + try + [candidate, metadata] = ... + obj.Documents.restore(filepath, asRecovery); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); + end try labkit.app.internal.RuntimeContractBoundary.validateState( ... obj.Application, candidate); @@ -214,9 +350,13 @@ function restoreProject(obj, filepath, asRecovery) obj.Presentation = view; obj.Documents.acceptRestore(metadata); obj.refreshWindowTitle(); + obj.Recorder.finish( ... + operation, "completed", "committed", []); catch cause obj.State = previousState; obj.Presentation = previousPresentation; + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); failure = MException( ... "labkit:app:runtime:ProjectRestoreFailed", ... "Project restore failed transactionally."); @@ -236,53 +376,89 @@ function restoreProject(obj, filepath, asRecovery) metadata = obj.Documents.Metadata; end writer = labkit.app.internal.ResultWriter(obj.Application, metadata); - written = writer.write(folder, result); + written = obj.recordOperation( ... + "runtime.result", "result.written", ... + "Writing result package.", ... + "committed", "rolledBack", ... + @() writer.write(folder, result)); end function record = sourceRecord(obj, id, role, path, required) - record = obj.Sources.create(id, role, path, required); + record = obj.recordOperation( ... + "runtime.source", "source.record_created", ... + "Creating portable source record.", ... + "notApplicable", "notApplicable", ... + @() obj.Sources.create(id, role, path, required)); end function paths = sourcePaths(obj, sources, ids) - if isempty(ids) - paths = obj.Sources.sourcePaths(sources); - else - paths = obj.Sources.sourcePaths(sources, ids); + paths = obj.recordOperation( ... + "runtime.source", "source.paths_resolved", ... + "Resolving portable source paths.", ... + "notApplicable", "notApplicable", ... + @resolve); + + function values = resolve() + if isempty(ids) + values = obj.Sources.sourcePaths(sources); + else + values = obj.Sources.sourcePaths(sources, ids); + end end end function sources = upsertSource(obj, sources, record) - sources = obj.Sources.upsert(sources, record); + sources = obj.recordOperation( ... + "runtime.source", "source.record_upserted", ... + "Updating portable source records.", ... + "notApplicable", "notApplicable", ... + @() obj.Sources.upsert(sources, record)); end function sources = reconcileSources(obj, current, incoming) - sources = obj.Sources.reconcile(current, incoming); + sources = obj.recordOperation( ... + "runtime.source", "source.records_reconciled", ... + "Reconciling portable source records.", ... + "notApplicable", "notApplicable", ... + @() obj.Sources.reconcile(current, incoming)); end function applyBinding(obj, target, value) - obj.applyBoundControl(target, value, false); + obj.recordOperation( ... + "runtime.interaction", "interaction.binding_applied", ... + "Applying bound control value.", ... + "committed", "rolledBack", ... + @() obj.applyBoundControl(target, value, false)); end function applyControlValue(obj, target, value) - plan = obj.Contract.PlatformPlan; - index = find(string({plan.Nodes.Id}) == string(target), 1); - if isempty(index) - error("labkit:app:contract:UnknownReference", ... - "Layout target is undeclared: %s.", target); - end - configuration = plan.Nodes(index).Configuration; - if isfield(configuration, "Bind") && ... - strlength(configuration.Bind) > 0 - obj.applyBoundControl(target, value, true); - else - binding = ... - labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... - obj.Contract, target, "valueChanged", false); - if isempty(binding) + obj.recordOperation( ... + "runtime.interaction", "interaction.value_changed", ... + "Applying control value.", ... + "committed", "rolledBack", @apply); + + function apply() + plan = obj.Contract.PlatformPlan; + index = find(string({plan.Nodes.Id}) == string(target), 1); + if isempty(index) error("labkit:app:contract:UnknownReference", ... - "Layout target has no value behavior: %s.", target); + "Layout target is undeclared: %s.", target); + end + configuration = plan.Nodes(index).Configuration; + if isfield(configuration, "Bind") && ... + strlength(configuration.Bind) > 0 + obj.applyBoundControl(target, value, true); + else + binding = ... + labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... + obj.Contract, target, "valueChanged", false); + if isempty(binding) + error("labkit:app:contract:UnknownReference", ... + "Layout target has no value behavior: %s.", ... + target); + end + obj.dispatch(binding, value); end - obj.dispatch(binding, value); end end @@ -291,7 +467,11 @@ function invokeAction(obj, target) binding = ... labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "pressed"); - obj.dispatch(binding, []); + obj.recordOperation( ... + "runtime.interaction", "interaction.action_invoked", ... + "Invoking application action.", ... + "committed", "rolledBack", ... + @() obj.dispatch(binding, [])); end function applyTableEdit(obj, target, edit) @@ -303,7 +483,11 @@ function applyTableEdit(obj, target, edit) binding = ... labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "cellEdited"); - obj.dispatch(binding, edit); + obj.recordOperation( ... + "runtime.interaction", "interaction.table_edited", ... + "Applying table edit.", ... + "committed", "rolledBack", ... + @() obj.dispatch(binding, edit)); end function applyTableSelection(obj, target, cells) @@ -312,7 +496,11 @@ function applyTableSelection(obj, target, cells) binding = ... labkit.app.internal.RuntimeContractBoundary.signalForTarget( ... obj.Contract, target, "cellSelectionChanged"); - obj.dispatch(binding, selection); + obj.recordOperation( ... + "runtime.interaction", "interaction.table_selected", ... + "Applying table selection.", ... + "committed", "rolledBack", ... + @() obj.dispatch(binding, selection)); end function applyInteraction(obj, interactionId, signal, payload) @@ -320,7 +508,11 @@ function applyInteraction(obj, interactionId, signal, payload) binding = ... labkit.app.internal.RuntimeContractBoundary.interactionSignal( ... obj.Contract, interactionId, signal); - obj.dispatch(binding, payload); + obj.recordOperation( ... + "runtime.interaction", "interaction.managed_committed", ... + "Applying managed interaction.", ... + "committed", "rolledBack", ... + @() obj.dispatch(binding, payload)); end function applyFilePanelSelection(obj, target, indices) @@ -328,7 +520,12 @@ function applyFilePanelSelection(obj, target, indices) [config, current] = ... labkit.app.internal.RuntimeContractBoundary.fileListState( ... obj.Contract, obj.State, target); - obj.commitFilePanel(target, config, current, indices, false); + obj.recordOperation( ... + "runtime.source", "source.selection_changed", ... + "Applying source selection.", ... + "committed", "rolledBack", ... + @() obj.commitFilePanel( ... + target, config, current, indices, false)); end function applyBoundControl(obj, target, value, dispatchChanged) @@ -380,6 +577,10 @@ function applyBoundControl(obj, target, value, dispatchChanged) function applyFileSelection(obj, target, paths, indices) obj.assertOpen(); + operation = obj.Recorder.begin( ... + "runtime.source", "source.files_selected", ... + "Applying selected source files."); + try [config, current] = ... labkit.app.internal.RuntimeContractBoundary.fileListState( ... obj.Contract, obj.State, target); @@ -400,10 +601,21 @@ function applyFileSelection(obj, target, paths, indices) "fileList selection indices are invalid."); end obj.commitFilePanel(target, config, sources, indices, true); + obj.Recorder.finish( ... + operation, "completed", "committed", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); + rethrow(cause); + end end function removeFileSelection(obj, target, indices) obj.assertOpen(); + operation = obj.Recorder.begin( ... + "runtime.source", "source.files_removed", ... + "Removing selected source files."); + try [config, current] = ... labkit.app.internal.RuntimeContractBoundary.fileListState( ... obj.Contract, obj.State, target); @@ -421,6 +633,13 @@ function removeFileSelection(obj, target, indices) current, config.SourceRole, visible(keep)); obj.commitFilePanel( ... target, config, sources, zeros(1, 0), true); + obj.Recorder.finish( ... + operation, "completed", "committed", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "rolledBack", cause); + rethrow(cause); + end end function close(obj) @@ -446,6 +665,16 @@ function delete(obj) end methods (Access = private) + function folder = uniqueSyntheticInputFolder(obj, parent) + timestamp = string(datetime("now", ... + "TimeZone", "UTC", "Format", "yyyyMMdd-HHmmss")); + nonce = extractBefore( ... + string(java.util.UUID.randomUUID()), 9); + folder = string(fullfile(char(parent), ... + "labkit-synthetic-" + obj.Application.AppId + "-" + ... + timestamp + "-" + nonce)); + end + function commitFilePanel(obj, target, config, sources, indices, rebuildSession) if nargin < 6 rebuildSession = false; @@ -509,14 +738,8 @@ function commitFilePanel(obj, target, config, sources, indices, rebuildSession) backend = struct(); end builtins = struct( ... - "appendStatus", @(message) obj.appendStatus(message), ... - "reportError", @(operation, exception) ... - obj.reportError(operation, exception), ... - "diagnosticCheckpoint", @(id) ... - obj.Recorder.note( ... - "app", id, "checkpoint", "completed"), ... - "diagnosticCount", @(id, count) ... - obj.Recorder.count(id, count), ... + "log", @(severity, eventName, message, category, audience, attributes, exception) ... + obj.log(severity, eventName, message, category, audience, attributes, exception), ... "setResource", @(scope, id, value, cleanup) ... obj.setResource(scope, id, value, cleanup), ... "getResource", @(scope, id) obj.getResource(scope, id), ... @@ -554,6 +777,7 @@ function commitFilePanel(obj, target, config, sources, indices, rebuildSession) backend.(names(k)) = builtins.(names(k)); end end + backend = obj.wrapDialogOperations(backend); end function execute(obj, binding, payload) @@ -561,7 +785,8 @@ function execute(obj, binding, payload) previousPresentation = obj.Presentation; obj.PendingDocumentMetadata = []; operation = obj.Recorder.begin( ... - "callback", binding.Id, binding.Signal); + "runtime.callback", "callback." + binding.Signal, ... + "Dispatching callback.", Attributes=struct("runtimeAlias", binding.Id)); try if ~binding.AcceptsPayload candidate = binding.UpdateState(previousState, obj.Context); @@ -581,13 +806,13 @@ function execute(obj, binding, payload) obj.Documents.acceptRestore(obj.PendingDocumentMetadata); obj.refreshWindowTitle(); end - obj.Recorder.finish(operation, "completed", []); + obj.Recorder.finish(operation, "completed", "committed", []); catch cause obj.State = previousState; obj.Presentation = previousPresentation; obj.PendingDocumentMetadata = []; obj.Resources.clearScope("event"); - obj.Recorder.finish(operation, "rolledBack", cause); + obj.Recorder.finish(operation, "failed", "rolledBack", cause); failure = MException("labkit:app:runtime:ActionFailed", ... "Callback %s failed transactionally.", binding.Id); failure = addCause(failure, cause); @@ -598,21 +823,29 @@ function execute(obj, binding, payload) end function view = present(obj, state) - view = labkit.app.internal.RuntimePresentation.fromState( ... - obj.Contract.PlatformPlan, state, ... - @(records, role) obj.presentationSourcePaths(records, role), ... - obj.StatusLog); - if isempty(obj.Application.PresentWorkbench) - custom = labkit.app.view.Snapshot(); - else - custom = obj.Application.PresentWorkbench(state); + operation = obj.Recorder.begin( ... + "runtime.presentation", "presentation.rendered", ... + "Preparing application presentation."); + try + view = labkit.app.internal.RuntimePresentation.fromState( ... + obj.Contract.PlatformPlan, state, ... + @(records, role) ... + obj.presentationSourcePaths(records, role), ... + obj.StatusLog); + if isempty(obj.Application.PresentWorkbench) + custom = labkit.app.view.Snapshot(); + else + custom = obj.Application.PresentWorkbench(state); + end + view = view.overlayForRuntime(custom); + obj.Application.validateViewSnapshot(view); + obj.Recorder.finish( ... + operation, "completed", "notApplicable", []); + catch cause + obj.Recorder.finish( ... + operation, "failed", "notApplicable", cause); + rethrow(cause); end - view = view.overlayForRuntime(custom); - obj.Application.validateViewSnapshot(view); - end - - function appendStatus(obj, message) - obj.StatusLog(end + 1) = message; end function paths = presentationSourcePaths(obj, records, role) @@ -620,13 +853,98 @@ function appendStatus(obj, message) paths = obj.Sources.sourcePaths(records); end - function reportError(obj, operation, exception) - obj.Diagnostics{end + 1} = struct( ... - "Operation", operation, "Exception", exception); - diagnosticOperation = obj.Recorder.begin( ... - "reportedError", operation, "reported"); - obj.Recorder.finish( ... - diagnosticOperation, "reported", exception); + function log(obj, severity, eventName, message, category, audience, attributes, exception) + obj.Recorder.log(severity, eventName, message, ... + Category=category, Audience=audience, Attributes=attributes, ... + Exception=exception); + if audience == "user" && ... + any(severity == ["info", "warning", "error", "critical"]) + obj.StatusLog(end + 1) = message; + end + end + + function backend = wrapDialogOperations(obj, backend) + if isfield(backend, "alert") + operation = backend.alert; + backend.alert = @(message, title) ... + obj.invokeDialogAlert(operation, message, title); + end + if isfield(backend, "choose") + operation = backend.choose; + backend.choose = @(prompt, choices, title, ... + defaultChoice, cancelChoice) ... + obj.invokeDialogChoice( ... + "dialog.option_chosen", "Showing option dialog.", ... + operation, prompt, choices, title, ... + defaultChoice, cancelChoice); + end + if isfield(backend, "chooseInputFile") + operation = backend.chooseInputFile; + backend.chooseInputFile = @(filters, startPath) ... + obj.invokeDialogChoice( ... + "dialog.input_file_chosen", ... + "Showing input-file dialog.", ... + operation, filters, startPath); + end + if isfield(backend, "chooseInputFolder") + operation = backend.chooseInputFolder; + backend.chooseInputFolder = @(startPath) ... + obj.invokeDialogChoice( ... + "dialog.input_folder_chosen", ... + "Showing input-folder dialog.", ... + operation, startPath); + end + if isfield(backend, "chooseOutputFile") + operation = backend.chooseOutputFile; + backend.chooseOutputFile = @(filters, startPath) ... + obj.invokeDialogChoice( ... + "dialog.output_file_chosen", ... + "Showing output-file dialog.", ... + operation, filters, startPath); + end + if isfield(backend, "chooseOutputFolder") + operation = backend.chooseOutputFolder; + backend.chooseOutputFolder = @(startPath) ... + obj.invokeDialogChoice( ... + "dialog.output_folder_chosen", ... + "Showing output-folder dialog.", ... + operation, startPath); + end + end + + function invokeDialogAlert(obj, operation, message, title) + obj.recordOperation( ... + "runtime.dialog", "dialog.alert_shown", ... + "Showing application alert.", ... + "notApplicable", "notApplicable", ... + @() operation(message, title)); + end + + function result = invokeDialogChoice( ... + obj, eventName, message, operation, varargin) + result = obj.recordOperation( ... + "runtime.dialog", eventName, message, ... + "notApplicable", "notApplicable", ... + @() operation(varargin{:})); + end + + function varargout = recordOperation( ... + obj, category, eventName, message, ... + successDisposition, failureDisposition, operation) + scope = obj.Recorder.begin(category, eventName, message); + try + if nargout == 0 + operation(); + else + [varargout{1:nargout}] = operation(); + end + obj.Recorder.finish( ... + scope, "completed", successDisposition, []); + catch cause + obj.Recorder.finish( ... + scope, "failed", failureDisposition, cause); + rethrow(cause); + end end function finishProcessing(obj) diff --git a/+labkit/+app/+internal/RuntimePresentation.m b/+labkit/+app/+internal/RuntimePresentation.m index 38ee24a9b..4e8ad2295 100644 --- a/+labkit/+app/+internal/RuntimePresentation.m +++ b/+labkit/+app/+internal/RuntimePresentation.m @@ -83,8 +83,6 @@ Columns=config.Columns, ... RowNames=config.RowNames, ... ColumnEditable=config.ColumnEditable); - case "logPanel" - view = view.text(node.Id, join(statusLog, newline)); case "statusPanel" status = config.Text; if isempty(status) && ~isempty(statusLog) diff --git a/+labkit/+app/+internal/SessionDiagnosticBundle.m b/+labkit/+app/+internal/SessionDiagnosticBundle.m new file mode 100644 index 000000000..4a4b9f356 --- /dev/null +++ b/+labkit/+app/+internal/SessionDiagnosticBundle.m @@ -0,0 +1,281 @@ +classdef (Hidden, Sealed) SessionDiagnosticBundle + %SESSIONDIAGNOSTICBUNDLE Write one privacy-safe diagnostic ZIP snapshot. + % SessionDiagnostics supplies canonical events and manifest metadata. + % This writer never receives App state, source files, results, or images. + + methods (Static) + function destination = write(snapshot, destination) + snapshot = validateSnapshot(snapshot); + destination = diagnosticZipPath(destination); + parent = string(fileparts(destination)); + if strlength(parent) == 0 + parent = string(pwd); + destination = fullfile(parent, destination); + end + if exist(char(parent), "dir") ~= 7 + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "The diagnostic bundle destination folder is unavailable."); + end + staging = string(tempname); + mkdir(char(staging)); + cleanup = onCleanup(@() removeStaging(staging)); + writeText(fullfile(staging, "README.txt"), ... + readmeLines(snapshot)); + writeJson(fullfile(staging, "manifest.json"), ... + snapshot.manifest); + writeEvents(fullfile(staging, "events.jsonl"), ... + snapshot.events); + writeText(fullfile(staging, "session.log.txt"), ... + timeline(snapshot.events)); + writeJson(fullfile(staging, "errors.json"), ... + errorRecords(snapshot.events)); + writeJson(fullfile(staging, "redaction-report.json"), ... + redactionReport(snapshot)); + + temporaryZip = string(tempname(char(parent))) + ".zip"; + zipCleanup = onCleanup(@() removeFile(temporaryZip)); + files = [ + "README.txt" + "manifest.json" + "events.jsonl" + "session.log.txt" + "errors.json" + "redaction-report.json" + ]; + zip(char(temporaryZip), cellstr(files), char(staging)); + [moved, message] = movefile( ... + char(temporaryZip), char(destination), "f"); + if ~moved + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not publish the diagnostic bundle: %s", message); + end + clear zipCleanup cleanup + end + end +end + +function snapshot = validateSnapshot(snapshot) +fields = ["manifest", "events", "degradation", "capture"]; +if ~isstruct(snapshot) || ~isscalar(snapshot) || ... + ~isequal(string(fieldnames(snapshot)), fields.') + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic bundle snapshot is invalid."); +end +if ~isstruct(snapshot.manifest) || ~isscalar(snapshot.manifest) || ... + ~isstruct(snapshot.degradation) || ... + ~isscalar(snapshot.degradation) || ... + ~isstruct(snapshot.capture) || ~isscalar(snapshot.capture) + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic bundle metadata is invalid."); +end +if isempty(snapshot.events) + snapshot.events = emptyEvents(); +elseif ~isstruct(snapshot.events) + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic bundle events are invalid."); +else + for index = 1:numel(snapshot.events) + validateRecord(snapshot.events(index)); + end + [~, order] = sort(double([snapshot.events.sequence])); + snapshot.events = snapshot.events(order); +end +end + +function validateRecord(record) +fields = ["schemaVersion", "sequence", "timestampUtc", "elapsedSeconds", ... + "severity", "audience", "category", "eventName", "message", ... + "attributes", "sessionId", "appId", "operationId", ... + "parentOperationId", "rootActionId", "operationResult", ... + "stateDisposition", "durationSeconds", "exception"]; +if ~isstruct(record) || ~isscalar(record) || ... + ~isequal(string(fieldnames(record)), fields.') || ... + ~labkit.app.internal.SessionEventValidator.canonicalTerminalPair( ... + record.operationResult, record.stateDisposition) + error("labkit:app:runtime:InvariantFailure", ... + "Diagnostic bundle record is not canonical."); +end +labkit.app.internal.SessionEventValidator.privacySafeText( ... + record.message, "message"); +labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + record.attributes); +end + +function destination = diagnosticZipPath(destination) +if ~(ischar(destination) || ... + (isstring(destination) && isscalar(destination))) || ... + strlength(strip(string(destination))) == 0 + error("labkit:app:contract:InvalidValue", ... + "Diagnostic bundle destination must be nonempty scalar text."); +end +destination = string(destination); +[folder, name, extension] = fileparts(destination); +if strlength(extension) == 0 + destination = fullfile(folder, name + ".zip"); +elseif ~strcmpi(extension, ".zip") + error("labkit:app:contract:InvalidValue", ... + "Diagnostic bundle destination must use the .zip extension."); +end +end + +function value = readmeLines(snapshot) +capture = snapshot.capture; +degradation = snapshot.degradation; +value = [ + "LabKit Diagnostic Bundle" + "" + "This bundle contains privacy-safe Runtime session records only." + "It does not contain projects, scientific inputs or results, images, screenshots, or source files." + "" + "Capture notes:" + "- TRACE enabled at export: " + yesNo(capture.traceEnabled) + "- In-memory live view truncated: " + ... + yesNo(capture.inMemoryTruncated) + "- Retained canonical event count: " + ... + string(numel(snapshot.events)) + "- Journal dropped records: " + ... + numericField(degradation, "droppedRecordCount") + "- Journal coalesced records: " + ... + numericField(degradation, "coalescedRecordCount") + "- Expired journal segments: " + ... + numericField(degradation, "expiredSegmentCount") + "" + "Use manifest.json for session/component context, events.jsonl for structured history, session.log.txt for a readable timeline, errors.json for failures, and redaction-report.json for excluded-data categories." + ]; +end + +function value = timeline(events) +value = strings(numel(events), 1); +for index = 1:numel(events) + value(index) = string(events(index).timestampUtc) + " [" + ... + string(events(index).severity) + "] " + ... + string(events(index).category) + " " + ... + string(events(index).eventName) + " - " + ... + string(events(index).message); +end +end + +function value = errorRecords(events) +if isempty(events) + value = repmat(errorTemplate(), 0, 1); + return; +end +levels = string({events.severity}); +events = events(ismember(levels, ["ERROR", "CRITICAL"])); +value = repmat(errorTemplate(), numel(events), 1); +for index = 1:numel(events) + record = events(index); + value(index) = struct( ... + "sequence", double(record.sequence), ... + "timestampUtc", string(record.timestampUtc), ... + "severity", string(record.severity), ... + "category", string(record.category), ... + "eventName", string(record.eventName), ... + "message", string(record.message), ... + "rootActionId", string(record.rootActionId), ... + "exception", record.exception); +end +end + +function value = errorTemplate() +value = struct( ... + "sequence", 0, "timestampUtc", "", "severity", "", ... + "category", "", "eventName", "", "message", "", ... + "rootActionId", "", "exception", struct()); +end + +function value = redactionReport(snapshot) +value = struct( ... + "schemaVersion", 1, ... + "privacyBoundary", "validated-before-retention", ... + "exportProjection", "canonical-safe-events-only", ... + "excludedData", [ ... + "paths" + "filenames" + "input-content" + "scientific-data" + "workspace-values" + "projects" + "images" + "screenshots" + "source-files" + ], ... + "removedValueCount", 0, ... + "degradation", snapshot.degradation); +end + +function writeEvents(filepath, events) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not write diagnostic events."); +end +cleanup = onCleanup(@() fclose(file)); +for index = 1:numel(events) + fprintf(file, "%s\n", jsonencode(events(index))); +end +clear cleanup +end + +function writeText(filepath, lines) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not write diagnostic text."); +end +cleanup = onCleanup(@() fclose(file)); +for index = 1:numel(lines) + fprintf(file, "%s\n", lines(index)); +end +clear cleanup +end + +function writeJson(filepath, payload) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:DiagnosticWriteFailed", ... + "Could not write diagnostic metadata."); +end +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); +clear cleanup +end + +function value = numericField(structure, name) +if isfield(structure, name) + value = string(structure.(name)); +else + value = "unknown"; +end +end + +function value = yesNo(tf) +if tf + value = "yes"; +else + value = "no"; +end +end + +function removeStaging(folder) +if exist(char(folder), "dir") == 7 + rmdir(char(folder), "s"); +end +end + +function removeFile(filepath) +if exist(char(filepath), "file") == 2 + delete(char(filepath)); +end +end + +function value = emptyEvents() +value = repmat(struct( ... + "schemaVersion", 1, "sequence", 0, "timestampUtc", "", ... + "elapsedSeconds", 0, "severity", "", "audience", "", ... + "category", "", "eventName", "", "message", "", ... + "attributes", struct(), "sessionId", "", "appId", "", ... + "operationId", "", "parentOperationId", "", "rootActionId", "", ... + "operationResult", "", "stateDisposition", "", ... + "durationSeconds", [], "exception", struct()), 0, 1); +end diff --git a/+labkit/+app/+internal/SessionDiagnostics.m b/+labkit/+app/+internal/SessionDiagnostics.m new file mode 100644 index 000000000..4cf01702c --- /dev/null +++ b/+labkit/+app/+internal/SessionDiagnostics.m @@ -0,0 +1,190 @@ +classdef (Hidden, Sealed) SessionDiagnostics < handle + %SESSIONDIAGNOSTICS Own one Runtime's canonical event and journal services. + % RuntimeFactory is the production creator. RuntimeKernel uses this private + % boundary for semantic operations, viewer snapshots, trace capture, and + % orderly persistence teardown. App callbacks never receive this object. + + properties (Access = private) + Application + Stream + Projection + Journal + Closed (1, 1) logical = false + end + + methods + function obj = SessionDiagnostics( ... + application, stream, projection, journal) + if ~isa(application, "labkit.app.Definition") || ... + ~isscalar(application) + error("labkit:app:runtime:InvariantFailure", ... + "SessionDiagnostics requires one Definition."); + end + if ~isa(stream, "labkit.app.internal.SessionEventStream") || ... + ~isscalar(stream) + error("labkit:app:runtime:InvariantFailure", ... + "SessionDiagnostics requires one SessionEventStream."); + end + if ~isa(projection, ... + "labkit.app.internal.SessionJournalProjection") || ... + ~isscalar(projection) + error("labkit:app:runtime:InvariantFailure", ... + "SessionDiagnostics requires one journal projection."); + end + if ~isa(journal, "labkit.app.internal.SessionJournal") || ... + ~isscalar(journal) + error("labkit:app:runtime:InvariantFailure", ... + "SessionDiagnostics requires one SessionJournal."); + end + obj.Application = application; + obj.Stream = stream; + obj.Projection = projection; + obj.Journal = journal; + end + + function operation = begin(obj, category, eventName, message, varargin) + operation = obj.Stream.begin( ... + category, eventName, message, varargin{:}); + end + + function finish(obj, operation, operationResult, ... + stateDisposition, exception) + if nargin < 5 + exception = []; + end + obj.Stream.finish( ... + operation, operationResult, stateDisposition, exception); + end + + function log(obj, severity, eventName, message, varargin) + obj.Stream.log(severity, eventName, message, varargin{:}); + end + + function events = events(obj) + events = obj.Stream.records(); + end + + function snapshot = captureSnapshot(obj) + streamSnapshot = obj.Stream.captureSnapshot(); + manifest = obj.Journal.manifest(); + health = obj.Journal.healthSnapshot(); + degradation = manifest.degradation; + snapshot = struct( ... + "events", streamSnapshot.events, ... + "traceEnabled", streamSnapshot.traceEnabled, ... + "inMemoryTruncated", streamSnapshot.inMemoryTruncated, ... + "retainedRecordCount", ... + streamSnapshot.retainedRecordCount, ... + "totalRecordCount", streamSnapshot.totalRecordCount, ... + "journalAvailable", health.available, ... + "journalState", string(health.state), ... + "droppedRecordCount", ... + double(degradation.droppedRecordCount), ... + "coalescedRecordCount", ... + double(degradation.coalescedRecordCount), ... + "expiredSegmentCount", ... + double(degradation.expiredSegmentCount), ... + "degradationReason", ... + string(health.degradationReason)); + end + + function token = subscribe(obj, callback) + token = obj.Stream.subscribe(callback); + end + + function unsubscribe(obj, token) + obj.Stream.unsubscribe(token); + end + + function setTraceEnabled(obj, enabled) + obj.Stream.setTraceEnabled(enabled); + end + + function destination = exportBundle( ... + obj, destination, excludeOperationId) + if nargin < 3 + excludeOperationId = ""; + end + obj.Journal.flush(); + streamSnapshot = obj.Stream.captureSnapshot(); + manifest = obj.Journal.manifest(); + events = streamSnapshot.events; + degradation = manifest.degradation; + try + archived = ... + labkit.app.internal.SessionJournalArchive.snapshot( ... + obj.Journal.rootFolder(), ... + obj.Journal.sessionId()); + events = mergeEvents( ... + archived.events, streamSnapshot.events); + degradation = archived.degradation; + catch + % A surviving in-memory stream remains exportable when the + % persistent journal is unavailable or unreadable. + end + if strlength(string(excludeOperationId)) > 0 && ... + ~isempty(events) + events = events( ... + string({events.operationId}) ~= ... + string(excludeOperationId)); + end + capture = struct( ... + "traceEnabled", streamSnapshot.traceEnabled, ... + "inMemoryTruncated", ... + streamSnapshot.inMemoryTruncated, ... + "retainedRecordCount", ... + streamSnapshot.retainedRecordCount, ... + "totalRecordCount", ... + streamSnapshot.totalRecordCount); + snapshot = struct( ... + "manifest", manifest, "events", events, ... + "degradation", degradation, "capture", capture); + destination = ... + labkit.app.internal.SessionDiagnosticBundle.write( ... + snapshot, destination); + end + + function close(obj) + if obj.Closed + return; + end + try + obj.Stream.close(); + catch + % Stream teardown must not prevent projection cleanup. + end + try + obj.Projection.close(); + catch + % Journal teardown must not change Runtime close semantics. + end + try + obj.Stream.refreshProjectionHealth(); + catch + % Health refresh must not change Runtime close semantics. + end + obj.Closed = true; + end + + function delete(obj) + obj.close(); + end + end +end + +function value = mergeEvents(archived, retained) +if isempty(archived) + value = retained(:); + return; +end +if isempty(retained) + value = archived(:); + return; +end +combined = [archived(:); retained(:)]; +sequences = double([combined.sequence]); +[~, last] = unique(sequences, "last"); +value = combined(last); +[~, order] = sort(double([value.sequence])); +value = value(order); +end diff --git a/+labkit/+app/+internal/SessionEventStream.m b/+labkit/+app/+internal/SessionEventStream.m new file mode 100644 index 000000000..fe087c60c --- /dev/null +++ b/+labkit/+app/+internal/SessionEventStream.m @@ -0,0 +1,531 @@ +classdef (Hidden, Sealed) SessionEventStream < handle + %SESSIONEVENTSTREAM Private privacy-safe in-memory session event stream. + % Expected callers are the private App Runtime and focused framework tests. + % Records are validated before entering the bounded ring; persistence and + % viewer projections intentionally belong to later migration checkpoints. + + properties (Access = private) + Application + SessionId (1, 1) string + StartedAt (1, 1) datetime + StartedTimer + Sequence (1, 1) double = 0 + OperationSequence (1, 1) double = 0 + Records + OperationStack (1, :) cell = {} + FinishedOperationIds (1, :) string = strings(1, 0) + ProjectionHook = [] + ProjectionHealthHook = [] + ProjectionHealthUnavailableReported (1, 1) logical = false + TraceEnabled (1, 1) logical = false + ConsumerSequence (1, 1) double = 0 + Consumers (1, :) struct = struct( ... + "Id", strings(1, 0), "Callback", cell(1, 0)) + Closed (1, 1) logical = false + end + + properties (Constant, Access = private) + % A temporary immediate-view bound until Phase 3 profiles durable policy. + ProvisionalInMemoryRecordLimit = 512 + end + + methods + function obj = SessionEventStream(application, varargin) + if ~isa(application, "labkit.app.Definition") || ~isscalar(application) + error("labkit:app:runtime:InvariantFailure", ... + "SessionEventStream requires one Definition."); + end + sessionId = optionValue(varargin, "SessionId", ... + labkit.app.internal.SessionIdentity.create()); + projectionHook = optionValue(varargin, "ProjectionHook", []); + projectionHealthHook = optionValue(varargin, "ProjectionHealthHook", []); + traceEnabled = optionValue(varargin, "TraceEnabled", false); + if ~isempty(projectionHook) && ~isa(projectionHook, "function_handle") + error("labkit:app:contract:InvalidValue", ... + "Session event ProjectionHook must be a function handle."); + end + if ~isempty(projectionHealthHook) && ~isa(projectionHealthHook, "function_handle") + error("labkit:app:contract:InvalidValue", ... + "Session event ProjectionHealthHook must be a function handle."); + end + if ~(islogical(traceEnabled) && isscalar(traceEnabled)) + error("labkit:app:contract:InvalidValue", ... + "Session event TraceEnabled must be scalar logical."); + end + obj.Application = application; + obj.SessionId = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + sessionId, "sessionId"); + obj.StartedAt = datetime("now", TimeZone="UTC", ... + Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + obj.StartedTimer = tic; + obj.Records = repmat(recordTemplate(), 0, 1); + obj.ProjectionHook = projectionHook; + obj.ProjectionHealthHook = projectionHealthHook; + obj.TraceEnabled = traceEnabled; + obj.log("info", "session.started", "Session started.", ... + Category="runtime.lifecycle", Audience="developer"); + end + + function operation = begin(obj, category, eventName, message, varargin) + obj.ensureOpen(); + category = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + category, "category"); + eventName = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + eventName, "eventName"); + message = labkit.app.internal.SessionEventValidator.privacySafeText( ... + message, "message"); + attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + optionValue(varargin, "Attributes", struct())); + obj.OperationSequence = obj.OperationSequence + 1; + parent = obj.currentOperation(); + operation = struct( ... + "Id", "op-" + string(obj.OperationSequence), ... + "ParentId", parent.Id, ... + "RootActionId", parent.RootActionId, ... + "Category", category, "EventName", eventName, ... + "SessionId", obj.SessionId, "Timer", tic); + if strlength(operation.RootActionId) == 0 + operation.RootActionId = operation.Id; + end + obj.OperationStack{end + 1} = operation; + obj.log("debug", eventName + ".started", message, ... + Category=category, Audience="developer", Attributes=attributes, ... + Operation=operation); + end + + function finish(obj, operation, operationResult, stateDisposition, exception) + if nargin < 4 + error("labkit:app:contract:InvalidValue", ... + "Session operation finish requires a state disposition."); + end + if nargin < 5 + exception = []; + end + obj.ensureOpen(); + operation = validOperation(operation); + obj.requireActiveTopOperation(operation); + terminal = labkit.app.internal.SessionEventValidator.terminalFields( ... + operationResult, stateDisposition); + if ~isempty(exception) && terminal.operationResult ~= "failed" + error("labkit:app:contract:InvalidValue", ... + "A session exception requires a failed operation result."); + end + severity = "debug"; + if ~isempty(exception) + severity = "error"; + elseif terminal.operationResult ~= "completed" + severity = "warning"; + end + obj.log(severity, operation.EventName + "." + terminal.operationResult, ... + terminalMessage(terminal.operationResult), Category=operation.Category, ... + Audience="developer", ... + Attributes=struct("durationSeconds", toc(operation.Timer)), ... + Terminal=terminal, ... + Exception=exception, Operation=operation); + obj.OperationStack(end) = []; + obj.rememberFinishedOperation(operation.Id); + end + + function log(obj, severity, eventName, message, varargin) + obj.ensureOpen(); + values = labkit.app.internal.SessionEventValidator.logInputs( ... + severity, eventName, message, ... + optionValue(varargin, "Category", "runtime.lifecycle"), ... + optionValue(varargin, "Audience", "developer"), ... + optionValue(varargin, "Attributes", struct()), ... + optionValue(varargin, "Exception", [])); + severity = values.severity; + eventName = values.eventName; + message = values.message; + category = values.category; + audience = values.audience; + attributes = values.attributes; + exception = exceptionProjection(values.exception); + if severity == "trace" && ~obj.TraceEnabled + return; + end + operation = optionValue(varargin, "Operation", []); + if isempty(operation) + operation = obj.currentOperation(); + else + operation = validOperation(operation); + obj.requireActiveOperation(operation); + end + obj.Sequence = obj.Sequence + 1; + record = recordTemplate(); + record.schemaVersion = 1; + record.sequence = obj.Sequence; + record.timestampUtc = string(datetime("now", TimeZone="UTC", ... + Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")); + record.elapsedSeconds = toc(obj.StartedTimer); + record.severity = upper(severity); + record.audience = audience; + record.category = category; + record.eventName = eventName; + record.message = message; + record.attributes = attributes; + record.sessionId = obj.SessionId; + record.appId = obj.Application.AppId; + record.operationId = operation.Id; + record.parentOperationId = operation.ParentId; + record.rootActionId = operation.RootActionId; + terminal = optionValue(varargin, "Terminal", []); + if ~isempty(terminal) + terminal = labkit.app.internal.SessionEventValidator.terminalFields( ... + terminal.operationResult, terminal.stateDisposition); + record.operationResult = terminal.operationResult; + record.stateDisposition = terminal.stateDisposition; + end + if isfield(attributes, "durationSeconds") + record.durationSeconds = double(attributes.durationSeconds); + end + record.exception = exception; + obj.retain(record); + end + + function records = records(obj) + records = obj.Records; + end + + function snapshot = captureSnapshot(obj) + snapshot = struct( ... + "events", obj.Records, ... + "traceEnabled", obj.TraceEnabled, ... + "inMemoryTruncated", obj.Sequence > numel(obj.Records), ... + "retainedRecordCount", numel(obj.Records), ... + "totalRecordCount", obj.Sequence); + end + + function setTraceEnabled(obj, enabled) + obj.ensureOpen(); + if ~(islogical(enabled) && isscalar(enabled)) + error("labkit:app:contract:InvalidValue", ... + "Trace capture state must be scalar logical."); + end + if obj.TraceEnabled == enabled + return; + end + obj.TraceEnabled = enabled; + if enabled + obj.log("info", "trace.capture_enabled", ... + "Trace capture enabled.", ... + Category="runtime.lifecycle", Audience="developer"); + else + obj.log("info", "trace.capture_disabled", ... + "Trace capture disabled.", ... + Category="runtime.lifecycle", Audience="developer"); + end + end + + function token = subscribe(obj, callback) + obj.ensureOpen(); + if ~isa(callback, "function_handle") + error("labkit:app:contract:InvalidValue", ... + "Session event consumer must be a function handle."); + end + obj.ConsumerSequence = obj.ConsumerSequence + 1; + token = "consumer-" + string(obj.ConsumerSequence); + obj.Consumers(end + 1) = struct( ... + "Id", token, "Callback", callback); + end + + function unsubscribe(obj, token) + token = string(token); + if ~isscalar(token) || ismissing(token) + return; + end + keep = string({obj.Consumers.Id}) ~= token; + obj.Consumers = obj.Consumers(keep); + end + + function refreshProjectionHealth(obj) + % Allow private close teardown to expose a final sink failure. + obj.drainProjectionHealth(); + end + + function close(obj) + if obj.Closed + return; + end + while ~isempty(obj.OperationStack) + obj.finish(obj.OperationStack{end}, "abandoned", "unknown"); + end + obj.log("info", "session.closed", "Session closed.", ... + Category="runtime.lifecycle", Audience="developer"); + obj.Closed = true; + end + end + + methods (Access = private) + function operation = currentOperation(obj) + operation = emptyOperation(); + if ~isempty(obj.OperationStack) + operation = obj.OperationStack{end}; + end + end + + function ensureOpen(obj) + if obj.Closed + error("labkit:app:runtime:OperationClosed", ... + "Session event stream is closed."); + end + end + + function requireActiveOperation(obj, operation) + if operation.SessionId ~= obj.SessionId + error("labkit:app:runtime:UnknownOperation", ... + "Session operation does not belong to this stream."); + end + ids = string(cellfun(@(entry) entry.Id, obj.OperationStack, ... + UniformOutput=false)); + if ~any(ids == operation.Id) + if any(obj.FinishedOperationIds == operation.Id) + error("labkit:app:runtime:OperationAlreadyFinished", ... + "Session operation already has a terminal result."); + end + error("labkit:app:runtime:UnknownOperation", ... + "Session operation is not active."); + end + end + + function requireActiveTopOperation(obj, operation) + obj.requireActiveOperation(operation); + if obj.OperationStack{end}.Id ~= operation.Id + error("labkit:app:runtime:OutOfOrderOperation", ... + "Nested session operations must finish in stack order."); + end + end + + function rememberFinishedOperation(obj, id) + obj.FinishedOperationIds(end + 1) = string(id); + if numel(obj.FinishedOperationIds) > obj.ProvisionalInMemoryRecordLimit + obj.FinishedOperationIds(1) = []; + end + end + + function retain(obj, record, notifyProjection) + if nargin < 3 + notifyProjection = true; + end + obj.Records(end + 1, 1) = record; + if numel(obj.Records) > obj.ProvisionalInMemoryRecordLimit + obj.Records(1) = []; + end + if notifyProjection + obj.notifyProjectionHook(record); + end + obj.notifyConsumers(record); + if notifyProjection + obj.drainProjectionHealth(record); + end + end + + function notifyProjectionHook(obj, record) + if isempty(obj.ProjectionHook) + return; + end + try + obj.ProjectionHook(record); + catch + % Downstream projections must not alter canonical history. + end + end + + function drainProjectionHealth(obj, contextRecord) + if nargin < 2 + contextRecord = []; + end + if isempty(obj.ProjectionHealthHook) + return; + end + try + notifications = obj.ProjectionHealthHook(); + if isempty(notifications) + return; + end + if ~isstruct(notifications) + error("labkit:app:runtime:InvariantFailure", ... + "Projection health notifications must be structs."); + end + for index = 1:numel(notifications) + [eventName, attributes] = projectionHealthNotification( ... + notifications(index)); + obj.retainProjectionHealth(eventName, attributes, contextRecord); + end + catch + obj.ProjectionHealthHook = []; + if ~obj.ProjectionHealthUnavailableReported + obj.ProjectionHealthUnavailableReported = true; + obj.retainProjectionHealth("journal.degraded", ... + struct("reason", "health-unavailable"), contextRecord); + end + end + end + + function retainProjectionHealth(obj, eventName, attributes, contextRecord) + if nargin < 4 + contextRecord = []; + end + attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + attributes); + if isempty(contextRecord) + operation = obj.currentOperation(); + operationId = operation.Id; + parentOperationId = operation.ParentId; + rootActionId = operation.RootActionId; + else + contextFields = ["operationId", "parentOperationId", "rootActionId"]; + if ~isstruct(contextRecord) || ~isscalar(contextRecord) || ... + ~all(isfield(contextRecord, contextFields)) + error("labkit:app:runtime:InvariantFailure", ... + "Projection health context record is invalid."); + end + operationId = string(contextRecord.operationId); + parentOperationId = string(contextRecord.parentOperationId); + rootActionId = string(contextRecord.rootActionId); + end + obj.Sequence = obj.Sequence + 1; + record = recordTemplate(); + record.schemaVersion = 1; + record.sequence = obj.Sequence; + record.timestampUtc = string(datetime("now", TimeZone="UTC", ... + Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")); + record.elapsedSeconds = toc(obj.StartedTimer); + record.severity = "WARNING"; + record.audience = "developer"; + record.category = "runtime.journal"; + record.eventName = eventName; + record.message = projectionHealthMessage(eventName); + record.attributes = attributes; + record.sessionId = obj.SessionId; + record.appId = obj.Application.AppId; + record.operationId = operationId; + record.parentOperationId = parentOperationId; + record.rootActionId = rootActionId; + record.exception = emptyException(); + obj.retain(record, false); + end + + function notifyConsumers(obj, record) + if isempty(obj.Consumers) + return; + end + keep = true(1, numel(obj.Consumers)); + for index = 1:numel(obj.Consumers) + try + obj.Consumers(index).Callback(record); + catch + keep(index) = false; + end + end + obj.Consumers = obj.Consumers(keep); + end + end +end + +function record = recordTemplate() +record = struct( ... + "schemaVersion", 1, "sequence", 0, "timestampUtc", "", ... + "elapsedSeconds", 0, "severity", "", "audience", "", ... + "category", "", "eventName", "", "message", "", ... + "attributes", struct(), "sessionId", "", "appId", "", ... + "operationId", "", "parentOperationId", "", "rootActionId", "", ... + "operationResult", "", "stateDisposition", "", "durationSeconds", [], ... + "exception", emptyException()); +end + +function operation = emptyOperation() +operation = struct("Id", "", "ParentId", "", "RootActionId", "", ... + "Category", "", "EventName", "", "SessionId", "", "Timer", []); +end + +function value = optionValue(values, name, defaultValue) +value = defaultValue; +if mod(numel(values), 2) ~= 0 + error("labkit:app:contract:InvalidValue", ... + "Session event options must be name-value pairs."); +end +for index = 1:2:numel(values) + optionName = string(values{index}); + if optionName == string(name) + value = values{index + 1}; + return; + end +end +end + +function operation = validOperation(operation) +needed = ["Id", "ParentId", "RootActionId", "Category", "EventName", "SessionId", "Timer"]; +if ~isstruct(operation) || ~isscalar(operation) || ~all(isfield(operation, needed)) + error("labkit:app:runtime:InvariantFailure", "Session operation is invalid."); +end +end + +function message = terminalMessage(operationResult) +if operationResult == "completed" + message = "Operation completed."; +elseif operationResult == "failed" + message = "Operation failed."; +else + message = "Operation settled."; +end +end + +function exception = emptyException() +exception = struct("identifier", "", "message", "", "stack", strings(0, 1)); +end + +function [eventName, attributes] = projectionHealthNotification(notification) +if ~isstruct(notification) || ~isscalar(notification) || ... + ~isequal(string(fieldnames(notification)), ["eventName"; "reason"; "count"]) + error("labkit:app:runtime:InvariantFailure", ... + "Projection health notification is invalid."); +end +eventName = string(notification.eventName); +attributes = labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + struct("reason", notification.reason)); +count = notification.count; +if eventName == "journal.degraded" + if ~(isnumeric(count) && isreal(count) && isscalar(count) && ... + isfinite(count) && count == 0) + error("labkit:app:runtime:InvariantFailure", ... + "Journal degradation notifications use a fixed count."); + end +elseif eventName == "journal.records_dropped" + if ~(isnumeric(count) && isreal(count) && isscalar(count) && ... + isfinite(count) && count >= 1 && count == fix(count)) + error("labkit:app:runtime:InvariantFailure", ... + "Journal drop notifications require a positive integer delta."); + end + attributes.count = double(count); +else + error("labkit:app:runtime:InvariantFailure", ... + "Projection health event is not part of the closed protocol."); +end +end + +function message = projectionHealthMessage(eventName) +if eventName == "journal.degraded" + message = "Detailed session logging became unavailable; in-memory diagnostics continue."; +elseif eventName == "journal.records_dropped" + message = "Some detailed session log records could not be saved."; +else + error("labkit:app:runtime:InvariantFailure", ... + "Projection health event is not part of the closed protocol."); +end +end + + +function exception = exceptionProjection(value) +exception = emptyException(); +if isempty(value) + return; +end +if ~isa(value, "MException") || ~isscalar(value) + error("labkit:app:contract:InvalidValue", ... + "Session event Exception must be a scalar MException."); +end +exception.identifier = string(value.identifier); +exception.message = "Exception captured."; +exception.stack = string({value.stack.name}).'; +end diff --git a/+labkit/+app/+internal/SessionEventValidator.m b/+labkit/+app/+internal/SessionEventValidator.m new file mode 100644 index 000000000..023d3a5ce --- /dev/null +++ b/+labkit/+app/+internal/SessionEventValidator.m @@ -0,0 +1,389 @@ +classdef (Hidden, Sealed) SessionEventValidator + %SESSIONEVENTVALIDATOR Validate privacy-safe private session event inputs. + + methods (Static) + function values = logInputs(severity, eventName, message, ... + category, audience, attributes, exception) + values = struct( ... + "severity", labkit.app.internal.SessionEventValidator.severity(severity), ... + "eventName", labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + eventName, "eventName"), ... + "message", labkit.app.internal.SessionEventValidator.privacySafeText( ... + message, "message"), ... + "category", labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + category, "category"), ... + "audience", labkit.app.internal.SessionEventValidator.audience(audience), ... + "attributes", labkit.app.internal.SessionEventValidator.privacySafeAttributes( ... + attributes), ... + "exception", labkit.app.internal.SessionEventValidator.exception(exception)); + end + + function value = semanticIdentifier(value, name) + if ~(ischar(value) || (isstring(value) && isscalar(value))) || ... + strlength(strip(string(value))) == 0 + error("labkit:app:contract:InvalidValue", ... + "Session event %s must be nonempty scalar text.", name); + end + value = string(value); + if isempty(regexp(char(value), "^[A-Za-z][A-Za-z0-9._-]*$", "once")) + error("labkit:app:contract:InvalidValue", ... + "Session event %s must be a semantic identifier.", name); + end + end + + function value = severity(value) + value = lower(labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + value, "severity")); + if ~any(value == ["trace", "debug", "info", "warning", "error", "critical"]) + error("labkit:app:contract:InvalidValue", ... + "Unsupported log severity: %s.", value); + end + end + + function value = audience(value) + value = lower(labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + value, "audience")); + if ~any(value == ["user", "developer"]) + error("labkit:app:contract:InvalidValue", ... + "Session event audience must be user or developer."); + end + end + + function value = privacySafeText(value, name) + if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Session event %s must be scalar text.", name); + end + value = string(value); + if strlength(value) > 512 + error("labkit:app:contract:InvalidValue", ... + "Session event %s exceeds the retained-text limit.", name); + end + if containsUnsafeAbsolutePath(value) || containsFilenameLikeLeaf(value) + error("labkit:app:contract:UnsafeLogData", ... + "Session event %s must not contain a path or original filename.", name); + end + end + + function attributes = privacySafeAttributes(attributes) + if ~isstruct(attributes) || ~isscalar(attributes) + unsafeAttributeData("must be one scalar struct"); + end + names = string(fieldnames(attributes)); + if numel(names) > maximumRetainedAttributeFieldCount() + unsafeAttributeData("exceed the retained field limit"); + end + for index = 1:numel(names) + name = attributeKeyIdentifier(names(index), "key"); + value = attributes.(name); + rejectSensitiveAttributeKey(name, value); + if name == "dimensions" + attributes.(name) = validateDimensions(value); + elseif isScalarText(value) + if ~any(name == retainedTextAttributeKeys()) + unsafeAttributeData("only allow text for controlled semantic keys"); + end + attributes.(name) = validateRetainedText(name, value); + elseif isnumeric(value) || islogical(value) + if ~(isscalar(value) && isreal(value) && isfinite(double(value))) + unsafeAttributeData("must use finite scalar numeric or logical values"); + end + else + unsafeAttributeData("must not contain arrays, nested values, or MATLAB objects"); + end + end + attributes = orderfields(attributes); + if isfield(attributes, "dimensions") + attributes.dimensions = orderfields(attributes.dimensions); + end + dimensionAxes = 0; + if isfield(attributes, "dimensions") + dimensionAxes = numel(fieldnames(attributes.dimensions)); + end + if numel(names) + dimensionAxes > maximumRetainedAttributeFieldCount() + unsafeAttributeData("exceed the retained total field limit"); + end + if utf8ByteCount(jsonencode(attributes)) > maximumRetainedAttributeJsonBytes() + unsafeAttributeData("exceed the retained canonical JSON byte limit"); + end + end + + function value = exception(value) + if ~isempty(value) && (~isa(value, "MException") || ~isscalar(value)) + error("labkit:app:contract:InvalidValue", ... + "Session event Exception must be a scalar MException."); + end + end + + function terminal = terminalFields(operationResult, stateDisposition) + operationResult = lower( ... + labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + operationResult, "operationResult")); + stateDisposition = lower( ... + labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + stateDisposition, "stateDisposition")); + if operationResult == "completed" && ... + any(stateDisposition == ["committed", "notapplicable"]) + terminal = struct("operationResult", "completed", ... + "stateDisposition", ternary(stateDisposition == "committed", ... + "committed", "notApplicable")); + elseif operationResult == "failed" && ... + any(stateDisposition == ["rolledback", "notapplicable"]) + terminal = struct("operationResult", "failed", ... + "stateDisposition", ternary(stateDisposition == "rolledback", ... + "rolledBack", "notApplicable")); + elseif operationResult == "abandoned" && stateDisposition == "unknown" + terminal = struct("operationResult", "abandoned", ... + "stateDisposition", "unknown"); + else + error("labkit:app:contract:InvalidValue", ... + "Session terminal result and state disposition are incompatible."); + end + end + + function tf = canonicalTerminalPair(operationResult, stateDisposition) + if ~(ischar(operationResult) || ... + (isstring(operationResult) && isscalar(operationResult))) || ... + ~(ischar(stateDisposition) || ... + (isstring(stateDisposition) && isscalar(stateDisposition))) + tf = false; + return; + end + operationResult = string(operationResult); + stateDisposition = string(stateDisposition); + if strlength(operationResult) == 0 || strlength(stateDisposition) == 0 + tf = strlength(operationResult) == 0 && ... + strlength(stateDisposition) == 0; + return; + end + try + terminal = labkit.app.internal.SessionEventValidator.terminalFields( ... + operationResult, stateDisposition); + tf = operationResult == terminal.operationResult && ... + stateDisposition == terminal.stateDisposition; + catch + tf = false; + end + end + end +end + +function value = ternary(condition, trueValue, falseValue) +if condition + value = trueValue; +else + value = falseValue; +end +end + +function value = validateRetainedText(key, value) +if key == "unit" + value = validateUnitToken(value); +elseif key == "sourceAlias" + value = validateSourceAlias(value); +else + value = retainedSemanticToken(value, "text value"); +end +end + +function value = validateUnitToken(value) +if ~isScalarText(value) + unsafeAttributeData("unit must be one short controlled token"); +end +value = string(value); +unitCharacters = [char(181), char(956), char(8486), char(176)]; +factor = "[A-Za-z" + string(unitCharacters) + "]+(?:\^-?[1-9][0-9]*)?"; +pattern = "^(1|%|a\.u\." + "|" + factor + "(?:[*/.]" + factor + ")*)$"; +if strlength(value) > maximumRetainedSemanticTokenLength() || ... + strlength(value) > maximumRetainedUnitTokenLength() || ... + isempty(regexp(char(value), char(pattern), "once")) + unsafeAttributeData("unit must be one short controlled token"); +end +end + +function value = validateSourceAlias(value) +if ~isScalarText(value) + unsafeAttributeData("sourceAlias must use the framework source-N form"); +end +value = string(value); +if strlength(value) > maximumRetainedSemanticTokenLength() || ... + isempty(regexp(char(value), '^source-[1-9][0-9]*$', "once")) + unsafeAttributeData("sourceAlias must use the framework source-N form"); +end +end + +function value = validateDimensions(value) +if ~isstruct(value) || ~isscalar(value) + unsafeAttributeData("dimensions must be one fixed scalar object"); +end +names = string(fieldnames(value)); +if isempty(names) || numel(names) > maximumDimensionAxisCount() + unsafeAttributeData("dimensions exceed the axis limit"); +end +for index = 1:numel(names) + name = attributeKeyIdentifier(names(index), "dimension axis"); + axisLength = value.(name); + if ~(isnumeric(axisLength) && isreal(axisLength) && isscalar(axisLength) && ... + isfinite(axisLength) && axisLength >= 1 && axisLength == fix(axisLength)) + unsafeAttributeData("dimension axes must be positive integer scalars"); + end + value.(name) = double(axisLength); +end +end + +function value = attributeKeyIdentifier(value, label) +if ~isScalarText(value) + unsafeAttributeData(label + " must be scalar semantic text"); +end +value = string(value); +if strlength(value) > maximumRetainedAttributeKeyLength() || ... + isempty(regexp(char(value), '^[A-Za-z][A-Za-z0-9._-]*$', "once")) + unsafeAttributeData(label + " must be a short semantic identifier"); +end +end + +function value = retainedSemanticToken(value, label) +if ~isScalarText(value) + unsafeAttributeData(label + " must be scalar semantic text"); +end +value = string(value); +if strlength(value) > maximumRetainedSemanticTokenLength() || ... + isempty(regexp(char(value), '^[A-Za-z][A-Za-z0-9._-]*$', "once")) + unsafeAttributeData(label + " must be a short semantic identifier"); +end +end + +function rejectSensitiveAttributeKey(key, value) +if any(key == retainedTextAttributeKeys()) || key == "dimensions" + return; +end +normalized = lower(erase(key, [".", "_", "-"])); +if isSafeScalarFact(key, value) + return; +end +forbiddenFragments = ["subject", "device", "serial", "sample", "signal", ... + "path", "file", "name", "value", "data", "content", "metadata", ... + "message", "freetext", "user", "identifier"]; +if any(contains(normalized, forbiddenFragments)) || endsWith(normalized, "id") + unsafeAttributeData("key denotes retained identity, content, or scientific data"); +end +end + +function tf = isSafeScalarFact(key, value) +tf = (isnumeric(value) || islogical(value)) && isreal(value) && isscalar(value) && ... + isfinite(double(value)) && (key == "ordinal" || key == "count" || ... + endsWith(key, ["Count", "Index", "DurationSeconds"])); +end + +function keys = retainedTextAttributeKeys() +keys = ["enum", "unit", "reason", "runtimeAlias", "sourceAlias"]; +end + +function tf = isScalarText(value) +tf = (isstring(value) && isscalar(value) && ~ismissing(value)) || ... + (ischar(value) && isrow(value)); +end + +function count = maximumRetainedAttributeFieldCount() +count = 16; +end + +function count = maximumDimensionAxisCount() +count = 4; +end + +function count = maximumRetainedAttributeKeyLength() +count = 64; +end + +function count = maximumRetainedSemanticTokenLength() +count = 64; +end + +function count = maximumRetainedUnitTokenLength() +count = 24; +end + +function count = maximumRetainedAttributeJsonBytes() +% Bound one fully validated canonical object before it reaches the ring/disk. +count = 1024; +end + +function count = utf8ByteCount(value) +count = numel(unicode2native(char(string(value)), "UTF-8")); +end + +function unsafeAttributeData(detail) +error("labkit:app:contract:UnsafeLogData", ... + "Session event attributes %s.", detail); +end + +function tf = containsUnsafeAbsolutePath(value) +text = char(value); +tf = containsWindowsDrivePath(text) || containsUncPath(text) || ... + containsPosixAbsolutePath(text); +end + +function tf = containsWindowsDrivePath(text) +tf = false; +for index = 1:max(0, strlength(string(text)) - 2) + if isstrprop(text(index), "alpha") && text(index + 1) == ':' && ... + isPathSeparator(text(index + 2)) && isPathTokenBoundary(text, index) + tf = true; + return; + end +end +end + +function tf = containsUncPath(text) +tf = false; +for index = 1:max(0, strlength(string(text)) - 2) + if text(index) == char(92) && text(index + 1) == char(92) && ... + isstrprop(text(index + 2), "alphanum") && ... + isPathTokenBoundary(text, index) + tf = true; + return; + end +end +end + +function tf = containsPosixAbsolutePath(text) +tf = false; +for index = 1:max(0, strlength(string(text)) - 1) + if text(index) == '/' && isstrprop(text(index + 1), "alphanum") && ... + isPathTokenBoundary(text, index) + tf = true; + return; + end +end +end + +function tf = isPathSeparator(character) +tf = character == '/' || character == char(92); +end + +function tf = isPathTokenBoundary(text, index) +tf = index == 1 || (~isstrprop(text(index - 1), "alphanum") && ... + text(index - 1) ~= '_' && text(index - 1) ~= ':'); +end + +function tf = containsFilenameLikeLeaf(value) +extensions = [".csv", ".mat", ".json", ".txt", ".png", ".jpg", ... + ".jpeg", ".tif", ".tiff", ".avi", ".xlsx", ".dta", ".rhs"]; +text = char(lower(value)); +tf = false; +for extension = extensions + starts = strfind(text, char(extension)); + for startIndex = starts + extensionEnd = startIndex + strlength(extension) - 1; + hasLeafStem = startIndex > 1 && ... + isstrprop(text(startIndex - 1), "alphanum"); + hasLeafBoundary = extensionEnd == strlength(string(text)) || ... + ~isstrprop(text(extensionEnd + 1), "alphanum"); + if hasLeafStem && hasLeafBoundary + tf = true; + return; + end + end +end +end diff --git a/+labkit/+app/+internal/SessionIdentity.m b/+labkit/+app/+internal/SessionIdentity.m new file mode 100644 index 000000000..918c603eb --- /dev/null +++ b/+labkit/+app/+internal/SessionIdentity.m @@ -0,0 +1,12 @@ +classdef (Hidden, Sealed) SessionIdentity + %SESSIONIDENTITY Create private session identifiers without RNG side effects. + + methods (Static) + function sessionId = create() + temporaryPath = string(tempname); + [~, leaf] = fileparts(temporaryPath); + sessionId = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + "session-" + string(leaf), "sessionId"); + end + end +end diff --git a/+labkit/+app/+internal/SessionJournal.m b/+labkit/+app/+internal/SessionJournal.m new file mode 100644 index 000000000..0ea1167fe --- /dev/null +++ b/+labkit/+app/+internal/SessionJournal.m @@ -0,0 +1,616 @@ +classdef (Hidden, Sealed) SessionJournal < handle + %SESSIONJOURNAL Buffered writer for already-validated canonical events. + % This private projection owns one live session only. Archive inspection, + % recovery, retention, and export belong to SessionJournalArchive. + + properties (Access = private) + Application + RootFolder (1, 1) string + SessionId (1, 1) string + Folder (1, 1) string + ManifestFile (1, 1) string + ActiveFile (1, 1) string + SegmentFile (1, 1) string = "" + SegmentHandle (1, 1) double = -1 + SegmentIndex (1, 1) double = 0 + SegmentBytes (1, 1) double = 0 + BufferLines (1, :) string = strings(1, 0) + BufferBytes (1, 1) double = 0 + StartedAt (1, 1) datetime + Closed (1, 1) logical = false + ClosedAtUtc (1, 1) string = "" + Available (1, 1) logical = false + SegmentByteLimit (1, 1) double + SegmentLimit (1, 1) double + SessionByteLimit (1, 1) double + BufferRecordLimit (1, 1) double + BufferByteLimit (1, 1) double + DroppedRecordCount (1, 1) double = 0 + CoalescedRecordCount (1, 1) double = 0 + ExpiredSegmentCount (1, 1) double = 0 + WriteFailureCount (1, 1) double = 0 + InvalidRecordDropCount (1, 1) double = 0 + WriteFailureDropCount (1, 1) double = 0 + LastFailureReason (1, 1) string = "" + DegradationReason (1, 1) string = "" + LastCoalescingKey (1, 1) string = "" + LastCoalescingElapsedSeconds (1, 1) double = -inf + FaultInjector = [] + TestObserver = [] + LeaseClock = [] + LeaseProbe = [] + LeaseNonce (1, 1) string = "" + HeartbeatIntervalSeconds (1, 1) double = 30 + LastHeartbeatUtc (1, 1) string = "" + end + + properties (Constant, Access = private) + % Consecutive low-level duplicates only; terminal/visible events remain exact. + CoalescingWindowSeconds = 1 + end + + methods + function obj = SessionJournal(application, varargin) + if ~isa(application, "labkit.app.Definition") || ~isscalar(application) + error("labkit:app:runtime:InvariantFailure", ... + "SessionJournal requires one Definition."); + end + options = parseOptions(varargin{:}); + obj.Application = application; + obj.RootFolder = options.RootFolder; + obj.SessionId = options.SessionId; + obj.Folder = fullfile(obj.RootFolder, "sessions", obj.SessionId); + obj.ManifestFile = fullfile(obj.Folder, "manifest.json"); + obj.ActiveFile = fullfile(obj.Folder, "active.json"); + obj.SegmentByteLimit = options.SegmentByteLimit; + obj.SegmentLimit = options.SegmentLimit; + obj.SessionByteLimit = options.SessionByteLimit; + obj.BufferRecordLimit = options.BufferRecordLimit; + obj.BufferByteLimit = options.BufferByteLimit; + obj.FaultInjector = options.FaultInjector; + obj.TestObserver = options.TestObserver; + obj.LeaseClock = options.LeaseClock; + obj.LeaseProbe = options.LeaseProbe; + obj.LeaseNonce = options.LeaseNonce; + obj.HeartbeatIntervalSeconds = options.HeartbeatIntervalSeconds; + obj.StartedAt = parseLeaseUtc(leaseNow(obj.LeaseClock)); + obj.initialize(); + end + + function append(obj, record) + if obj.Closed + return; + end + if ~obj.Available + obj.recordUnavailableDrop(); + return; + end + if ~isCanonicalRecord(record) + obj.DroppedRecordCount = obj.DroppedRecordCount + 1; + obj.InvalidRecordDropCount = obj.InvalidRecordDropCount + 1; + return; + end + try + if isFlushSeverity(record.severity) && ~obj.flush( ... + DeferFailureManifest=true) + obj.recordUnavailableDrop(); + obj.writeManifest(Force=true); + return; + end + candidateKey = obj.coalescingCandidateKey(record); + if obj.shouldCoalesce(candidateKey, record.elapsedSeconds) + obj.CoalescedRecordCount = obj.CoalescedRecordCount + 1; + return; + end + line = string(jsonencode(record)); + lineBytes = utf8Bytes(line) + 1; + obj.BufferLines(end + 1) = line; + obj.BufferBytes = obj.BufferBytes + lineBytes; + obj.rememberCoalescingCandidate(candidateKey, record.elapsedSeconds); + if isFlushSeverity(record.severity) || ... + numel(obj.BufferLines) >= obj.BufferRecordLimit || ... + obj.BufferBytes >= obj.BufferByteLimit + obj.flush(); + end + catch + obj.recordWriteFailure("write-failure"); + obj.writeManifest(Force=true); + end + end + + function written = flush(obj, varargin) + deferFailureManifest = false; + if ~isempty(varargin) + options = labkit.app.internal.OptionParser.parse( ... + "SessionJournal.flush", "DeferFailureManifest", varargin{:}); + deferFailureManifest = optionValue(options, ... + "DeferFailureManifest", false); + end + written = false; + if obj.Closed || ~obj.Available || isempty(obj.BufferLines) + written = obj.Available; + return; + end + failureReason = "open-failure"; + try + obj.ensureSegmentForBufferedRecords(); + failureReason = "write-failure"; + obj.invokeFault("write"); + for index = 1:numel(obj.BufferLines) + fprintf(obj.SegmentHandle, "%s\n", obj.BufferLines(index)); + end + obj.SegmentBytes = obj.SegmentBytes + obj.BufferBytes; + obj.notifyObserver("flush", numel(obj.BufferLines)); + obj.BufferLines = strings(1, 0); + obj.BufferBytes = 0; + failureReason = "flush-failure"; + obj.writeActiveMarker(); + obj.enforceSessionRetention(); + written = obj.writeManifest(); + catch + obj.recordWriteFailure(failureReason); + if ~deferFailureManifest + obj.writeManifest(Force=true); + end + end + end + + function close(obj) + if obj.Closed + return; + end + obj.flush(); + try + obj.closeSegment(); + catch + obj.recordPersistenceFailure("close-failure"); + end + obj.Closed = true; + obj.ClosedAtUtc = utcNow(); + try + obj.writeActiveMarker(true); + catch + obj.recordPersistenceFailure("close-failure"); + end + if obj.writeManifest(Force=true) + try + obj.removeActiveMarker(); + catch + obj.recordPersistenceFailure("close-failure"); + end + end + end + + function folder = folder(obj) + folder = obj.Folder; + end + + function folder = rootFolder(obj) + folder = obj.RootFolder; + end + + function manifest = manifest(obj) + manifest = manifestPayload(obj); + end + + function sessionId = sessionId(obj) + sessionId = obj.SessionId; + end + + function snapshot = healthSnapshot(obj) + % Return fixed in-memory health only; this method performs no I/O. + try + state = "healthy"; + if obj.Closed + state = "closed"; + elseif ~obj.Available + state = "unavailable"; + end + snapshot = struct("state", state, "available", obj.Available, ... + "droppedRecordCount", obj.DroppedRecordCount, ... + "invalidCanonicalRecordDropCount", obj.InvalidRecordDropCount, ... + "writeFailureDropCount", obj.WriteFailureDropCount, ... + "writeFailureCount", obj.WriteFailureCount, ... + "lastFailureReason", obj.LastFailureReason, ... + "degradationReason", obj.DegradationReason); + catch + snapshot = struct("state", "unavailable", "available", false, ... + "droppedRecordCount", 0, "invalidCanonicalRecordDropCount", 0, ... + "writeFailureDropCount", 0, "writeFailureCount", 0, ... + "lastFailureReason", "health-unavailable", ... + "degradationReason", "health-unavailable"); + end + end + + function delete(obj) + obj.close(); + end + end + + methods (Access = private) + function initialize(obj) + try + obj.invokeFault("initialize"); + if exist(char(obj.Folder), "dir") ~= 7 + mkdir(char(obj.Folder)); + end + obj.Available = true; + if ~obj.writeManifest() + return; + end + obj.writeActiveMarker(true); + catch + obj.recordPersistenceFailure("initialize-failure"); + end + end + + function ensureSegmentForBufferedRecords(obj) + if obj.SegmentHandle < 0 + obj.openNextSegment(); + elseif obj.SegmentBytes + obj.BufferBytes > obj.SegmentByteLimit && ... + obj.SegmentBytes > 0 + obj.closeSegment(); + obj.openNextSegment(); + end + end + + function openNextSegment(obj) + obj.SegmentIndex = obj.SegmentIndex + 1; + obj.SegmentFile = fullfile(obj.Folder, ... + "events-" + compose("%04d", obj.SegmentIndex) + ".jsonl"); + obj.invokeFault("open"); + obj.SegmentHandle = fopen(char(obj.SegmentFile), "a", "n", "UTF-8"); + if obj.SegmentHandle < 0 + error("labkit:app:runtime:JournalWriteFailure", ... + "Could not open the active session segment."); + end + obj.SegmentBytes = fileBytes(obj.SegmentFile); + obj.notifyObserver("open", obj.SegmentIndex); + end + + function closeSegment(obj) + if obj.SegmentHandle < 0 + return; + end + fclose(obj.SegmentHandle); + obj.SegmentHandle = -1; + obj.notifyObserver("close", obj.SegmentIndex); + end + + function enforceSessionRetention(obj) + segments = sessionSegments(obj.Folder); + totalBytes = sum([segments.bytes]); + while numel(segments) > obj.SegmentLimit || totalBytes > obj.SessionByteLimit + candidate = segments(1); + if string(fullfile(candidate.folder, candidate.name)) == obj.SegmentFile + break; + end + delete(fullfile(candidate.folder, candidate.name)); + obj.ExpiredSegmentCount = obj.ExpiredSegmentCount + 1; + segments = sessionSegments(obj.Folder); + totalBytes = sum([segments.bytes]); + end + end + + function recordWriteFailure(obj, reason) + if nargin < 2 + reason = "write-failure"; + end + obj.WriteFailureCount = obj.WriteFailureCount + 1; + dropped = numel(obj.BufferLines); + obj.DroppedRecordCount = obj.DroppedRecordCount + dropped; + obj.WriteFailureDropCount = obj.WriteFailureDropCount + dropped; + obj.closeSegment(); + obj.BufferLines = strings(1, 0); + obj.BufferBytes = 0; + obj.Available = false; + obj.LastFailureReason = string(reason); + obj.rememberDegradationReason(reason); + obj.LastCoalescingKey = ""; + obj.LastCoalescingElapsedSeconds = -inf; + end + + function writeActiveMarker(obj, force) + if nargin < 2 + force = false; + end + if ~obj.Available + return; + end + nowUtc = leaseNow(obj.LeaseClock); + if ~force && strlength(obj.LastHeartbeatUtc) > 0 && ... + seconds(parseLeaseUtc(nowUtc) - parseLeaseUtc(obj.LastHeartbeatUtc)) < ... + obj.HeartbeatIntervalSeconds + return; + end + probe = leaseProbe(obj.LeaseProbe); + marker = labkit.app.internal.SessionLease.create( ... + obj.SessionId, obj.Application.AppId, string(obj.StartedAt), ... + nowUtc, obj.LeaseNonce, probe); + obj.invokeFault("activeMarker"); + atomicWriteJson(obj.ActiveFile, marker); + obj.LastHeartbeatUtc = nowUtc; + end + + function removeActiveMarker(obj) + if exist(char(obj.ActiveFile), "file") == 2 + delete(char(obj.ActiveFile)); + end + end + + function written = writeManifest(obj, varargin) + written = false; + force = false; + if ~isempty(varargin) + options = labkit.app.internal.OptionParser.parse( ... + "SessionJournal.writeManifest", "Force", varargin{:}); + force = optionValue(options, "Force", false); + end + if (~obj.Available && ~force) || exist(char(obj.Folder), "dir") ~= 7 + return; + end + try + obj.invokeFault("manifest"); + atomicWriteJson(obj.ManifestFile, manifestPayload(obj)); + obj.notifyObserver("manifest", []); + written = true; + catch + obj.recordPersistenceFailure("manifest-failure"); + end + end + + function invokeFault(obj, stage) + if ~isempty(obj.FaultInjector) + obj.FaultInjector(stage); + end + end + + function notifyObserver(obj, stage, value) + if isempty(obj.TestObserver) + return; + end + try + obj.TestObserver(stage, value); + catch + end + end + + function key = coalescingCandidateKey(obj, record) + key = ""; + if ~any(string(record.severity) == ["TRACE", "DEBUG"]) || ... + isTerminalRecord(record) || ~isEmptyException(record.exception) + obj.LastCoalescingKey = ""; + obj.LastCoalescingElapsedSeconds = -inf; + return; + end + key = coalescingKey(record); + end + + function tf = shouldCoalesce(obj, key, elapsedSeconds) + withinWindow = elapsedSeconds - obj.LastCoalescingElapsedSeconds <= ... + obj.CoalescingWindowSeconds; + tf = strlength(obj.LastCoalescingKey) > 0 && ... + strlength(key) > 0 && obj.LastCoalescingKey == key && withinWindow; + end + + function rememberCoalescingCandidate(obj, key, elapsedSeconds) + if strlength(key) == 0 + return; + end + obj.LastCoalescingKey = key; + obj.LastCoalescingElapsedSeconds = elapsedSeconds; + end + + function recordUnavailableDrop(obj) + obj.DroppedRecordCount = obj.DroppedRecordCount + 1; + obj.WriteFailureDropCount = obj.WriteFailureDropCount + 1; + if strlength(obj.LastFailureReason) == 0 + obj.LastFailureReason = "journal-unavailable"; + end + obj.rememberDegradationReason("journal-unavailable"); + end + + function recordPersistenceFailure(obj, reason) + obj.WriteFailureCount = obj.WriteFailureCount + 1; + obj.LastFailureReason = string(reason); + obj.rememberDegradationReason(reason); + obj.Available = false; + end + + function rememberDegradationReason(obj, reason) + if strlength(obj.DegradationReason) == 0 + obj.DegradationReason = string(reason); + end + end + end +end + +function options = parseOptions(varargin) +% Provisional private bounds: rotation avoids one unbounded file; five +% segments cap ordinary context; 64 records/64 KiB bound buffered writes. +options = struct( ... + "RootFolder", string(fullfile(prefdir, "LabKit", "logs")), ... + "SessionId", labkit.app.internal.SessionIdentity.create(), ... + "SegmentByteLimit", 10 * 1024 * 1024, ... + "SegmentLimit", 5, ... + "SessionByteLimit", 50 * 1024 * 1024, ... + "BufferRecordLimit", 64, ... + "BufferByteLimit", 64 * 1024, ... + "FaultInjector", [], "TestObserver", [], "LeaseClock", [], "LeaseProbe", [], ... + "LeaseNonce", "", "HeartbeatIntervalSeconds", 30); +if mod(numel(varargin), 2) ~= 0 + error("labkit:app:contract:InvalidValue", ... + "SessionJournal options must be name-value pairs."); +end +for index = 1:2:numel(varargin) + name = string(varargin{index}); + if ~isfield(options, name) + error("labkit:app:contract:InvalidValue", ... + "Unknown SessionJournal option: %s.", name); + end + options.(name) = varargin{index + 1}; +end +options.RootFolder = string(options.RootFolder); +options.SessionId = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + options.SessionId, "SessionId"); +for name = ["SegmentByteLimit", "SegmentLimit", "SessionByteLimit", ... + "BufferRecordLimit", "BufferByteLimit"] + value = options.(name); + if ~(isnumeric(value) && isscalar(value) && isfinite(value) && value > 0 && ... + value == fix(value)) + error("labkit:app:contract:InvalidValue", ... + "SessionJournal %s must be a positive integer.", name); + end + options.(name) = double(value); +end +for name = ["FaultInjector", "TestObserver", "LeaseClock", "LeaseProbe"] + if ~isempty(options.(name)) && ~isa(options.(name), "function_handle") + error("labkit:app:contract:InvalidValue", ... + "SessionJournal %s must be a function handle.", name); + end +end +isTextNonce = (isstring(options.LeaseNonce) && isscalar(options.LeaseNonce)) || ... + (ischar(options.LeaseNonce) && isrow(options.LeaseNonce)); +if isTextNonce && strlength(string(options.LeaseNonce)) == 0 + options.LeaseNonce = labkit.app.internal.SessionLease.createNonce(); +end +options.LeaseNonce = labkit.app.internal.SessionLease.validateNonce(options.LeaseNonce); +if ~(isnumeric(options.HeartbeatIntervalSeconds) && isscalar(options.HeartbeatIntervalSeconds) && ... + isfinite(options.HeartbeatIntervalSeconds) && options.HeartbeatIntervalSeconds > 0) + error("labkit:app:contract:InvalidValue", ... + "SessionJournal HeartbeatIntervalSeconds must be positive."); +end +end + +function value = leaseNow(clock) +if isempty(clock) + value = utcNow(); +else + value = string(clock()); +end +end + +function value = leaseProbe(probe) +if isempty(probe) + value = labkit.app.internal.SessionLease.localProbe(); +else + value = probe(); +end +end + +function value = parseLeaseUtc(text) +value = datetime(text, InputFormat="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", ... + TimeZone="UTC", Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); +end + +function value = optionValue(options, name, defaultValue) +value = defaultValue; +if isfield(options, name) + value = options.(name); +end +end + +function tf = isCanonicalRecord(record) +fields = ["schemaVersion", "sequence", "timestampUtc", "elapsedSeconds", ... + "severity", "audience", "category", "eventName", "message", ... + "attributes", "sessionId", "appId", "operationId", ... + "parentOperationId", "rootActionId", "operationResult", "stateDisposition", "durationSeconds", ... + "exception"]; +tf = isstruct(record) && isscalar(record) && ... + isequal(string(fieldnames(record)), fields.') && ... + labkit.app.internal.SessionEventValidator.canonicalTerminalPair( ... + record.operationResult, record.stateDisposition); +end + +function tf = isFlushSeverity(severity) +tf = any(string(severity) == ["WARNING", "ERROR", "CRITICAL"]); +end + +function tf = isTerminalRecord(record) +terminalNames = ["completed", "failed", "abandoned"]; +tf = strlength(string(record.operationResult)) > 0 || any(endsWith( ... + string(record.eventName), "." + terminalNames)); +end + +function tf = isEmptyException(exception) +tf = isstruct(exception) && isscalar(exception) && ... + isfield(exception, "identifier") && strlength(string(exception.identifier)) == 0; +end + +function key = coalescingKey(record) +key = string(jsonencode(struct( ... + "severity", record.severity, "audience", record.audience, ... + "category", record.category, "eventName", record.eventName, ... + "message", record.message, "attributes", record.attributes, ... + "operationId", record.operationId, ... + "parentOperationId", record.parentOperationId, ... + "rootActionId", record.rootActionId))); +end + +function segments = sessionSegments(folder) +segments = dir(fullfile(folder, "events-*.jsonl")); +if ~isempty(segments) + [~, order] = sort(string({segments.name})); + segments = segments(order); +end +end + +function value = fileBytes(filepath) +info = dir(filepath); +if isempty(info) + value = 0; +else + value = info.bytes; +end +end + +function value = utf8Bytes(text) +value = numel(unicode2native(char(text), "UTF-8")); +end + +function payload = manifestPayload(obj) +segments = sessionSegments(obj.Folder); +payload = struct("schemaVersion", 1, "sessionId", obj.SessionId, ... + "appId", obj.Application.AppId, "state", ternary(obj.Closed, "closed", "active"), ... + "startedAtUtc", string(obj.StartedAt), "closedAtUtc", obj.ClosedAtUtc, ... + "updatedAtUtc", utcNow(), "segmentCount", numel(segments), ... + "retainedBytes", sum([segments.bytes]), "lease", struct( ... + "nonce", obj.LeaseNonce, "leaseVersion", 1), "degradation", struct( ... + "droppedRecordCount", obj.DroppedRecordCount, ... + "dropReasons", struct("invalidCanonicalRecord", obj.InvalidRecordDropCount, ... + "writeFailure", obj.WriteFailureDropCount), ... + "coalescedRecordCount", obj.CoalescedRecordCount, ... + "coalescing", struct("reason", "repeated-low-severity", ... + "windowSeconds", obj.CoalescingWindowSeconds), ... + "expiredSegmentCount", obj.ExpiredSegmentCount, ... + "writeFailureCount", obj.WriteFailureCount)); +end + +function value = ternary(condition, trueValue, falseValue) +if condition + value = trueValue; +else + value = falseValue; +end +end + +function value = utcNow() +value = string(datetime("now", TimeZone="UTC", ... + Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")); +end + +function atomicWriteJson(filepath, payload) +temporary = string(tempname(fileparts(filepath))); +file = fopen(char(temporary), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:JournalWriteFailure", ... + "Could not write the session manifest."); +end +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); +clear cleanup +[moved, message] = movefile(char(temporary), char(filepath), "f"); +if ~moved + error("labkit:app:runtime:JournalWriteFailure", "%s", message); +end +end diff --git a/+labkit/+app/+internal/SessionJournalArchive.m b/+labkit/+app/+internal/SessionJournalArchive.m new file mode 100644 index 000000000..f680b4aa6 --- /dev/null +++ b/+labkit/+app/+internal/SessionJournalArchive.m @@ -0,0 +1,547 @@ +classdef (Hidden, Sealed) SessionJournalArchive + %SESSIONJOURNALARCHIVE Inspect, recover, retain, and export closed journals. + % This private archive boundary never receives live events or decides their + % privacy semantics; it consumes only the canonical journal representation. + + methods (Static) + function inspection = inspect(rootFolder, varargin) + options = parseOptions(varargin{:}); + inspection = inspectRoot(string(rootFolder), options); + end + + function snapshot = snapshot(rootFolder, sessionId) + sessionId = semanticSessionId(sessionId, "SessionId"); + rootFolder = string(rootFolder); + folder = fullfile(rootFolder, "sessions", sessionId); + manifest = readJson(fullfile(folder, "manifest.json")); + if isempty(manifest) + error("labkit:app:runtime:JournalUnavailable", ... + "The requested session journal is unavailable."); + end + [events, corruptRecordCount] = readCanonicalEvents(folder); + retention = readJson(fullfile(rootFolder, "retention.json")); + if isempty(retention) + retention = emptyRetention(); + end + snapshot = struct("manifest", manifest, "events", events, ... + "timeline", timeline(events), "degradation", ... + degradation(manifest, retention, corruptRecordCount), ... + "redaction", redactionMetadata()); + end + + function exportFolder = exportSnapshot(rootFolder, sessionId, exportFolder) + snapshot = labkit.app.internal.SessionJournalArchive.snapshot( ... + rootFolder, sessionId); + exportFolder = string(exportFolder); + if exist(char(exportFolder), "dir") ~= 7 + mkdir(char(exportFolder)); + end + atomicJson(fullfile(exportFolder, "manifest.json"), snapshot.manifest); + writeEvents(fullfile(exportFolder, "events.jsonl"), snapshot.events); + writeLines(fullfile(exportFolder, "timeline.txt"), snapshot.timeline); + atomicJson(fullfile(exportFolder, "degradation.json"), snapshot.degradation); + atomicJson(fullfile(exportFolder, "redaction.json"), snapshot.redaction); + end + end +end + +function options = parseOptions(varargin) +% These migration-ledger hypotheses bound stale sessions until profiling fixes +% the production contract. Global storage equals five ordinary App budgets. +options = struct("ClosedSessionLimitPerApp", 10, "ClosedSessionAgeDays", 14, ... + "AppByteLimit", 50 * 1024 * 1024, "GlobalByteLimit", 250 * 1024 * 1024, ... + "ProtectedSessionIds", strings(0, 1), "LeaseClock", [], "LeaseProbe", [], ... + "LeaseFreshSeconds", 60); +if mod(numel(varargin), 2) ~= 0 + error("labkit:app:contract:InvalidValue", ... + "SessionJournalArchive options must be name-value pairs."); +end +for index = 1:2:numel(varargin) + name = string(varargin{index}); + if ~isfield(options, name) + error("labkit:app:contract:InvalidValue", ... + "Unknown SessionJournalArchive option: %s.", name); + end + options.(name) = varargin{index + 1}; +end +for name = ["ClosedSessionLimitPerApp", "ClosedSessionAgeDays", ... + "AppByteLimit", "GlobalByteLimit"] + value = options.(name); + if ~(isnumeric(value) && isscalar(value) && isfinite(value) && value >= 0 && ... + value == fix(value)) + error("labkit:app:contract:InvalidValue", ... + "SessionJournalArchive %s must be a nonnegative integer.", name); + end + options.(name) = double(value); +end +options.ProtectedSessionIds = string(options.ProtectedSessionIds(:)); +for index = 1:numel(options.ProtectedSessionIds) + options.ProtectedSessionIds(index) = semanticSessionId( ... + options.ProtectedSessionIds(index), "ProtectedSessionId"); +end +for name = ["LeaseClock", "LeaseProbe"] + if ~isempty(options.(name)) && ~isa(options.(name), "function_handle") + error("labkit:app:contract:InvalidValue", ... + "SessionJournalArchive %s must be a function handle.", name); + end +end +if ~(isnumeric(options.LeaseFreshSeconds) && isscalar(options.LeaseFreshSeconds) && ... + isfinite(options.LeaseFreshSeconds) && options.LeaseFreshSeconds > 0) + error("labkit:app:contract:InvalidValue", ... + "SessionJournalArchive LeaseFreshSeconds must be positive."); +end +end + +function inspection = inspectRoot(rootFolder, options) +sessionRoot = fullfile(rootFolder, "sessions"); +if exist(char(sessionRoot), "dir") ~= 7 + inspection = struct("retention", emptyRetention(), "sessionCount", 0, ... + "recoveredSessionCount", 0, "corruptTailCount", 0, ... + "liveSessionCount", 0, "uncertainSessionCount", 0, ... + "staleSessionCount", 0); + return; +end +sessions = discover(sessionRoot); +recoveredSessionCount = 0; +corruptTailCount = 0; +liveSessionCount = 0; +uncertainSessionCount = 0; +staleSessionCount = 0; +for index = 1:numel(sessions) + leaseState = classifyLease(sessions(index), options); + switch leaseState + case "live" + liveSessionCount = liveSessionCount + 1; + case "uncertain" + uncertainSessionCount = uncertainSessionCount + 1; + case "stale" + staleSessionCount = staleSessionCount + 1; + end + if any(sessions(index).SessionId == options.ProtectedSessionIds) + continue; + end + if ~(sessions(index).State == "closed" || sessions(index).State == "abandoned" || ... + leaseState == "stale") + continue; + end + [didRecover, tailCount] = recover(sessions(index).Folder, leaseState == "stale"); + recoveredSessionCount = recoveredSessionCount + didRecover; + corruptTailCount = corruptTailCount + tailCount; +end +sessions = discover(sessionRoot); +[sessions, retention] = prune(sessions, options); +retention.recoveredSessionCount = recoveredSessionCount; +retention.corruptTailCount = corruptTailCount; +retention.updatedAtUtc = utcNow(); +atomicJson(fullfile(rootFolder, "retention.json"), retention); +inspection = struct("retention", retention, "sessionCount", numel(sessions), ... + "recoveredSessionCount", recoveredSessionCount, "corruptTailCount", corruptTailCount, ... + "liveSessionCount", liveSessionCount, "uncertainSessionCount", uncertainSessionCount, ... + "staleSessionCount", staleSessionCount); +end + +function sessions = discover(sessionRoot) +folders = dir(sessionRoot); +folders = folders([folders.isdir]); +folders = folders(~ismember(string({folders.name}), [".", ".."])); +template = struct("Folder", "", "SessionId", "", "State", "", ... + "AppId", "", "Timestamp", "", "Bytes", 0); +sessions = repmat(template, 0, 1); +for index = 1:numel(folders) + folder = string(fullfile(folders(index).folder, folders(index).name)); + manifest = readJson(fullfile(folder, "manifest.json")); + if isempty(manifest) || ~isstruct(manifest) || ~isscalar(manifest) || ... + ~all(isfield(manifest, ["sessionId", "state", "appId"])) || ... + ~isTextScalar(manifest.sessionId) || ~isTextScalar(manifest.state) || ... + ~isTextScalar(manifest.appId) + continue; + end + try + sessionId = semanticSessionId(manifest.sessionId, "SessionId"); + catch + continue; + end + sessions(end + 1, 1) = struct("Folder", folder, "SessionId", sessionId, ... + "State", string(manifest.state), "AppId", string(manifest.appId), ... + "Timestamp", sessionTimestamp(manifest), "Bytes", folderBytes(folder)); +end +end + +function [didRecover, corruptTailCount] = recover(folder, abandonActive) +manifestFile = fullfile(folder, "manifest.json"); +manifest = readJson(manifestFile); +didRecover = false; +corruptTailCount = 0; +if isempty(manifest) + return; +end +segments = journalSegments(folder); +for index = 1:numel(segments) + if recoverTail(fullfile(segments(index).folder, segments(index).name)) + corruptTailCount = corruptTailCount + 1; + end +end +if corruptTailCount > 0 + manifest.recovery = recoveryMetadata(manifest, corruptTailCount, false); + didRecover = true; +end +if abandonActive && string(manifest.state) == "active" + manifest.state = "abandoned"; + manifest.abandonedAtUtc = utcNow(); + manifest.recovery = recoveryMetadata(manifest, corruptTailCount, true); + active = fullfile(folder, "active.json"); + if exist(char(active), "file") == 2 + delete(char(active)); + end + didRecover = true; +elseif string(manifest.state) ~= "active" + active = fullfile(folder, "active.json"); + if exist(char(active), "file") == 2 + delete(char(active)); + didRecover = true; + end +end + +if didRecover + manifest.updatedAtUtc = utcNow(); + atomicJson(manifestFile, manifest); +end +end + +function state = classifyLease(session, options) +if session.State ~= "active" + state = "terminal"; + return; +end +marker = readJson(fullfile(session.Folder, "active.json")); +manifest = readJson(fullfile(session.Folder, "manifest.json")); +nowUtc = utcNow(); +if ~isempty(options.LeaseClock) + nowUtc = string(options.LeaseClock()); +end +probe = []; +targetPid = -1; +targetHost = "unknown"; +if isstruct(marker) && isscalar(marker) + if isfield(marker, "pid") && isnumeric(marker.pid) && isscalar(marker.pid) && ... + isfinite(marker.pid) && marker.pid == fix(marker.pid) + targetPid = marker.pid; + end + if isfield(marker, "host") && ((isstring(marker.host) && isscalar(marker.host)) || ... + (ischar(marker.host) && isrow(marker.host))) + targetHost = string(marker.host); + end +end +try + if ~isempty(options.LeaseProbe) + probe = options.LeaseProbe(targetPid, targetHost); + else + probe = labkit.app.internal.SessionLease.localProbe(targetPid); + end +catch + probe = []; +end +state = labkit.app.internal.SessionLease.classify( ... + marker, manifest, nowUtc, probe, options.LeaseFreshSeconds); +end + +function metadata = recoveryMetadata(manifest, tailCount, abandoned) +metadata = struct("corruptTailCount", tailCount, "inspectedAtUtc", utcNow(), ... + "abandoned", abandoned); +if isfield(manifest, "recovery") && isstruct(manifest.recovery) && ... + isfield(manifest.recovery, "corruptTailCount") + metadata.corruptTailCount = double(manifest.recovery.corruptTailCount) + tailCount; +end +end + +function didRecover = recoverTail(filepath) +didRecover = false; +content = fileread(char(filepath)); +if isempty(content) + return; +end +breaks = find(content == char(10)); +starts = [1, breaks + 1]; +ends = [breaks - 1, numel(content)]; +for index = numel(starts):-1:1 + line = strtrim(string(content(starts(index):ends(index)))); + if strlength(line) == 0 + continue; + end + try + record = jsondecode(line); + if ~isCanonicalRecord(record) + error("labkit:app:runtime:JournalCorruptTail", ... + "The final journal record is not canonical."); + end + catch + truncate(filepath, content(1:starts(index) - 1)); + didRecover = true; + end + return; +end +end + +function [sessions, retention] = prune(sessions, options) +retention = emptyRetention(); +expiry = datetime("now", TimeZone="UTC") - days(options.ClosedSessionAgeDays); +for index = 1:numel(sessions) + if isPrunable(sessions(index), options.ProtectedSessionIds) && ... + parseUtc(sessions(index).Timestamp) < expiry + removeSession(sessions(index).Folder); + sessions(index).State = "deleted"; + retention.expiredSessionCount = retention.expiredSessionCount + 1; + end +end +sessions = sessions(string({sessions.State}) ~= "deleted"); +for appId = unique(string({sessions.AppId})) + candidates = find(string({sessions.AppId}) == appId); + [sessions, removed] = enforce(sessions, candidates, ... + options.ClosedSessionLimitPerApp, options.AppByteLimit, ... + options.ProtectedSessionIds); + retention.perAppPrunedSessionCount = retention.perAppPrunedSessionCount + removed; + retainedAppSessions = find(string({sessions.AppId}) == appId); + retainedAppBytes = sum([sessions(retainedAppSessions).Bytes]); + if retainedAppBytes > options.AppByteLimit + retention.unsatisfiedAppIds(end + 1, 1) = appId; + end +end +[sessions, removed] = enforce(sessions, 1:numel(sessions), inf, ... + options.GlobalByteLimit, options.ProtectedSessionIds); +retention.globalPrunedSessionCount = retention.globalPrunedSessionCount + removed; +retention.retainedGlobalBytes = sum([sessions.Bytes]); +retention.unsatisfiedGlobalByteLimit = ... + retention.retainedGlobalBytes > options.GlobalByteLimit; +end + +function [sessions, removed] = enforce(sessions, candidates, countLimit, byteLimit, protected) +removed = 0; +while true + scope = candidates(candidates <= numel(sessions)); + removable = scope(arrayfun(@(index) isPrunable(sessions(index), protected), scope)); + if isempty(removable) || ... + (numel(removable) <= countLimit && sum([sessions(scope).Bytes]) <= byteLimit) + return; + end + [~, order] = sort(string({sessions(removable).Timestamp}) + "|" + ... + string({sessions(removable).SessionId})); + removeIndex = removable(order(1)); + removeSession(sessions(removeIndex).Folder); + sessions(removeIndex) = []; + candidates = candidates(candidates ~= removeIndex); + candidates(candidates > removeIndex) = candidates(candidates > removeIndex) - 1; + removed = removed + 1; +end +end + +function tf = isPrunable(session, protected) +tf = any(session.State == ["closed", "abandoned"]) && ... + ~any(session.SessionId == protected); +end + +function removeSession(folder) +if exist(char(folder), "dir") == 7 + rmdir(char(folder), "s"); +end +end + +function retention = emptyRetention() +retention = struct("expiredSessionCount", 0, "perAppPrunedSessionCount", 0, ... + "globalPrunedSessionCount", 0, "recoveredSessionCount", 0, ... + "corruptTailCount", 0, "retainedGlobalBytes", 0, ... + "unsatisfiedAppIds", strings(0, 1), "unsatisfiedGlobalByteLimit", false, ... + "updatedAtUtc", ""); +end + +function value = sessionTimestamp(manifest) +for field = ["closedAtUtc", "abandonedAtUtc", "updatedAtUtc", "startedAtUtc"] + if isfield(manifest, field) && isTextScalar(manifest.(field)) && ... + strlength(string(manifest.(field))) > 0 + value = string(manifest.(field)); + return; + end +end +value = ""; +end + +function tf = isTextScalar(value) +tf = (isstring(value) && isscalar(value)) || (ischar(value) && isrow(value)); +end + +function value = parseUtc(text) +try + value = datetime(text, InputFormat="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", ... + TimeZone="UTC"); +catch + value = NaT(TimeZone="UTC"); +end +end + +function value = folderBytes(folder) +children = dir(folder); +value = 0; +for index = 1:numel(children) + if ismember(string(children(index).name), [".", ".."]) + continue; + end + child = fullfile(children(index).folder, children(index).name); + if children(index).isdir + value = value + folderBytes(child); + else + value = value + children(index).bytes; + end +end +end + +function [events, corruptRecordCount] = readCanonicalEvents(folder) +events = repmat(canonicalTemplate(), 0, 1); +corruptRecordCount = 0; +segments = journalSegments(folder); +for segmentIndex = 1:numel(segments) + lines = splitlines(string(fileread(fullfile(segments(segmentIndex).folder, ... + segments(segmentIndex).name)))); + for lineIndex = 1:numel(lines) + line = strtrim(lines(lineIndex)); + if strlength(line) == 0 + continue; + end + try + record = jsondecode(line); + if ~isCanonicalRecord(record) + error("labkit:app:runtime:JournalCorruptRecord", ... + "A retained journal record is not canonical."); + end + events(end + 1, 1) = record; + catch + corruptRecordCount = corruptRecordCount + 1; + end + end +end +end + +function template = canonicalTemplate() +template = struct("schemaVersion", [], "sequence", [], "timestampUtc", "", ... + "elapsedSeconds", [], "severity", "", "audience", "", "category", "", ... + "eventName", "", "message", "", "attributes", struct(), "sessionId", "", ... + "appId", "", "operationId", "", "parentOperationId", "", ... + "rootActionId", "", "operationResult", "", "stateDisposition", "", ... + "durationSeconds", [], "exception", struct()); +end + +function value = timeline(events) +value = strings(numel(events), 1); +for index = 1:numel(events) + value(index) = string(events(index).timestampUtc) + " [" + ... + string(events(index).severity) + "] " + string(events(index).category) + ... + " - " + string(events(index).message); +end +end + +function value = degradation(manifest, retention, corruptRecordCount) +if isfield(manifest, "degradation") + value = manifest.degradation; +else + value = struct(); +end +if isfield(manifest, "recovery") + value.recovery = manifest.recovery; +end +value.snapshotCorruptRecordCount = corruptRecordCount; +value.retention = retention; +end + +function value = redactionMetadata() +value = struct("semanticEventPrivacy", "validated-before-retention", ... + "exportProjection", "canonical-safe-events-only", ... + "excludedData", ["paths", "filenames", "input-content", ... + "scientific-data", "workspace-values"]); +end + +function writeEvents(filepath, events) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:JournalWriteFailure", "Could not write export events."); +end +cleanup = onCleanup(@() fclose(file)); +for index = 1:numel(events) + fprintf(file, "%s\n", jsonencode(events(index))); +end +clear cleanup +end + +function writeLines(filepath, lines) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:JournalWriteFailure", "Could not write export timeline."); +end +cleanup = onCleanup(@() fclose(file)); +for index = 1:numel(lines) + fprintf(file, "%s\n", lines(index)); +end +clear cleanup +end + +function truncate(filepath, content) +file = fopen(char(filepath), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:JournalWriteFailure", "Could not recover journal tail."); +end +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", content); +clear cleanup +end + +function segments = journalSegments(folder) +segments = dir(fullfile(folder, "events-*.jsonl")); +if ~isempty(segments) + [~, order] = sort(string({segments.name})); + segments = segments(order); +end +end + +function tf = isCanonicalRecord(record) +fields = ["schemaVersion", "sequence", "timestampUtc", "elapsedSeconds", ... + "severity", "audience", "category", "eventName", "message", ... + "attributes", "sessionId", "appId", "operationId", ... + "parentOperationId", "rootActionId", "operationResult", "stateDisposition", "durationSeconds", ... + "exception"]; +tf = isstruct(record) && isscalar(record) && ... + isequal(string(fieldnames(record)), fields.') && ... + labkit.app.internal.SessionEventValidator.canonicalTerminalPair( ... + record.operationResult, record.stateDisposition); +end + +function value = semanticSessionId(value, label) +value = labkit.app.internal.SessionEventValidator.semanticIdentifier(value, label); +end + +function value = utcNow() +value = string(datetime("now", TimeZone="UTC", ... + Format="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")); +end + +function payload = readJson(filepath) +payload = []; +if exist(char(filepath), "file") ~= 2 + return; +end +try + payload = jsondecode(fileread(char(filepath))); +catch +end +end + +function atomicJson(filepath, payload) +temporary = string(tempname(fileparts(filepath))); +file = fopen(char(temporary), "w", "n", "UTF-8"); +if file < 0 + error("labkit:app:runtime:JournalWriteFailure", "Could not write journal metadata."); +end +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); +clear cleanup +[moved, message] = movefile(char(temporary), char(filepath), "f"); +if ~moved + error("labkit:app:runtime:JournalWriteFailure", "%s", message); +end +end diff --git a/+labkit/+app/+internal/SessionJournalProjection.m b/+labkit/+app/+internal/SessionJournalProjection.m new file mode 100644 index 000000000..f6010e96e --- /dev/null +++ b/+labkit/+app/+internal/SessionJournalProjection.m @@ -0,0 +1,154 @@ +classdef (Hidden, Sealed) SessionJournalProjection < handle + %SESSIONJOURNALPROJECTION Private non-recursive Journal health boundary. + % RuntimeFactory owns this adapter; Apps never call it. + + properties (Access = private) + Journal + LastAvailable (1, 1) logical = true + LastInvalidCanonicalRecordDropCount (1, 1) double = 0 + LastWriteFailureDropCount (1, 1) double = 0 + ProjectionAvailable (1, 1) logical = true + ProjectionDegradationPending (1, 1) logical = false + ProjectionDropCount (1, 1) double = 0 + LastReportedProjectionDropCount (1, 1) double = 0 + HealthUnavailableReported (1, 1) logical = false + ProjectionFaultInjector = [] + end + + methods + function obj = SessionJournalProjection(journal, projectionFaultInjector) + if ~isa(journal, "labkit.app.internal.SessionJournal") || ~isscalar(journal) + error("labkit:app:runtime:InvariantFailure", ... + "SessionJournalProjection requires one SessionJournal."); + end + if nargin < 2 + projectionFaultInjector = []; + end + if ~isempty(projectionFaultInjector) && ... + ~isa(projectionFaultInjector, "function_handle") + error("labkit:app:runtime:InvariantFailure", ... + "SessionJournalProjection fault injector must be a function handle."); + end + obj.Journal = journal; + obj.ProjectionFaultInjector = projectionFaultInjector; + end + + function project(obj, record) + try + if ~isempty(obj.ProjectionFaultInjector) + obj.ProjectionFaultInjector("project"); + end + obj.Journal.append(record); + obj.ProjectionAvailable = true; + catch + if obj.ProjectionAvailable + obj.ProjectionDegradationPending = true; + end + obj.ProjectionAvailable = false; + obj.ProjectionDropCount = obj.ProjectionDropCount + 1; + end + end + + function close(obj) + try + if ~isempty(obj.ProjectionFaultInjector) + obj.ProjectionFaultInjector("close"); + end + obj.Journal.close(); + catch + if obj.ProjectionAvailable + obj.ProjectionDegradationPending = true; + end + obj.ProjectionAvailable = false; + end + end + + function notifications = drainHealth(obj) + notifications = emptyNotifications(); + if obj.ProjectionDegradationPending + notifications(end + 1, 1) = notification( ... + "journal.degraded", "projection-failure", 0); + obj.ProjectionDegradationPending = false; + end + projectionDropDelta = obj.ProjectionDropCount - ... + obj.LastReportedProjectionDropCount; + if projectionDropDelta > 0 + notifications(end + 1, 1) = notification( ... + "journal.records_dropped", "projection-failure", projectionDropDelta); + obj.LastReportedProjectionDropCount = obj.ProjectionDropCount; + end + try + snapshot = obj.Journal.healthSnapshot(); + snapshot = validateSnapshot(snapshot); + catch + if ~obj.HealthUnavailableReported + notifications(end + 1, 1) = notification( ... + "journal.degraded", "health-unavailable", 0); + obj.HealthUnavailableReported = true; + end + return; + end + if obj.LastAvailable && ~snapshot.available + reason = snapshot.degradationReason; + if strlength(reason) == 0 + reason = "journal-unavailable"; + end + notifications(end + 1, 1) = notification( ... + "journal.degraded", reason, 0); + end + invalidDelta = snapshot.invalidCanonicalRecordDropCount - ... + obj.LastInvalidCanonicalRecordDropCount; + if invalidDelta > 0 + notifications(end + 1, 1) = notification( ... + "journal.records_dropped", "invalid-canonical-record", invalidDelta); + end + writeFailureDelta = snapshot.writeFailureDropCount - ... + obj.LastWriteFailureDropCount; + if writeFailureDelta > 0 + notifications(end + 1, 1) = notification( ... + "journal.records_dropped", "write-failure", writeFailureDelta); + end + obj.LastAvailable = snapshot.available; + obj.LastInvalidCanonicalRecordDropCount = ... + snapshot.invalidCanonicalRecordDropCount; + obj.LastWriteFailureDropCount = snapshot.writeFailureDropCount; + end + end +end + +function notifications = emptyNotifications() +notifications = repmat(notification("", "", 0), 0, 1); +end + +function value = notification(eventName, reason, count) +value = struct("eventName", string(eventName), "reason", string(reason), ... + "count", double(count)); +end + +function snapshot = validateSnapshot(snapshot) +fields = ["state", "available", "droppedRecordCount", ... + "invalidCanonicalRecordDropCount", "writeFailureDropCount", ... + "writeFailureCount", "lastFailureReason", "degradationReason"]; +if ~isstruct(snapshot) || ~isscalar(snapshot) || ... + ~isequal(string(fieldnames(snapshot)), fields.') + error("labkit:app:runtime:InvariantFailure", "Journal health snapshot is invalid."); +end +snapshot.state = string(snapshot.state); +snapshot.lastFailureReason = string(snapshot.lastFailureReason); +snapshot.degradationReason = string(snapshot.degradationReason); +if ~isscalar(snapshot.state) || ~any(snapshot.state == ["healthy", "unavailable", "closed"]) || ... + ~isscalar(snapshot.lastFailureReason) || ismissing(snapshot.lastFailureReason) || ... + ~isscalar(snapshot.degradationReason) || ismissing(snapshot.degradationReason) || ... + ~(islogical(snapshot.available) && isscalar(snapshot.available)) + error("labkit:app:runtime:InvariantFailure", "Journal health snapshot is invalid."); +end +for name = ["droppedRecordCount", "invalidCanonicalRecordDropCount", ... + "writeFailureDropCount", "writeFailureCount"] + value = snapshot.(name); + if ~(isnumeric(value) && isreal(value) && isscalar(value) && ... + isfinite(value) && value >= 0 && value == fix(value)) + error("labkit:app:runtime:InvariantFailure", "Journal health snapshot is invalid."); + end + snapshot.(name) = double(value); +end +end diff --git a/+labkit/+app/+internal/SessionLease.m b/+labkit/+app/+internal/SessionLease.m new file mode 100644 index 000000000..eb3b3cd60 --- /dev/null +++ b/+labkit/+app/+internal/SessionLease.m @@ -0,0 +1,202 @@ +classdef (Hidden, Sealed) SessionLease + %SESSIONLEASE Private active-session ownership and recovery classifier. + + methods (Static) + function marker = create(sessionId, appId, startedAtUtc, nowUtc, nonce, probe) + if nargin < 5 + error("labkit:app:contract:InvalidValue", ... + "SessionLease requires one explicit LeaseNonce."); + end + nonce = labkit.app.internal.SessionLease.validateNonce(nonce); + if nargin < 6 || isempty(probe) + probe = labkit.app.internal.SessionLease.localProbe(); + end + pid = -1; + if isfield(probe, "pid") && isnumeric(probe.pid) && isscalar(probe.pid) && ... + isfinite(probe.pid) && probe.pid >= 0 + pid = double(probe.pid); + end + marker = struct("sessionId", string(sessionId), "appId", string(appId), ... + "state", "active", "host", lower(string(probe.host)), ... + "pid", pid, "nonce", string(nonce), ... + "startedAtUtc", string(startedAtUtc), "heartbeatAtUtc", string(nowUtc), ... + "leaseVersion", 1); + end + + function nonce = createNonce() + [~, leaf] = fileparts(tempname); + nonce = labkit.app.internal.SessionLease.validateNonce("lease-" + string(leaf)); + end + + function nonce = validateNonce(nonce) + if ~((isstring(nonce) && isscalar(nonce)) || (ischar(nonce) && isrow(nonce))) || ... + strlength(string(nonce)) == 0 + error("labkit:app:contract:InvalidValue", ... + "SessionLease LeaseNonce must be one nonempty semantic identifier."); + end + nonce = labkit.app.internal.SessionEventValidator.semanticIdentifier( ... + nonce, "LeaseNonce"); + end + + function state = classify(marker, manifest, nowUtc, probe, freshSeconds) + %CLASSIFY Total, conservative ownership decision for one marker. + % Any malformed or incomplete input is uncertain rather than an + % inspection failure or permission to reclaim a live journal. + state = "uncertain"; + try + if nargin < 5 || ~isPositiveScalar(freshSeconds) + return; + end + if nargin < 4 || isempty(probe) + probe = labkit.app.internal.SessionLease.localProbe(); + end + fields = ["sessionId", "appId", "state", "host", "pid", "nonce", ... + "startedAtUtc", "heartbeatAtUtc", "leaseVersion"]; + if ~isstruct(marker) || ~isscalar(marker) || ~all(isfield(marker, fields)) || ... + ~isstruct(manifest) || ~isscalar(manifest) || ... + ~all(isfield(manifest, ["sessionId", "appId", "state", "lease"])) || ... + ~isstruct(manifest.lease) || ~isscalar(manifest.lease) || ... + ~all(isfield(manifest.lease, ["nonce", "leaseVersion"])) + return; + end + [markerSessionOk, markerSession] = scalarText(marker.sessionId, false); + [markerAppOk, markerApp] = scalarText(marker.appId, false); + [markerStateOk, markerState] = scalarText(marker.state, false); + [markerHostOk, markerHost] = scalarText(marker.host, false); + [markerNonceOk, markerNonce] = scalarText(marker.nonce, false); + [manifestSessionOk, manifestSession] = scalarText(manifest.sessionId, false); + [manifestAppOk, manifestApp] = scalarText(manifest.appId, false); + [manifestStateOk, manifestState] = scalarText(manifest.state, false); + [manifestNonceOk, manifestNonce] = scalarText(manifest.lease.nonce, false); + if ~(markerSessionOk && markerAppOk && markerStateOk && markerHostOk && ... + markerNonceOk && manifestSessionOk && manifestAppOk && ... + manifestStateOk && manifestNonceOk) || ... + markerState ~= "active" || manifestState ~= "active" || ... + markerSession ~= manifestSession || markerApp ~= manifestApp || ... + markerNonce ~= manifestNonce || ... + ~isLeaseVersion(marker.leaseVersion) || ... + ~isLeaseVersion(manifest.lease.leaseVersion) || ... + marker.leaseVersion ~= manifest.lease.leaseVersion || ... + ~isPid(marker.pid) + return; + end + heartbeat = parseUtc(marker.heartbeatAtUtc); + started = parseUtc(marker.startedAtUtc); + observedNow = parseUtc(nowUtc); + if isnat(heartbeat) || isnat(started) || isnat(observedNow) || ... + heartbeat < started || heartbeat > observedNow + return; + end + if seconds(observedNow - heartbeat) <= double(freshSeconds) + state = "live"; + return; + end + if marker.pid < 0 || markerHost == "unknown" || ... + ~isstruct(probe) || ~isscalar(probe) || ... + ~all(isfield(probe, ["host", "targetPid", "processState"])) + return; + end + [probeHostOk, probeHost] = scalarText(probe.host, false); + [probeStateOk, probeState] = scalarText(probe.processState, false); + if ~probeHostOk || ~probeStateOk || lower(markerHost) ~= lower(probeHost) || ... + ~isPid(probe.targetPid) || probe.targetPid ~= marker.pid || ... + ~any(probeState == ["alive", "dead", "unknown"]) + return; + end + if probeState == "dead" + state = "stale"; + end + catch + state = "uncertain"; + end + end + + function probe = localProbe(targetPid) + if nargin < 1 + targetPid = currentPid(); + end + host = string(getenv("COMPUTERNAME")); + if strlength(host) == 0 + host = string(getenv("HOSTNAME")); + end + if strlength(host) == 0 + host = "unknown"; + end + pid = currentPid(); + probe = struct("host", lower(host), "pid", pid, "targetPid", targetPid, ... + "processState", processState(targetPid)); + end + end +end + +function value = parseUtc(value) +try + value = datetime(string(value), InputFormat="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", ... + TimeZone="UTC"); +catch + value = NaT(TimeZone="UTC"); +end +end + +function value = currentPid() +value = -1; +try + candidate = feature("getpid"); + if isPid(candidate) + value = double(candidate); + end +catch +end +end + +function value = processState(targetPid) +value = "unknown"; +if ~isPid(targetPid) || targetPid < 0 + return; +end +try + if ispc + [status, output] = system(sprintf('tasklist /FI "PID eq %d" /NH', targetPid)); + if status ~= 0 + return; + end + if ~isempty(regexp(char(output), ... + ['(^|\\s)' num2str(targetPid) '(\\s|$)'], "once")) + value = "alive"; + elseif contains(lower(string(output)), "no tasks") + value = "dead"; + end + else + [status, output] = system(sprintf('kill -0 %d 2>&1', targetPid)); + if status == 0 + value = "alive"; + elseif contains(lower(string(output)), "no such process") + value = "dead"; + end + end +catch +end +end + +function tf = isPositiveScalar(value) +tf = isnumeric(value) && isreal(value) && isscalar(value) && isfinite(value) && value > 0; +end + +function tf = isLeaseVersion(value) +tf = isnumeric(value) && isreal(value) && isscalar(value) && ... + isfinite(value) && value == 1; +end + +function tf = isPid(value) +tf = isnumeric(value) && isreal(value) && isscalar(value) && ... + isfinite(value) && value == fix(value) && value >= -1; +end + +function [ok, value] = scalarText(input, allowEmpty) +ok = false; +value = ""; +if (isstring(input) && isscalar(input)) || (ischar(input) && isrow(input)) + value = string(input); + ok = allowEmpty || strlength(value) > 0; +end +end diff --git a/+labkit/+app/+internal/SessionLogProjection.m b/+labkit/+app/+internal/SessionLogProjection.m new file mode 100644 index 000000000..117097da7 --- /dev/null +++ b/+labkit/+app/+internal/SessionLogProjection.m @@ -0,0 +1,336 @@ +classdef (Hidden, Sealed) SessionLogProjection < handle + %SESSIONLOGPROJECTION Filter one canonical Runtime session for display. + % SessionLogViewer is the production caller. This class owns only + % projection state: it never mutates or deletes the canonical event stream. + + properties (Access = private) + Events + TraceEnabled (1, 1) logical = false + InMemoryTruncated (1, 1) logical = false + RetainedRecordCount (1, 1) double = 0 + TotalRecordCount (1, 1) double = 0 + JournalAvailable (1, 1) logical = false + JournalState (1, 1) string = "unavailable" + DroppedRecordCount (1, 1) double = 0 + CoalescedRecordCount (1, 1) double = 0 + ExpiredSegmentCount (1, 1) double = 0 + DegradationReason (1, 1) string = "" + ClearedThroughSequence (1, 1) double = 0 + LevelFilter (1, 1) string = "default" + AudienceFilter (1, 1) string = "default" + CategoryFilter (1, 1) string = "" + RootActionFilter (1, 1) string = "" + SearchText (1, 1) string = "" + end + + properties (Constant, Access = private) + RetainedViewLimit = 512 + end + + methods + function obj = SessionLogProjection(snapshot) + obj.Events = emptyEvents(); + if nargin > 0 + obj.update(snapshot); + end + end + + function update(obj, snapshot) + snapshot = validateSnapshot(snapshot); + obj.Events = snapshot.events(:); + obj.TraceEnabled = snapshot.traceEnabled; + obj.InMemoryTruncated = snapshot.inMemoryTruncated; + obj.RetainedRecordCount = snapshot.retainedRecordCount; + obj.TotalRecordCount = snapshot.totalRecordCount; + obj.JournalAvailable = snapshot.journalAvailable; + obj.JournalState = snapshot.journalState; + obj.DroppedRecordCount = snapshot.droppedRecordCount; + obj.CoalescedRecordCount = snapshot.coalescedRecordCount; + obj.ExpiredSegmentCount = snapshot.expiredSegmentCount; + obj.DegradationReason = snapshot.degradationReason; + end + + function append(obj, record) + record = validateRecord(record); + obj.Events(end + 1, 1) = record; + obj.TotalRecordCount = max( ... + obj.TotalRecordCount + 1, double(record.sequence)); + if numel(obj.Events) > obj.RetainedViewLimit + obj.Events(1) = []; + obj.InMemoryTruncated = true; + end + obj.RetainedRecordCount = numel(obj.Events); + end + + function setFilters(obj, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "SessionLogProjection.setFilters", ... + ["Level", "Audience", "Category", "RootAction", "Search"], ... + varargin{:}); + if isfield(options, "Level") + obj.LevelFilter = oneOf(options.Level, ... + ["default", "trace", "debug", "info", ... + "warning", "error", "critical"], "Level"); + end + if isfield(options, "Audience") + obj.AudienceFilter = oneOf(options.Audience, ... + ["default", "all", "user", "developer"], "Audience"); + end + if isfield(options, "Category") + obj.CategoryFilter = optionalText( ... + options.Category, "Category"); + end + if isfield(options, "RootAction") + obj.RootActionFilter = optionalText( ... + options.RootAction, "RootAction"); + end + if isfield(options, "Search") + obj.SearchText = lower(optionalText( ... + options.Search, "Search")); + end + end + + function clearView(obj) + if isempty(obj.Events) + return; + end + obj.ClearedThroughSequence = max( ... + double([obj.Events.sequence])); + end + + function projection = view(obj) + selected = obj.filteredEvents(); + projection = struct( ... + "rows", rowsFor(selected), ... + "events", selected, ... + "severityCounts", severityCounts(selected), ... + "categories", choices(string({obj.Events.category})), ... + "rootActions", choices(string({obj.Events.rootActionId})), ... + "notices", obj.notices(), ... + "clearedThroughSequence", obj.ClearedThroughSequence); + end + + function record = detail(obj, sequence) + sequence = finiteInteger(sequence, "sequence"); + match = obj.Events( ... + double([obj.Events.sequence]) == sequence); + if isempty(match) + record = []; + else + record = match(end); + end + end + end + + methods (Access = private) + function selected = filteredEvents(obj) + selected = obj.Events; + if isempty(selected) + return; + end + sequence = double([selected.sequence]); + keep = sequence > obj.ClearedThroughSequence; + levels = lower(string({selected.severity})); + audiences = lower(string({selected.audience})); + if obj.LevelFilter == "default" + rank = severityRank(levels); + keep = keep & ((audiences == "user" & rank >= 3) | ... + rank >= 4); + else + keep = keep & severityRank(levels) >= ... + severityRank(obj.LevelFilter); + end + if obj.AudienceFilter == "user" + keep = keep & audiences == "user"; + elseif obj.AudienceFilter == "developer" + keep = keep & audiences == "developer"; + elseif obj.AudienceFilter == "default" + rank = severityRank(levels); + keep = keep & (audiences == "user" | rank >= 4); + end + if strlength(obj.CategoryFilter) > 0 + keep = keep & ... + string({selected.category}) == obj.CategoryFilter; + end + if strlength(obj.RootActionFilter) > 0 + keep = keep & ... + string({selected.rootActionId}) == ... + obj.RootActionFilter; + end + if strlength(obj.SearchText) > 0 + searchable = lower( ... + string({selected.eventName}) + " " + ... + string({selected.category}) + " " + ... + string({selected.message})); + keep = keep & contains(searchable, obj.SearchText); + end + selected = selected(keep); + end + + function value = notices(obj) + value = strings(0, 1); + if ~obj.TraceEnabled + value(end + 1, 1) = ... + "TRACE capture is off; earlier TRACE events are unavailable."; + end + if obj.InMemoryTruncated + value(end + 1, 1) = ... + "Older in-memory records expired from the live view."; + end + if obj.CoalescedRecordCount > 0 + value(end + 1, 1) = ... + string(obj.CoalescedRecordCount) + ... + " repeated low-level record(s) were coalesced."; + end + if obj.ExpiredSegmentCount > 0 + value(end + 1, 1) = ... + string(obj.ExpiredSegmentCount) + ... + " older journal segment(s) expired."; + end + if obj.DroppedRecordCount > 0 + value(end + 1, 1) = ... + string(obj.DroppedRecordCount) + ... + " journal record(s) were dropped."; + end + if ~obj.JournalAvailable + reason = obj.DegradationReason; + if strlength(reason) == 0 + reason = obj.JournalState; + end + value(end + 1, 1) = ... + "Persistent journal unavailable (" + reason + ")."; + end + end + end +end + +function snapshot = validateSnapshot(snapshot) +fields = ["events", "traceEnabled", "inMemoryTruncated", ... + "retainedRecordCount", "totalRecordCount", "journalAvailable", ... + "journalState", "droppedRecordCount", "coalescedRecordCount", ... + "expiredSegmentCount", "degradationReason"]; +if ~isstruct(snapshot) || ~isscalar(snapshot) || ... + ~isequal(string(fieldnames(snapshot)), fields.') + error("labkit:app:runtime:InvariantFailure", ... + "Session log snapshot is invalid."); +end +if ~(islogical(snapshot.traceEnabled) && ... + isscalar(snapshot.traceEnabled)) || ... + ~(islogical(snapshot.inMemoryTruncated) && ... + isscalar(snapshot.inMemoryTruncated)) || ... + ~(islogical(snapshot.journalAvailable) && ... + isscalar(snapshot.journalAvailable)) + error("labkit:app:runtime:InvariantFailure", ... + "Session log snapshot state is invalid."); +end +for name = ["retainedRecordCount", "totalRecordCount", ... + "droppedRecordCount", "coalescedRecordCount", ... + "expiredSegmentCount"] + snapshot.(name) = finiteInteger(snapshot.(name), name); +end +snapshot.journalState = optionalText(snapshot.journalState, "journalState"); +snapshot.degradationReason = optionalText( ... + snapshot.degradationReason, "degradationReason"); +if isempty(snapshot.events) + snapshot.events = emptyEvents(); +elseif ~isstruct(snapshot.events) + error("labkit:app:runtime:InvariantFailure", ... + "Session log snapshot events are invalid."); +else + for index = 1:numel(snapshot.events) + validateRecord(snapshot.events(index)); + end +end +end + +function record = validateRecord(record) +fields = ["schemaVersion", "sequence", "timestampUtc", "elapsedSeconds", ... + "severity", "audience", "category", "eventName", "message", ... + "attributes", "sessionId", "appId", "operationId", ... + "parentOperationId", "rootActionId", "operationResult", ... + "stateDisposition", "durationSeconds", "exception"]; +if ~isstruct(record) || ~isscalar(record) || ... + ~isequal(string(fieldnames(record)), fields.') + error("labkit:app:runtime:InvariantFailure", ... + "Session log record is invalid."); +end +end + +function value = rowsFor(events) +if isempty(events) + value = table( ... + strings(0, 1), strings(0, 1), strings(0, 1), ... + strings(0, 1), zeros(0, 1), ... + VariableNames=["Time", "Level", "Area", "Message", "Sequence"]); + return; +end +value = table( ... + string({events.timestampUtc}).', ... + string({events.severity}).', ... + string({events.category}).', ... + string({events.message}).', ... + double([events.sequence]).', ... + VariableNames=["Time", "Level", "Area", "Message", "Sequence"]); +end + +function value = severityCounts(events) +levels = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]; +value = struct(); +for level = levels + value.(lower(level)) = sum(string({events.severity}) == level); +end +end + +function value = choices(values) +values = values(strlength(values) > 0); +value = unique(values, "stable"); +end + +function value = severityRank(values) +values = lower(string(values)); +legal = ["trace", "debug", "info", "warning", "error", "critical"]; +value = zeros(size(values)); +for index = 1:numel(legal) + value(values == legal(index)) = index; +end +end + +function value = oneOf(value, legal, name) +value = lower(optionalText(value, name)); +if ~any(value == legal) + error("labkit:app:contract:InvalidValue", ... + "Session log %s filter is invalid.", name); +end +end + +function value = optionalText(value, name) +if ~(ischar(value) || (isstring(value) && isscalar(value))) + error("labkit:app:contract:InvalidValue", ... + "Session log %s must be scalar text.", name); +end +value = string(value); +if ismissing(value) + error("labkit:app:contract:InvalidValue", ... + "Session log %s must not be missing.", name); +end +end + +function value = finiteInteger(value, name) +if ~(isnumeric(value) && isreal(value) && isscalar(value) && ... + isfinite(value) && value >= 0 && value == fix(value)) + error("labkit:app:contract:InvalidValue", ... + "Session log %s must be a nonnegative integer.", name); +end +value = double(value); +end + +function value = emptyEvents() +value = repmat(struct( ... + "schemaVersion", 1, "sequence", 0, "timestampUtc", "", ... + "elapsedSeconds", 0, "severity", "", "audience", "", ... + "category", "", "eventName", "", "message", "", ... + "attributes", struct(), "sessionId", "", "appId", "", ... + "operationId", "", "parentOperationId", "", "rootActionId", "", ... + "operationResult", "", "stateDisposition", "", ... + "durationSeconds", [], "exception", struct()), 0, 1); +end diff --git a/+labkit/+app/+internal/SessionLogViewer.m b/+labkit/+app/+internal/SessionLogViewer.m new file mode 100644 index 000000000..87b352fb4 --- /dev/null +++ b/+labkit/+app/+internal/SessionLogViewer.m @@ -0,0 +1,482 @@ +classdef (Hidden, Sealed) SessionLogViewer < handle + %SESSIONLOGVIEWER Native incremental viewer for one Runtime session. + % MatlabPlatformAdapter owns this private tool window. It projects the + % canonical retained history and never clears or mutates that history. + + properties (Access = private) + Runtime + Projection + SubscriptionToken (1, 1) string = "" + Figure + RootPanel + SummaryLabel + NoticeLabel + SearchField + LevelFilter + AudienceFilter + CategoryFilter + RootFilter + FollowButton + CopyButton + EventTable + DetailArea + VisibleSequences (1, :) double = zeros(1, 0) + FollowLatest (1, 1) logical = true + Closed (1, 1) logical = false + end + + methods + function obj = SessionLogViewer(runtime) + if ~isa(runtime, "labkit.app.internal.RuntimeKernel") || ... + ~isscalar(runtime) + error("labkit:app:runtime:InvariantFailure", ... + "SessionLogViewer requires one RuntimeKernel."); + end + obj.Runtime = runtime; + obj.Projection = labkit.app.internal.SessionLogProjection( ... + runtime.diagnosticSnapshot()); + obj.createFigure(); + obj.SubscriptionToken = ... + runtime.subscribeDiagnostics(@obj.acceptRecord); + obj.refresh(); + end + + function show(obj) + if obj.Closed || isempty(obj.Figure) || ~isvalid(obj.Figure) + return; + end + mode = ... + labkit.app.internal.NativeAdapterValues.startupGuiMode(); + if mode == "hidden" + return; + end + obj.Figure.Visible = "on"; + if mode == "minimized" && ... + isprop(obj.Figure, "WindowState") + obj.Figure.WindowState = "minimized"; + end + figure(obj.Figure); + end + + function refresh(obj) + if obj.Closed + return; + end + obj.Projection.update(obj.Runtime.diagnosticSnapshot()); + obj.refreshView(); + end + + function figure = figureHandle(obj) + figure = obj.Figure; + end + + function tf = isOpen(obj) + tf = ~obj.Closed && ~isempty(obj.Figure) && ... + isvalid(obj.Figure); + end + + function close(obj) + if obj.Closed + return; + end + obj.Closed = true; + if strlength(obj.SubscriptionToken) > 0 + obj.Runtime.unsubscribeDiagnostics( ... + obj.SubscriptionToken); + end + obj.SubscriptionToken = ""; + if ~isempty(obj.Figure) && isvalid(obj.Figure) + delete(obj.Figure); + end + end + + function delete(obj) + obj.close(); + end + end + + methods (Access = private) + function createFigure(obj) + obj.Figure = uifigure( ... + Visible="off", ... + Name="LabKit Session Log", ... + Position=viewerPosition(), ... + AutoResizeChildren="off", ... + CloseRequestFcn=@(~, ~) obj.close(), ... + Tag="labkitSessionLogViewer"); + obj.RootPanel = uipanel(obj.Figure, ... + BorderType="none", ... + Position=[1 1 obj.Figure.Position(3:4)]); + root = uigridlayout(obj.RootPanel, [5 1], ... + RowHeight={30, 68, 36, "1x", 180}, ... + Padding=[10 10 10 10], RowSpacing=6); + + obj.SummaryLabel = uilabel(root, ... + Text="No retained events.", FontWeight="bold", ... + Tag="labkitSessionLogSummary"); + obj.SummaryLabel.Layout.Row = 1; + + filters = uigridlayout(root, [2 8], ... + RowHeight={30, 30}, ... + ColumnWidth={55, "1x", 45, 105, 62, 110, 70, 90}, ... + Padding=[0 0 0 0], RowSpacing=4, ColumnSpacing=5); + filters.Layout.Row = 2; + label = uilabel(filters, Text="Search"); + label.Layout.Row = 1; + label.Layout.Column = 1; + obj.SearchField = uieditfield(filters, "text", ... + ValueChangedFcn=@(~, ~) obj.applyFilters(), ... + Tag="labkitSessionLogSearch"); + obj.SearchField.Layout.Row = 1; + obj.SearchField.Layout.Column = 2; + label = uilabel(filters, Text="Level"); + label.Layout.Row = 1; + label.Layout.Column = 3; + obj.LevelFilter = uidropdown(filters, ... + Items=["Default", "TRACE+", "DEBUG+", "INFO+", ... + "WARNING+", "ERROR+", "CRITICAL"], ... + ItemsData=["default", "trace", "debug", "info", ... + "warning", "error", "critical"], ... + ValueChangedFcn=@(~, ~) obj.applyFilters(), ... + Tag="labkitSessionLogLevel"); + obj.LevelFilter.Value = "default"; + obj.LevelFilter.Layout.Row = 1; + obj.LevelFilter.Layout.Column = 4; + label = uilabel(filters, Text="Audience"); + label.Layout.Row = 1; + label.Layout.Column = 5; + obj.AudienceFilter = uidropdown(filters, ... + Items=["Default", "All", "User", "Developer"], ... + ItemsData=["default", "all", "user", "developer"], ... + ValueChangedFcn=@(~, ~) obj.applyFilters(), ... + Tag="labkitSessionLogAudience"); + obj.AudienceFilter.Value = "default"; + obj.AudienceFilter.Layout.Row = 1; + obj.AudienceFilter.Layout.Column = 6; + label = uilabel(filters, Text="Area"); + label.Layout.Row = 2; + label.Layout.Column = 1; + obj.CategoryFilter = uidropdown(filters, ... + Items="(All)", ItemsData="", ... + ValueChangedFcn=@(~, ~) obj.applyFilters(), ... + Tag="labkitSessionLogCategory"); + obj.CategoryFilter.Value = ""; + obj.CategoryFilter.Layout.Row = 2; + obj.CategoryFilter.Layout.Column = 2; + label = uilabel(filters, Text="Root"); + label.Layout.Row = 2; + label.Layout.Column = 3; + obj.RootFilter = uidropdown(filters, ... + Items="(All)", ItemsData="", ... + ValueChangedFcn=@(~, ~) obj.applyFilters(), ... + Tag="labkitSessionLogRoot"); + obj.RootFilter.Value = ""; + obj.RootFilter.Layout.Row = 2; + obj.RootFilter.Layout.Column = [4 8]; + obj.FollowButton = uibutton(filters, ... + Text="Pause follow", ... + ButtonPushedFcn=@(~, ~) obj.toggleFollow(), ... + Tag="labkitSessionLogFollow"); + obj.FollowButton.Layout.Row = 1; + obj.FollowButton.Layout.Column = [7 8]; + + noticeGrid = uigridlayout(root, [1 5], ... + ColumnWidth={"1x", 80, 90, 110, 150}, ... + Padding=[0 0 0 0], ColumnSpacing=6); + noticeGrid.Layout.Row = 3; + obj.NoticeLabel = uilabel(noticeGrid, ... + Text="", FontColor=[0.45 0.25 0], ... + Tag="labkitSessionLogNotices"); + uibutton(noticeGrid, Text="Refresh", ... + ButtonPushedFcn=@(~, ~) obj.refresh(), ... + Tag="labkitSessionLogRefresh"); + uibutton(noticeGrid, Text="Clear view", ... + ButtonPushedFcn=@(~, ~) obj.clearView(), ... + Tag="labkitSessionLogClear"); + obj.CopyButton = uibutton(noticeGrid, ... + Text="Copy selected", Enable="off", ... + ButtonPushedFcn=@(~, ~) obj.copyDetails(), ... + Tag="labkitSessionLogCopy"); + uibutton(noticeGrid, ... + Text="Export diagnostic ZIP", ... + ButtonPushedFcn=@(~, ~) ... + obj.Runtime.exportDiagnosticBundleInteractive(), ... + Tag="labkitSessionLogExport"); + + obj.EventTable = uitable(root, ... + Data=emptyRows(), ... + ColumnName=["Time", "Level", "Area", "Message"], ... + ColumnWidth={"auto", "auto", "auto", "auto"}, ... + RowName={}, FontSize=13, ... + CellSelectionCallback=@(~, event) ... + obj.selectRow(event), ... + Tag="labkitSessionLogTable"); + obj.EventTable.Layout.Row = 4; + + obj.DetailArea = uitextarea(root, ... + Editable="off", ... + Value="Select an event to inspect safe structured details.", ... + FontName="Consolas", ... + Tag="labkitSessionLogDetail"); + obj.DetailArea.Layout.Row = 5; + obj.Figure.SizeChangedFcn = ... + @(~, ~) obj.resizeTableColumns(); + obj.resizeTableColumns(); + end + + function acceptRecord(obj, record) + if obj.Closed + return; + end + obj.Projection.append(record); + obj.refreshView(true); + end + + function applyFilters(obj) + obj.Projection.setFilters( ... + Level=string(obj.LevelFilter.Value), ... + Audience=string(obj.AudienceFilter.Value), ... + Category=string(obj.CategoryFilter.Value), ... + RootAction=string(obj.RootFilter.Value), ... + Search=string(obj.SearchField.Value)); + obj.refreshView(); + end + + function clearView(obj) + obj.Projection.clearView(); + obj.DetailArea.Value = ... + "Select an event to inspect safe structured details."; + obj.CopyButton.Enable = "off"; + obj.refreshView(); + end + + function toggleFollow(obj) + obj.FollowLatest = ~obj.FollowLatest; + if obj.FollowLatest + obj.FollowButton.Text = "Pause follow"; + obj.followLatest(); + else + obj.FollowButton.Text = "Follow latest"; + end + end + + function refreshView(obj, incremental) + if nargin < 2 + incremental = false; + end + if obj.Closed || isempty(obj.Figure) || ~isvalid(obj.Figure) + return; + end + projection = obj.Projection.view(); + obj.updateChoices( ... + obj.CategoryFilter, projection.categories); + obj.updateChoices(obj.RootFilter, projection.rootActions); + rows = projection.rows; + sequences = rows.Sequence.'; + appended = incremental && ... + numel(sequences) == numel(obj.VisibleSequences) + 1 && ... + isequal(sequences(1:end - 1), obj.VisibleSequences); + unchanged = incremental && ... + isequal(sequences, obj.VisibleSequences); + if appended + obj.EventTable.Data(end + 1, :) = rows(end, 1:4); + obj.applySeverityStyle( ... + rows.Level(end), height(rows)); + elseif ~unchanged + obj.EventTable.Data = rows(:, 1:4); + obj.applySeverityStyles(rows.Level); + end + obj.VisibleSequences = sequences; + counts = projection.severityCounts; + obj.SummaryLabel.Text = char(sprintf( ... + "INFO %d WARNING %d ERROR %d CRITICAL %d Visible %d", ... + counts.info, counts.warning, counts.error, ... + counts.critical, height(rows))); + if isempty(projection.notices) + obj.NoticeLabel.Text = "Complete retained live view."; + obj.NoticeLabel.FontColor = [0.1 0.45 0.15]; + else + obj.NoticeLabel.Text = char(strjoin( ... + projection.notices, " | ")); + obj.NoticeLabel.FontColor = [0.55 0.28 0]; + end + if obj.FollowLatest && (appended || ~incremental) + obj.followLatest(); + end + end + + function updateChoices(~, dropdown, values) + current = string(dropdown.Value); + values = string(values(:)).'; + dropdown.Items = ["(All)", values]; + dropdown.ItemsData = ["", values]; + if any(current == dropdown.ItemsData) + dropdown.Value = current; + else + dropdown.Value = ""; + end + end + + function selectRow(obj, event) + if isempty(event.Indices) + return; + end + row = event.Indices(1, 1); + if row < 1 || row > numel(obj.VisibleSequences) + return; + end + record = obj.Projection.detail( ... + obj.VisibleSequences(row)); + if isempty(record) + return; + end + obj.DetailArea.Value = cellstr(detailLines(record)); + obj.CopyButton.Enable = "on"; + end + + function copyDetails(obj) + try + clipboard("copy", strjoin( ... + string(obj.DetailArea.Value), newline)); + catch + obj.NoticeLabel.Text = ... + "Clipboard access is unavailable in this MATLAB session."; + obj.NoticeLabel.FontColor = [0.55 0.28 0]; + end + end + + function followLatest(obj) + if isempty(obj.VisibleSequences) + return; + end + try + scroll(obj.EventTable, "bottom"); + catch + % Older MATLAB releases retain native table navigation. + end + end + + function resizeTableColumns(obj) + if isempty(obj.Figure) || ~isvalid(obj.Figure) || ... + isempty(obj.EventTable) || ~isvalid(obj.EventTable) + return; + end + if ~isempty(obj.RootPanel) && isvalid(obj.RootPanel) + obj.RootPanel.Position = ... + [1 1 obj.Figure.Position(3:4)]; + end + available = max(620, obj.Figure.Position(3) - 42); + fixed = 190 + 80 + 260; + obj.EventTable.ColumnWidth = ... + {190, 80, 260, max(220, available - fixed)}; + end + + function applySeverityStyles(obj, levels) + try + removeStyle(obj.EventTable); + addSeverityStyle( ... + obj.EventTable, levels, "WARNING", ... + [1.00 0.96 0.78], [0.35 0.25 0.00]); + addSeverityStyle( ... + obj.EventTable, levels, "ERROR", ... + [1.00 0.86 0.86], [0.55 0.00 0.00]); + addSeverityStyle( ... + obj.EventTable, levels, "CRITICAL", ... + [0.55 0.05 0.05], [1.00 1.00 1.00]); + catch + % Severity text remains authoritative on older MATLAB releases. + end + end + + function applySeverityStyle(obj, level, row) + try + level = string(level); + if level == "WARNING" + addSeverityStyle( ... + obj.EventTable, level, "WARNING", ... + [1.00 0.96 0.78], [0.35 0.25 0.00], row); + elseif level == "ERROR" + addSeverityStyle( ... + obj.EventTable, level, "ERROR", ... + [1.00 0.86 0.86], [0.55 0.00 0.00], row); + elseif level == "CRITICAL" + addSeverityStyle( ... + obj.EventTable, level, "CRITICAL", ... + [0.55 0.05 0.05], [1.00 1.00 1.00], row); + end + catch + % Severity text remains authoritative on older MATLAB releases. + end + end + end +end + +function position = viewerPosition() +screen = double(get(groot, "ScreenSize")); +screenWidth = screen(3); +screenHeight = screen(4); +width = min(screenWidth, max(820, min(1280, screenWidth - 100))); +height = min(screenHeight, max(560, min(760, screenHeight - 140))); +x = max(screen(1), screen(1) + (screenWidth - width) / 2); +y = max(screen(2), screen(2) + (screenHeight - height) / 2); +position = round([x y width height]); +end + +function value = detailLines(record) +attributes = "{}"; +if ~isempty(fieldnames(record.attributes)) + attributes = string(jsonencode( ... + record.attributes, PrettyPrint=true)); +end +exception = record.exception; +stack = ""; +if isstruct(exception) && isfield(exception, "stack") && ... + ~isempty(exception.stack) + stack = strjoin(string(exception.stack), newline); +end +value = [ + "Event: " + string(record.eventName) + "Severity: " + string(record.severity) + ... + " Audience: " + string(record.audience) + "Category: " + string(record.category) + "Operation: " + string(record.operationId) + "Parent: " + string(record.parentOperationId) + "Root action: " + string(record.rootActionId) + "Result: " + string(record.operationResult) + ... + " State: " + string(record.stateDisposition) + "Duration (s): " + numericText(record.durationSeconds) + "Exception: " + string(exception.identifier) + "Attributes:" + attributes + "Stack:" + stack + ]; +end + +function value = numericText(number) +if isempty(number) + value = ""; +else + value = string(number); +end +end + +function value = emptyRows() +value = table( ... + strings(0, 1), strings(0, 1), ... + strings(0, 1), strings(0, 1), ... + VariableNames=["Time", "Level", "Area", "Message"]); +end + +function addSeverityStyle( ... + tableHandle, levels, severity, background, font, rows) +if nargin < 6 + rows = find(string(levels) == severity); +end +if isempty(rows) + return; +end +style = uistyle( ... + BackgroundColor=background, FontColor=font); +addStyle(tableHandle, style, "row", rows); +end diff --git a/+labkit/+app/+internal/SyntheticInputGenerator.m b/+labkit/+app/+internal/SyntheticInputGenerator.m new file mode 100644 index 000000000..b9d41100c --- /dev/null +++ b/+labkit/+app/+internal/SyntheticInputGenerator.m @@ -0,0 +1,106 @@ +classdef (Hidden, Sealed) SyntheticInputGenerator + % Private validation and publication boundary for App synthetic inputs. + + methods (Static) + function pack = generate(definition, folder) + if ~isa(definition, "labkit.app.Definition") || ~isscalar(definition) + error("labkit:app:runtime:InvariantFailure", ... + "SyntheticInputGenerator requires one Definition."); + end + if isempty(definition.BuildSyntheticSample) + error("labkit:app:contract:UnsupportedOperation", ... + "Definition does not declare BuildSyntheticSample."); + end + if isempty(definition.ProjectSchema) + error("labkit:app:contract:UnsupportedOperation", ... + "Synthetic inputs require ProjectSchema."); + end + context = labkit.app.synthetic.Context(folder); + pack = definition.BuildSyntheticSample(context); + if ~isa(pack, "labkit.app.synthetic.Pack") || ~isscalar(pack) + error("labkit:app:contract:InvalidValue", ... + "BuildSyntheticSample must return one " + ... + "labkit.app.synthetic.Pack value."); + end + labkit.app.internal.SyntheticInputGenerator.validateProject( ... + definition, pack); + labkit.app.internal.SyntheticInputGenerator.verifyArtifacts( ... + context, pack); + labkit.app.internal.SyntheticInputGenerator.writeManifest( ... + context, pack); + end + end + + methods (Static, Access = private) + function validateProject(definition, pack) + try + accepted = definition.ProjectSchema.Validate( ... + pack.InitialProject); + catch cause + failure = MException( ... + "labkit:app:contract:InvalidValue", ... + "BuildSyntheticSample returned an invalid current project."); + failure = addCause(failure, cause); + throw(failure); + end + if ~isequal(accepted, true) + error("labkit:app:contract:InvalidValue", ... + "BuildSyntheticSample returned an invalid current project."); + end + end + + function verifyArtifacts(context, pack) + for index = 1:numel(pack.Artifacts) + artifact = pack.Artifacts{index}; + if artifact.Expectation == "exports" + continue; + end + pathParts = cellstr(split(artifact.RelativePath, "/")); + filepath = string(fullfile( ... + char(context.RootFolder), pathParts{:})); + if exist(char(filepath), "file") ~= 2 && ... + exist(char(filepath), "dir") ~= 7 + error("labkit:app:contract:InvalidValue", ... + "BuildSyntheticSample did not create artifact %s.", ... + artifact.Id); + end + end + end + + function writeManifest(context, pack) + artifacts = repmat(struct( ... + "id", "", "role", "", "relativePath", "", ... + "expectation", ""), 1, numel(pack.Artifacts)); + for index = 1:numel(pack.Artifacts) + artifact = pack.Artifacts{index}; + artifacts(index) = struct( ... + "id", artifact.Id, ... + "role", artifact.Role, ... + "relativePath", artifact.RelativePath, ... + "expectation", artifact.Expectation); + end + payload = struct( ... + "type", "labkit.synthetic-input-pack", ... + "scenario", pack.Scenario, ... + "artifacts", artifacts); + filepath = string(fullfile( ... + context.RootFolder, "synthetic-input-pack.json")); + temporary = filepath + ".tmp"; + file = fopen(char(temporary), "w"); + if file < 0 + error("labkit:app:runtime:SyntheticInputWriteFailed", ... + "Could not write the synthetic-input manifest."); + end + cleanup = onCleanup(@() fclose(file)); + fprintf(file, "%s\n", jsonencode(payload, PrettyPrint=true)); + clear cleanup + [moved, message] = movefile( ... + char(temporary), char(filepath), "f"); + if ~moved + error("labkit:app:runtime:SyntheticInputWriteFailed", ... + "Could not publish the synthetic-input manifest: %s", ... + message); + end + end + end +end diff --git a/+labkit/+app/+internal/private/createRectangleEditor.m b/+labkit/+app/+internal/private/createRectangleEditor.m index 649cc285e..b5c67f6ad 100644 --- a/+labkit/+app/+internal/private/createRectangleEditor.m +++ b/+labkit/+app/+internal/private/createRectangleEditor.m @@ -21,7 +21,9 @@ % resizable - logical, default true. % onMoving - callback(position) during pointer movement, default []. % onMoved - callback(position) after pointer release, default []. -% onBackgroundDown - callback(src,event) for background clicks, default []. +% onBackgroundDown - callback(src,event) for plot clicks outside the +% rectangle and clicks on the rectangle that do not begin a drag, +% default []. % % Returned editor API: % getPosition(), setPosition(position), setImageSize(imageSize), @@ -64,6 +66,7 @@ state.dragCorner = 0; state.dragStartPoint = [0 0]; state.dragStartPosition = state.position; + state.dragMoved = false; state.session = runtime.createSession(struct( ... 'name', 'rectangleEditor', ... 'onPointerDown', @onPointerDown, ... @@ -193,7 +196,8 @@ function onPointerDown(src, event) state.dragCorner = find(state.cornerHandles == src, 1); if ~isempty(state.dragCorner) state.dragMode = "resize"; - elseif isequal(src, state.box) && state.movable + elseif state.movable && (isequal(src, state.box) || ... + positionContainsPoint(state.position, axesPoint(state.ax))) state.dragCorner = 0; state.dragMode = "move"; else @@ -202,6 +206,7 @@ function onPointerDown(src, event) end state.dragStartPoint = axesPoint(state.ax); state.dragStartPosition = state.position; + state.dragMoved = false; state.session.captureDrag(@onDrag, @onRelease); end @@ -216,14 +221,22 @@ function onDrag(~, ~) end state.position = constrainPosition(candidate, state.bounds, ... state.aspectRatio, state.fixedAspectRatio, state.minimumSize); + state.dragMoved = state.dragMoved || any( ... + abs(state.position - state.dragStartPosition) > 1e-9); refresh(); invokeCallback(state.onMoving, state.position); end - function onRelease(~, ~) + function onRelease(src, event) + moved = state.dragMoved; state.dragMode = ""; state.dragCorner = 0; - invokeCallback(state.onMoved, state.position); + state.dragMoved = false; + if ~moved && ~isempty(state.onBackgroundDown) + invokePointerCallback(state.onBackgroundDown, src, event); + else + invokeCallback(state.onMoved, state.position); + end end end @@ -234,6 +247,14 @@ function onRelease(~, ~) end end +function tf = positionContainsPoint(position, point) + tf = isnumeric(position) && numel(position) == 4 && ... + isnumeric(point) && numel(point) == 2 && ... + all(isfinite(double([position(:); point(:)]))) && ... + point(1) >= position(1) && point(1) <= position(1) + position(3) && ... + point(2) >= position(2) && point(2) <= position(2) + position(4); +end + function imageSize = normalizeImageSize(value) value = double(value(:).'); assert(numel(value) >= 2 && all(isfinite(value(1:2))) && ... diff --git a/+labkit/+app/+internal/private/nativeLayoutPolicy.m b/+labkit/+app/+internal/private/nativeLayoutPolicy.m index 4ae7cf630..9123ef3ab 100644 --- a/+labkit/+app/+internal/private/nativeLayoutPolicy.m +++ b/+labkit/+app/+internal/private/nativeLayoutPolicy.m @@ -21,7 +21,6 @@ "FileListHeight", 236, ... "FileListNoStatusHeight", 174, ... "TableHeight", 202, ... - "LogHeight", 240, ... "StatusHeight", 146, ... "StatusChromeHeight", 36, ... "StatusLineHeight", 22, ... diff --git a/+labkit/+app/+layout/logPanel.m b/+labkit/+app/+layout/logPanel.m deleted file mode 100644 index c5a73c318..000000000 --- a/+labkit/+app/+layout/logPanel.m +++ /dev/null @@ -1,28 +0,0 @@ -function node = logPanel(id, varargin) -%LOGPANEL Add a text display for App log messages. -% -% Usage: -% node = labkit.app.layout.logPanel(id, Name=Value) -% -% Description: -% Declares a runtime-populated multiline App log display. -% -% Inputs: -% id - Unique MATLAB identifier for the layout target. -% -% Options: -% Title - Visible panel title. Default: "Log". -% -% Outputs: -% node - Immutable internal layout node accepted by containers. -% -% Errors: -% Throws labkit:app:contract:InvalidValue for an invalid ID. -% -% Typical Call: -% node = labkit.app.layout.logPanel("appLog"); -% -% See also labkit.app.layout.statusPanel, -% labkit.app.CallbackContext -node = labkit.app.internal.LayoutNode.logPanel(id, varargin{:}); -end diff --git a/+labkit/+app/+layout/statusPanel.m b/+labkit/+app/+layout/statusPanel.m index 614652575..e981f23bb 100644 --- a/+labkit/+app/+layout/statusPanel.m +++ b/+labkit/+app/+layout/statusPanel.m @@ -27,7 +27,6 @@ % Typical Call: % node = labkit.app.layout.statusPanel("status"); % -% See also labkit.app.layout.logPanel, -% labkit.app.CallbackContext +% See also labkit.app.CallbackContext node = labkit.app.internal.LayoutNode.statusPanel(id, varargin{:}); end diff --git a/+labkit/+app/+diagnostic/Artifact.m b/+labkit/+app/+synthetic/Artifact.m similarity index 76% rename from +labkit/+app/+diagnostic/Artifact.m rename to +labkit/+app/+synthetic/Artifact.m index 8726a1fb3..e25be8877 100644 --- a/+labkit/+app/+diagnostic/Artifact.m +++ b/+labkit/+app/+synthetic/Artifact.m @@ -1,18 +1,18 @@ classdef (Sealed) Artifact - %ARTIFACT Describe one anonymous diagnostic-sample artifact. + %ARTIFACT Describe one anonymous synthetic-input artifact. % % Usage: - % artifact = labkit.app.diagnostic.Artifact( ... + % artifact = labkit.app.synthetic.Artifact( ... % id,role,relativePath,Name=Value) % % Description: % Identifies one synthetic input, expected export, or support file - % beneath a diagnostic sample root without exposing a user file path. + % beneath a synthetic-input root without exposing a user file path. % % Inputs: - % id - Nonempty semantic identifier unique within a SamplePack. + % id - Nonempty semantic identifier unique within a Pack. % role - Nonempty App-owned artifact purpose. - % relativePath - Nonempty diagnostic-root-relative path without + % relativePath - Nonempty synthetic-root-relative path without % traversal. % % Optional Name-Value Arguments: @@ -20,7 +20,7 @@ % Default: "loads". % % Outputs: - % artifact - Immutable diagnostic artifact value. + % artifact - Immutable synthetic-input artifact value. % % Errors: % labkit:app:contract:UnknownArgument - An option is unknown, @@ -29,12 +29,12 @@ % malformed. % % Example: - % artifact = labkit.app.diagnostic.Artifact( ... + % artifact = labkit.app.synthetic.Artifact( ... % "input","source","samples/input.csv"); % assert(artifact.Expectation == "loads") % - % See also labkit.app.diagnostic.SampleContext, - % labkit.app.diagnostic.SamplePack + % See also labkit.app.synthetic.Context, + % labkit.app.synthetic.Pack properties (SetAccess = immutable) Id (1, 1) string @@ -46,7 +46,7 @@ methods function obj = Artifact(id, role, relativePath, varargin) options = labkit.app.internal.OptionParser.parse( ... - "labkit.app.diagnostic.Artifact", "Expectation", ... + "labkit.app.synthetic.Artifact", "Expectation", ... varargin{:}); obj.Id = nonemptyText(id, "Id"); obj.Role = nonemptyText(role, "Role"); @@ -56,7 +56,7 @@ if ~any(expectation == ... ["loads", "rejects", "exports", "support"]) error("labkit:app:contract:InvalidValue", ... - "Diagnostic Artifact Expectation is unsupported."); + "Synthetic Artifact Expectation is unsupported."); end obj.Expectation = expectation; end @@ -67,7 +67,7 @@ value = replace(nonemptyText(value, "RelativePath"), "\", "/"); if startsWith(value, "/") || startsWith(value, "//") || ... ~isempty(regexp(char(value), '^[A-Za-z]:', "once")) - invalid("RelativePath must be diagnostic-root-relative."); + invalid("RelativePath must be synthetic-root-relative."); end parts = split(value, "/"); if any(parts == ["", ".", ".."], "all") @@ -95,5 +95,5 @@ function invalid(message, varargin) error("labkit:app:contract:InvalidValue", ... - "Diagnostic Artifact " + message, varargin{:}); + "Synthetic Artifact " + message, varargin{:}); end diff --git a/+labkit/+app/+diagnostic/SampleContext.m b/+labkit/+app/+synthetic/Context.m similarity index 73% rename from +labkit/+app/+diagnostic/SampleContext.m rename to +labkit/+app/+synthetic/Context.m index 8c3247726..6b0156dc6 100644 --- a/+labkit/+app/+diagnostic/SampleContext.m +++ b/+labkit/+app/+synthetic/Context.m @@ -1,21 +1,21 @@ -classdef (Sealed) SampleContext - %SAMPLECONTEXT Provide bounded folders for anonymous debug samples. +classdef (Sealed) Context + %CONTEXT Provide bounded folders for anonymous synthetic inputs. % % Usage: - % context = labkit.app.diagnostic.SampleContext(artifactFolder) + % context = labkit.app.synthetic.Context(rootFolder) % filepath = context.samplePath(relativePath) % filepath = context.outputPath(relativePath) % record = context.sourceRecord(id,role,filepath,required) % artifact = context.artifact(id,role,filepath,Name=Value) % % Description: - % SampleContext creates one diagnostic root with samples and outputs - % children. App-owned BuildDebugSample callbacks may write only + % Context creates one synthetic-input root with samples and outputs + % children. App-owned BuildSyntheticSample callbacks may write only % anonymous synthetic files beneath these folders. The value does not % expose a runtime, recorder, project store, or UI object. % % Inputs: - % artifactFolder - Nonempty scalar diagnostic-session folder. + % rootFolder - Nonempty scalar folder dedicated to one generated pack. % relativePath - Nonempty child path without an absolute root, empty % segment, current segment, or parent traversal. % id - Stable portable-source identifier. @@ -24,43 +24,42 @@ % required - Logical scalar source requirement. % % Outputs: - % context - Immutable diagnostic sample context. + % context - Immutable synthetic-input context. % filepath - Absolute path bounded by SampleFolder or OutputFolder. % record - Portable source value from % labkit.app.project.sourceRecord. - % artifact - Typed diagnostic artifact whose relative path is derived - % from a filepath beneath ArtifactFolder. + % artifact - Typed synthetic artifact whose relative path is derived + % from a filepath beneath RootFolder. % % Errors: % labkit:app:contract:InvalidValue - A folder, relative path, or source % argument is malformed. - % MATLAB filesystem errors propagate when the diagnostic folders cannot + % MATLAB filesystem errors propagate when the synthetic folders cannot % be created. % % Typical Call: - % context = labkit.app.diagnostic.SampleContext(tempname); + % context = labkit.app.synthetic.Context(tempname); % filepath = context.samplePath("input.csv"); % - % See also labkit.app.diagnostic.Artifact, - % labkit.app.diagnostic.SamplePack, + % See also labkit.app.synthetic.Artifact, + % labkit.app.synthetic.Pack, % labkit.app.project.sourceRecord properties (SetAccess = immutable) - ArtifactFolder (1, 1) string + RootFolder (1, 1) string SampleFolder (1, 1) string OutputFolder (1, 1) string end methods - function obj = SampleContext(artifactFolder) - artifactFolder = nonemptyText( ... - artifactFolder, "ArtifactFolder"); - obj.ArtifactFolder = artifactFolder; + function obj = Context(rootFolder) + rootFolder = nonemptyText(rootFolder, "RootFolder"); + obj.RootFolder = rootFolder; obj.SampleFolder = string(fullfile( ... - char(artifactFolder), "samples")); + char(rootFolder), "samples")); obj.OutputFolder = string(fullfile( ... - char(artifactFolder), "outputs")); - ensureFolder(obj.ArtifactFolder); + char(rootFolder), "outputs")); + ensureFolder(obj.RootFolder); ensureFolder(obj.SampleFolder); ensureFolder(obj.OutputFolder); end @@ -83,8 +82,8 @@ function artifact = artifact(obj, id, role, filepath, varargin) relativePath = relativeArtifactPath( ... - obj.ArtifactFolder, filepath); - artifact = labkit.app.diagnostic.Artifact( ... + obj.RootFolder, filepath); + artifact = labkit.app.synthetic.Artifact( ... id, role, relativePath, varargin{:}); end end @@ -98,7 +97,7 @@ end parts = split(relativePath, "/"); if any(parts == ["", ".", ".."], "all") - invalid("relativePath must not traverse the diagnostic folder."); + invalid("relativePath must not traverse the synthetic folder."); end partCells = cellstr(parts); filepath = string(fullfile(char(folder), partCells{:})); @@ -113,7 +112,7 @@ function ensureFolder(folder) end function relativePath = relativeArtifactPath(root, filepath) -root = normalizedAbsolutePath(root, "ArtifactFolder"); +root = normalizedAbsolutePath(root, "RootFolder"); filepath = normalizedAbsolutePath(filepath, "filepath"); rootPrefix = root + "/"; if ispc @@ -122,7 +121,7 @@ function ensureFolder(folder) inside = startsWith(filepath, rootPrefix); end if ~inside - invalid("filepath must remain beneath ArtifactFolder."); + invalid("filepath must remain beneath RootFolder."); end relativePath = extractAfter(filepath, strlength(rootPrefix)); if strlength(relativePath) == 0 @@ -158,5 +157,5 @@ function ensureFolder(folder) function invalid(message, varargin) error("labkit:app:contract:InvalidValue", ... - "Diagnostic SampleContext " + message, varargin{:}); + "Synthetic Context " + message, varargin{:}); end diff --git a/+labkit/+app/+diagnostic/SamplePack.m b/+labkit/+app/+synthetic/Pack.m similarity index 76% rename from +labkit/+app/+diagnostic/SamplePack.m rename to +labkit/+app/+synthetic/Pack.m index 382f1a88e..2059b990e 100644 --- a/+labkit/+app/+diagnostic/SamplePack.m +++ b/+labkit/+app/+synthetic/Pack.m @@ -1,22 +1,23 @@ -classdef (Sealed) SamplePack - %SAMPLEPACK Describe one typed anonymous App reproduction scenario. +classdef (Sealed) Pack + %PACK Describe one typed anonymous App reproduction scenario. % % Usage: - % pack = labkit.app.diagnostic.SamplePack( ... + % pack = labkit.app.synthetic.Pack( ... % Scenario=scenario,InitialProject=project,Artifacts=artifacts) % % Description: % Couples one App-authored synthetic project with its anonymous artifact - % declarations so verbose diagnostics can reproduce a named scenario. + % declarations so users and tests can reproduce a named scenario through + % the App's ordinary import workflow. % % Required Name-Value Arguments: % Scenario - Nonempty stable scenario identifier. % InitialProject - Scalar current App project struct. - % Artifacts - Row cell array of labkit.app.diagnostic.Artifact values. + % Artifacts - Row cell array of labkit.app.synthetic.Artifact values. % Empty is legal for an App whose scenario needs no files. % % Outputs: - % pack - Immutable diagnostic sample pack. + % pack - Immutable synthetic-input pack. % % Errors: % labkit:app:contract:UnknownArgument - An argument is missing, unknown, @@ -27,15 +28,15 @@ % duplicated. % % Example: - % artifact = labkit.app.diagnostic.Artifact( ... + % artifact = labkit.app.synthetic.Artifact( ... % "input","source","samples/input.csv"); - % pack = labkit.app.diagnostic.SamplePack( ... + % pack = labkit.app.synthetic.Pack( ... % Scenario="representative",InitialProject=struct(), ... % Artifacts={artifact}); % assert(pack.Scenario == "representative") % - % See also labkit.app.diagnostic.SampleContext, - % labkit.app.diagnostic.Artifact, + % See also labkit.app.synthetic.Context, + % labkit.app.synthetic.Artifact, % labkit.app.Definition properties (SetAccess = immutable) @@ -45,14 +46,14 @@ end methods - function obj = SamplePack(varargin) + function obj = Pack(varargin) names = ["Scenario", "InitialProject", "Artifacts"]; options = labkit.app.internal.OptionParser.parse( ... - "labkit.app.diagnostic.SamplePack", names, varargin{:}); + "labkit.app.synthetic.Pack", names, varargin{:}); for name = names if ~isfield(options, name) error("labkit:app:contract:UnknownArgument", ... - "labkit.app.diagnostic.SamplePack requires %s.", ... + "labkit.app.synthetic.Pack requires %s.", ... name); end end @@ -66,7 +67,7 @@ if ~iscell(artifacts) || ... (~isempty(artifacts) && ~isrow(artifacts)) || ... ~all(cellfun(@(value) isa(value, ... - "labkit.app.diagnostic.Artifact") && isscalar(value), ... + "labkit.app.synthetic.Artifact") && isscalar(value), ... artifacts)) invalid("Artifacts must be a row cell array of Artifact values."); end @@ -78,7 +79,7 @@ if numel(unique(ids)) ~= numel(ids) || ... numel(unique(paths)) ~= numel(paths) error("labkit:app:contract:DuplicateId", ... - "Diagnostic SamplePack artifact IDs and paths must be unique."); + "Synthetic Pack artifact IDs and paths must be unique."); end obj.Artifacts = artifacts; end @@ -97,5 +98,5 @@ function invalid(message, varargin) error("labkit:app:contract:InvalidValue", ... - "Diagnostic SamplePack " + message, varargin{:}); + "Synthetic Pack " + message, varargin{:}); end diff --git a/+labkit/+app/CallbackContext.m b/+labkit/+app/CallbackContext.m index ee398555d..4db4c9b5f 100644 --- a/+labkit/+app/CallbackContext.m +++ b/+labkit/+app/CallbackContext.m @@ -2,11 +2,7 @@ %CALLBACKCONTEXT Provide declared App-neutral runtime capabilities. % % Usage: - % labkit.app.CallbackContext.appendStatus(context, message) - % context.appendStatus(message) - % context.reportError(operation, exception) - % context.diagnosticCheckpoint(id) - % context.diagnosticCount(id, count) + % context.log(severity, eventName, message, Name=Value) % context.alert(message, title) % result = context.chooseOption(prompt, choices, Name=Value) % result = context.chooseInputFile(filters, startPath) @@ -35,8 +31,12 @@ % Inputs: % state - Complete App-owned state value. % message - Scalar reader-facing text. - % operation - Scalar diagnostic operation text. - % exception - Scalar MException. + % severity - Log severity from "trace" through "critical". + % eventName - Stable semantic event identifier. + % Category - Semantic App capability category. Default: "workflow". + % Audience - "user" or "developer"; default: "user". + % Attributes - Scalar privacy-safe structured details. Default: struct(). + % Exception - Scalar MException associated with the event. Default: []. % id - Stable semantic diagnostic or resource identifier. % count - Nonnegative integer diagnostic count. % title - Scalar reader-facing dialog title. @@ -79,7 +79,8 @@ % event % callbackContext (1,1) labkit.app.CallbackContext % end - % callbackContext.appendStatus("Analysis started."); + % callbackContext.log("info", "analysis.started", ... + % "Analysis started."); % end % % See also labkit.app.Definition, labkit.app.dialog.Choice, @@ -110,36 +111,20 @@ end methods - function appendStatus(obj, message) - message = scalarText(message, "message"); - obj.invoke("appendStatus", "workflow", {message}, 0); - end - - function reportError(obj, operation, exception) - operation = scalarText(operation, "operation"); - if ~isa(exception, "MException") || ~isscalar(exception) - error("labkit:app:contract:InvalidValue", ... - "CallbackContext exception must be a scalar MException."); - end - obj.invoke("reportError", "diagnostics", ... - {operation, exception}, 0); - end - - function diagnosticCheckpoint(obj, id) - id = nonemptyText(id, "diagnostic id"); - obj.invoke("diagnosticCheckpoint", "diagnostics", {id}, 0); - end - - function diagnosticCount(obj, id, count) - id = nonemptyText(id, "diagnostic id"); - if ~(isnumeric(count) && isscalar(count) && ... - isfinite(count) && count >= 0 && count == fix(count)) - error("labkit:app:contract:InvalidValue", ... - "CallbackContext diagnostic count must be a " + ... - "nonnegative integer."); - end - obj.invoke("diagnosticCount", "diagnostics", ... - {id, double(count)}, 0); + function log(obj, severity, eventName, message, varargin) + options = labkit.app.internal.OptionParser.parse( ... + "labkit.app.CallbackContext.log", ... + ["Category", "Audience", "Attributes", "Exception"], varargin{:}); + values = labkit.app.internal.SessionEventValidator.logInputs( ... + severity, eventName, message, ... + optionValue(options, "Category", "workflow"), ... + optionValue(options, "Audience", "user"), ... + optionValue(options, "Attributes", struct()), ... + optionValue(options, "Exception", [])); + obj.invoke("log", "logging", ... + {values.severity, values.eventName, values.message, ... + values.category, values.audience, values.attributes, ... + values.exception}, 0); end function alert(obj, message, title) diff --git a/+labkit/+app/Definition.m b/+labkit/+app/Definition.m index 6d5df1522..a5297d83e 100644 --- a/+labkit/+app/Definition.m +++ b/+labkit/+app/Definition.m @@ -8,7 +8,6 @@ % Requirements=requirements, Workbench=workbench, Name=Value) % fig = app.launch() % fig = app.launch(InitialProject=project) - % fig = app.launch(Diagnostics=diagnosticOptions) % requirements = app.launch("requirements") % version = app.launch("version") % @@ -42,7 +41,7 @@ % empty. % OnStart - Fixed callback state = callback(state,context), invoked % after the first view commit. Default: empty. - % BuildDebugSample - Fixed callback pack = callback(context). Default: + % BuildSyntheticSample - Fixed callback pack = callback(context). Default: % empty. % % Outputs: @@ -50,9 +49,6 @@ % % Definition Methods: % launch() - Build and show the native MATLAB App figure. - % launch(Diagnostics=options) - Use one - % labkit.app.diagnostic.Options value for standard or verbose - % sanitized runtime recording. % launch("requirements") - Return declared facade requirements without % creating a figure. % launch("version") - Return product version metadata without creating @@ -98,7 +94,7 @@ CreateSession PresentWorkbench OnStart - BuildDebugSample + BuildSyntheticSample end properties (SetAccess = immutable, GetAccess = { ... @@ -113,7 +109,7 @@ "Entrypoint", "AppId", "Title", "DisplayName", "Family", ... "AppVersion", "Updated", "Requirements", "Workbench", ... "ProjectSchema", "CreateSession", "PresentWorkbench", ... - "OnStart", "BuildDebugSample"]; + "OnStart", "BuildSyntheticSample"]; options = labkit.app.internal.OptionParser.parse( ... "labkit.app.Definition", names, varargin{:}); required = [ ... @@ -148,8 +144,8 @@ startCallback = optionalFixedCallback( ... options, "OnStart", 2, 1); obj.OnStart = startCallback; - obj.BuildDebugSample = optionalFixedCallback( ... - options, "BuildDebugSample", 1, 1); + obj.BuildSyntheticSample = optionalFixedCallback( ... + options, "BuildSyntheticSample", 1, 1); obj.Compiled = labkit.app.internal.CompiledDefinition( ... options.Workbench, startCallback); end @@ -161,14 +157,13 @@ function varargout = launch(obj, varargin) %LAUNCH Answer metadata requests or show the native MATLAB App. initialProject = []; - diagnostics = labkit.app.diagnostic.Options(); if ~isempty(varargin) && ... ~(numel(varargin) == 1 && ... (ischar(varargin{1}) || ... (isstring(varargin{1}) && isscalar(varargin{1})))) options = labkit.app.internal.OptionParser.parse( ... "labkit.app.Definition.launch", ... - ["InitialProject", "Diagnostics"], ... + "InitialProject", ... varargin{:}); if isfield(options, "InitialProject") if ~isstruct(options.InitialProject) || ... @@ -179,16 +174,6 @@ end initialProject = options.InitialProject; end - if isfield(options, "Diagnostics") - if ~isa(options.Diagnostics, ... - "labkit.app.diagnostic.Options") || ... - ~isscalar(options.Diagnostics) - error("labkit:app:contract:InvalidValue", ... - "Definition launch Diagnostics must be one " + ... - "labkit.app.diagnostic.Options value."); - end - diagnostics = options.Diagnostics; - end varargin = {}; end if ~isempty(varargin) @@ -225,8 +210,7 @@ "Definition launch returns at most one figure."); end runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - obj, ... - initialProject, struct(), diagnostics); + obj, initialProject, struct()); runtime.showFigure(); figure = runtime.figureHandle(); if nargout == 1 diff --git a/+labkit/+app/version.m b/+labkit/+app/version.m index c2cbb195c..1d996637e 100644 --- a/+labkit/+app/version.m +++ b/+labkit/+app/version.m @@ -23,13 +23,13 @@ % % Example: % info = labkit.app.version(); -% assert(startsWith(info.current, "1.")) +% assert(startsWith(info.current, "2.")) % % See also labkit.contract.versionInfo, % labkit.contract.requirements, % labkit.app.Definition info = labkit.contract.versionInfo( ... - "app", "1.2.4", ">=1 <2", "stable", ... + "app", "2.0.0", ">=2 <3", "stable", ... "Explicit LabKit App SDK contract for tracked production Apps."); end diff --git a/+labkit/+dta/detectPulses.m b/+labkit/+dta/detectPulses.m index cb55edf44..4d2ffaa01 100644 --- a/+labkit/+dta/detectPulses.m +++ b/+labkit/+dta/detectPulses.m @@ -41,21 +41,11 @@ % Output Fields: % ok - Logical success flag. % method - "metadata-current", "metadata-voltage", "auto-from-Im", or "-". +% pre - Structure with start_s and end_s. % cath - Structure with start_s, end_s, and current_A. -% anod - Structure with start_s, end_s, and current_A. % gap - Structure with start_s, end_s, and center_s. -% cath_start - Cathodic-window start time in seconds. -% cath_end - Cathodic-window end time in seconds. -% anod_start - Anodic-window start time in seconds. -% anod_end - Anodic-window end time in seconds. -% Ic_nominal - Nominal or median cathodic current in amperes. -% Ia_nominal - Nominal or median anodic current in amperes. -% pre_start - Start time of the pre-pulse region in seconds. -% pre_end - End time of the pre-pulse region in seconds. -% gap_start - Start time between cathodic and anodic pulses in seconds. -% gap_end - End time between cathodic and anodic pulses in seconds. -% post_start - Start time of the post-pulse region in seconds. -% post_end - End time of the post-pulse region in seconds. +% anod - Structure with start_s, end_s, and current_A. +% post - Structure with start_s and end_s. % % Failure Behavior: % Missing usable metadata, absent opposite-polarity current phases, or diff --git a/+labkit/+dta/private/detectPulseCore.m b/+labkit/+dta/private/detectPulseCore.m index c5881f119..b3627d6d6 100644 --- a/+labkit/+dta/private/detectPulseCore.m +++ b/+labkit/+dta/private/detectPulseCore.m @@ -13,7 +13,7 @@ % Im - measured current vector in ampere, same length as t. % meta - parsed chrono metadata struct with optional steps field. % opts - string mode or struct with mode. Accepted private modes are -% "metadata_first", "metadata_only", and "current_only"; legacy +% "metadata_first", "metadata_only", and "current_only"; public % display labels are normalized here. % % Output: @@ -28,11 +28,21 @@ if nargin < 4 || isempty(opts) opts = defaultPulseOptions(); elseif ischar(opts) || isstring(opts) - opts = struct('mode', normalizeMode(opts)); + [mode, recognized] = normalizeMode(opts); + if ~recognized + [pulse, msg] = unsupportedMode(opts); + return + end + opts = struct('mode', mode); elseif ~isfield(opts, 'mode') opts.mode = "metadata_first"; else - opts.mode = normalizeMode(opts.mode); + modeText = opts.mode; + [opts.mode, recognized] = normalizeMode(modeText); + if ~recognized + [pulse, msg] = unsupportedMode(modeText); + return + end end pulse = emptyPulse(); @@ -62,7 +72,8 @@ pulse.message = msg; end -function mode = normalizeMode(modeText) +function [mode, recognized] = normalizeMode(modeText) + recognized = true; switch string(modeText) case {"metadata_first", "Metadata first, then auto"} mode = "metadata_first"; @@ -71,6 +82,13 @@ case {"current_only", "Auto from Im only"} mode = "current_only"; otherwise - mode = "metadata_first"; + mode = ""; + recognized = false; end end + +function [pulse, msg] = unsupportedMode(modeText) +pulse = emptyPulse(); +msg = sprintf("Unsupported pulse detection mode: %s.", string(modeText)); +pulse.message = msg; +end diff --git a/+labkit/+dta/private/emptyPulse.m b/+labkit/+dta/private/emptyPulse.m index 9fba5069b..f90d2a3fa 100644 --- a/+labkit/+dta/private/emptyPulse.m +++ b/+labkit/+dta/private/emptyPulse.m @@ -6,31 +6,16 @@ %EMPTYPULSE Return the canonical empty chrono pulse struct. % % Output: -% pulse - struct with ok=false, legacy flat fields used by existing apps, -% and normalized cath/anod/gap nested fields used by newer code. +% pulse - struct with ok=false and unit-explicit nested phase windows. % % Notes: % All private pulse detectors start from this shape so public DTA item and % pulse outputs remain stable when detection fails. - pulse = struct( ... - 'ok', false, ... - 'method', '-', ... - 'message', '', ... - 'cath_start', NaN, ... - 'cath_end', NaN, ... - 'anod_start', NaN, ... - 'anod_end', NaN, ... - 'Ic_nominal', NaN, ... - 'Ia_nominal', NaN, ... - 'pre_start', NaN, ... - 'pre_end', NaN, ... - 'gap_start', NaN, ... - 'gap_end', NaN, ... - 'post_start', NaN, ... - 'post_end', NaN); - + pulse = struct('ok', false, 'method', '-', 'message', ''); + pulse.pre = struct('start_s', NaN, 'end_s', NaN); pulse.cath = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); - pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); pulse.gap = struct('start_s', NaN, 'end_s', NaN, 'center_s', NaN); + pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); + pulse.post = struct('start_s', NaN, 'end_s', NaN); end diff --git a/+labkit/+dta/private/makeChronoItem.m b/+labkit/+dta/private/makeChronoItem.m index bc07c8d61..38ff67ded 100644 --- a/+labkit/+dta/private/makeChronoItem.m +++ b/+labkit/+dta/private/makeChronoItem.m @@ -13,8 +13,8 @@ % opts - optional pulse-detection options forwarded to detectPulseCore. % % Output: -% item - chrono item with parser outputs, normalized arrays, legacy bridge -% fields, pulse info, alignment fields, message, and analysis struct. +% item - chrono item with parser outputs, unit-explicit arrays, pulse info, +% alignment fields, message, and analysis struct. % % Errors: % Throws when the main curve is missing or fewer than two valid T/Vf/Im @@ -61,30 +61,25 @@ item.curve = curve; - % Legacy field names are kept until all GUI call sites migrate. - item.t = t(:); - item.Vf = Vf(:); - item.Im = Im(:); + item.t_s = t(:); + item.Vf_V = Vf(:); + item.Im_A = Im(:); item.pt = pt(:); item.n = numel(t); item.message = msg; - - % Unit-explicit package field names are the long-term data model. - item.t_s = item.t; - item.Vf_V = item.Vf; - item.Im_A = item.Im; - item.alignTime = NaN; - item.tAligned = []; item.alignTime_s = NaN; item.tAligned_s = []; item.analysis = struct(); if isfield(opts, 'pulseOptions') - [item.pulse, pulseMsg] = detectPulseCore(item.t, item.Im, item.meta, opts.pulseOptions); + [item.pulse, pulseMsg] = detectPulseCore( ... + item.t_s, item.Im_A, item.meta, opts.pulseOptions); elseif isfield(opts, 'pulseMode') - [item.pulse, pulseMsg] = detectPulseCore(item.t, item.Im, item.meta, opts.pulseMode); + [item.pulse, pulseMsg] = detectPulseCore( ... + item.t_s, item.Im_A, item.meta, opts.pulseMode); else - [item.pulse, pulseMsg] = detectPulseCore(item.t, item.Im, item.meta); + [item.pulse, pulseMsg] = detectPulseCore( ... + item.t_s, item.Im_A, item.meta); end item.pulseMessage = pulseMsg; end diff --git a/+labkit/+dta/private/makeEISItem.m b/+labkit/+dta/private/makeEISItem.m index b8788f1fe..bb8830400 100644 --- a/+labkit/+dta/private/makeEISItem.m +++ b/+labkit/+dta/private/makeEISItem.m @@ -12,8 +12,8 @@ % filepath - Gamry EIS DTA file path containing a ZCURVE table. % % Output: -% item - EIS item with parser outputs, zcurve, normalized field aliases, -% legacy bridge fields, frequency-order metadata, and analysis struct. +% item - EIS item with parser outputs, zcurve, unit-explicit vectors, +% frequency-order metadata, and analysis struct. % % Errors: % Throws when no usable ZCURVE data remains. @@ -31,48 +31,38 @@ item.curve = curve; item.zcurve = curve; - item.Pt = defaultColumn(curve, 'Pt'); - item.Time = defaultColumn(curve, 'Time'); - item.Freq = defaultColumn(curve, 'Freq'); - item.Zreal = defaultColumn(curve, 'Zreal'); - item.Zimag = defaultColumn(curve, 'Zimag'); - item.Zmod = defaultColumn(curve, 'Zmod'); - item.Zphz = defaultColumn(curve, 'Zphz'); - item.Idc = defaultColumn(curve, 'Idc'); - item.Vdc = defaultColumn(curve, 'Vdc'); - item.negZimag = -item.Zimag; + item.point = defaultColumn(curve, 'Pt'); + item.time_s = defaultColumn(curve, 'Time'); + item.freq_Hz = defaultColumn(curve, 'Freq'); + item.Zreal_ohm = defaultColumn(curve, 'Zreal'); + item.Zimag_ohm = defaultColumn(curve, 'Zimag'); + item.negZimag_ohm = -item.Zimag_ohm; + item.Zmod_ohm = defaultColumn(curve, 'Zmod'); + item.Zphz_deg = defaultColumn(curve, 'Zphz'); + item.Idc_A = defaultColumn(curve, 'Idc'); + item.Vdc_V = defaultColumn(curve, 'Vdc'); - valid = isfinite(item.Freq) | isfinite(item.Zreal) | isfinite(item.Zimag) ... - | isfinite(item.Zmod) | isfinite(item.Zphz); - fields = {'Pt', 'Time', 'Freq', 'Zreal', 'Zimag', 'negZimag', 'Zmod', 'Zphz', 'Idc', 'Vdc'}; + valid = isfinite(item.freq_Hz) | isfinite(item.Zreal_ohm) | ... + isfinite(item.Zimag_ohm) | isfinite(item.Zmod_ohm) | ... + isfinite(item.Zphz_deg); + fields = {'point', 'time_s', 'freq_Hz', 'Zreal_ohm', ... + 'Zimag_ohm', 'negZimag_ohm', 'Zmod_ohm', 'Zphz_deg', ... + 'Idc_A', 'Vdc_V'}; for ii = 1:numel(fields) item.(fields{ii}) = item.(fields{ii})(valid); end - if numel(item.Pt) < 2 + if numel(item.point) < 2 error('Not enough valid ZCURVE points.'); end - if isempty(item.Pt) || all(~isfinite(item.Pt)) - item.Pt = (0:numel(item.Freq)-1).'; + if isempty(item.point) || all(~isfinite(item.point)) + item.point = (0:numel(item.freq_Hz)-1).'; end - item.n = numel(item.Pt); - item.freqDesc = isMostlyDescending(item.Freq); + item.n = numel(item.point); + item.freqDesc = isMostlyDescending(item.freq_Hz); item.message = msg; - - % Unit-explicit aliases are the long-term data model; legacy fields stay - % for behavior-preserving GUI compatibility. - item.point = item.Pt; - item.time_s = item.Time; - item.freq_Hz = item.Freq; - item.Zreal_ohm = item.Zreal; - item.Zimag_ohm = item.Zimag; - item.negZimag_ohm = item.negZimag; - item.Zmod_ohm = item.Zmod; - item.Zphz_deg = item.Zphz; - item.Idc_A = item.Idc; - item.Vdc_V = item.Vdc; item.analysis = struct(); end diff --git a/+labkit/+dta/private/pulsesFromCurrent.m b/+labkit/+dta/private/pulsesFromCurrent.m index 671cfad7c..f43578f67 100644 --- a/+labkit/+dta/private/pulsesFromCurrent.m +++ b/+labkit/+dta/private/pulsesFromCurrent.m @@ -13,7 +13,7 @@ % Im - measured current vector in ampere, same length/order as t. % % Outputs: -% pulse - pulse struct with legacy and normalized fields populated when ok. +% pulse - pulse struct with unit-explicit phase windows populated when ok. % ok - true when both cathodic and later anodic segments are found. % msg - detection status for DTA item logs. % @@ -59,22 +59,24 @@ pulse.ok = true; pulse.method = 'auto-from-Im'; - pulse.cath_start = t(cseg(1)); - pulse.cath_end = t(cseg(2)); - pulse.anod_start = t(aseg(1)); - pulse.anod_end = t(aseg(2)); - pulse.Ic_nominal = median(Im(cseg(1):cseg(2)), 'omitnan'); - pulse.Ia_nominal = median(Im(aseg(1):aseg(2)), 'omitnan'); - pulse.pre_start = t(1); - pulse.pre_end = pulse.cath_start; - pulse.gap_start = pulse.cath_end; - pulse.gap_end = pulse.anod_start; - pulse.post_start = pulse.anod_end; - pulse.post_end = t(end); + pulse.pre = struct('start_s', t(1), 'end_s', t(cseg(1))); + pulse.cath = struct( ... + 'start_s', t(cseg(1)), ... + 'end_s', t(cseg(2)), ... + 'current_A', median(Im(cseg(1):cseg(2)), 'omitnan')); + pulse.gap = struct( ... + 'start_s', pulse.cath.end_s, ... + 'end_s', t(aseg(1)), ... + 'center_s', 0.5 * (pulse.cath.end_s + t(aseg(1)))); + pulse.anod = struct( ... + 'start_s', t(aseg(1)), ... + 'end_s', t(aseg(2)), ... + 'current_A', median(Im(aseg(1):aseg(2)), 'omitnan')); + pulse.post = struct('start_s', pulse.anod.end_s, 'end_s', t(end)); ok = true; msg = sprintf('Auto pulse detection OK: cath [%d %d], anod [%d %d].', cseg(1), cseg(2), aseg(1), aseg(2)); - pulse = addNormalizedFields(pulse, msg); + pulse.message = msg; end function seg = contiguousSegments(mask) @@ -84,11 +86,3 @@ ends = find(d == -1) - 1; seg = [starts(:), ends(:)]; end - -function pulse = addNormalizedFields(pulse, msg) - pulse.message = msg; - pulse.cath = struct('start_s', pulse.cath_start, 'end_s', pulse.cath_end, 'current_A', pulse.Ic_nominal); - pulse.anod = struct('start_s', pulse.anod_start, 'end_s', pulse.anod_end, 'current_A', pulse.Ia_nominal); - pulse.gap = struct('start_s', pulse.gap_start, 'end_s', pulse.gap_end, ... - 'center_s', 0.5 * (pulse.gap_start + pulse.gap_end)); -end diff --git a/+labkit/+dta/private/pulsesFromMetadata.m b/+labkit/+dta/private/pulsesFromMetadata.m index dc292ed28..cff09b736 100644 --- a/+labkit/+dta/private/pulsesFromMetadata.m +++ b/+labkit/+dta/private/pulsesFromMetadata.m @@ -79,52 +79,47 @@ pulse.ok = true; pulse.method = ['metadata-' stepMode]; - pulse.cath_start = starts(idxCath); - pulse.cath_end = ends(idxCath); - pulse.anod_start = starts(idxAnod); - pulse.anod_end = ends(idxAnod); + cathCurrent = NaN; + anodCurrent = NaN; if strcmp(stepMode, 'current') - pulse.Ic_nominal = Ivals(idxCath); - pulse.Ia_nominal = Ivals(idxAnod); - else - pulse.Ic_nominal = NaN; - pulse.Ia_nominal = NaN; + cathCurrent = Ivals(idxCath); + anodCurrent = Ivals(idxAnod); end if idxCath > 1 - pulse.pre_start = starts(idxCath - 1); - pulse.pre_end = ends(idxCath - 1); + preStart = starts(idxCath - 1); + preEnd = ends(idxCath - 1); else - pulse.pre_start = t(1); - pulse.pre_end = pulse.cath_start; + preStart = t(1); + preEnd = starts(idxCath); end if idxAnod > idxCath - pulse.gap_start = pulse.cath_end; - pulse.gap_end = pulse.anod_start; + gapStart = ends(idxCath); + gapEnd = starts(idxAnod); else - pulse.gap_start = pulse.cath_end; - pulse.gap_end = pulse.cath_end; + gapStart = ends(idxCath); + gapEnd = ends(idxCath); end if idxAnod < numel(Tvals) - pulse.post_start = starts(idxAnod + 1); - pulse.post_end = ends(idxAnod + 1); + postStart = starts(idxAnod + 1); + postEnd = ends(idxAnod + 1); else - pulse.post_start = pulse.anod_end; - pulse.post_end = t(end); + postStart = ends(idxAnod); + postEnd = t(end); end + pulse.pre = struct('start_s', preStart, 'end_s', preEnd); + pulse.cath = struct('start_s', starts(idxCath), ... + 'end_s', ends(idxCath), 'current_A', cathCurrent); + pulse.gap = struct('start_s', gapStart, 'end_s', gapEnd, ... + 'center_s', 0.5 * (gapStart + gapEnd)); + pulse.anod = struct('start_s', starts(idxAnod), ... + 'end_s', ends(idxAnod), 'current_A', anodCurrent); + pulse.post = struct('start_s', postStart, 'end_s', postEnd); ok = true; msg = sprintf('Metadata pulse detection OK (%s-controlled): cath step %d, anod step %d.', ... stepMode, idxCath, idxAnod); - pulse = addNormalizedFields(pulse, msg); -end - -function pulse = addNormalizedFields(pulse, msg) pulse.message = msg; - pulse.cath = struct('start_s', pulse.cath_start, 'end_s', pulse.cath_end, 'current_A', pulse.Ic_nominal); - pulse.anod = struct('start_s', pulse.anod_start, 'end_s', pulse.anod_end, 'current_A', pulse.Ia_nominal); - pulse.gap = struct('start_s', pulse.gap_start, 'end_s', pulse.gap_end, ... - 'center_s', 0.5 * (pulse.gap_start + pulse.gap_end)); end diff --git a/+labkit/+dta/version.m b/+labkit/+dta/version.m index 46e38b643..38d2c30f3 100644 --- a/+labkit/+dta/version.m +++ b/+labkit/+dta/version.m @@ -27,6 +27,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("dta", "2.0.3", ">=2.0 <3", ... + info = labkit.contract.versionInfo("dta", "3.0.0", ">=3 <4", ... "stable", "DTA parser, file item, pulse, and curve facade contract."); end diff --git a/+labkit/+image/previewBudget.m b/+labkit/+image/previewBudget.m index 517671fe2..336b160f2 100644 --- a/+labkit/+image/previewBudget.m +++ b/+labkit/+image/previewBudget.m @@ -6,11 +6,13 @@ % [preview, info] = labkit.image.previewBudget(imageData, Name, Value) % % Description: -% Produces a lightweight preview by taking every Nth row and column. N is -% the smallest integer stride whose estimated processing area does not -% exceed MaxPixels. The estimate is source rows times source columns times -% Expansion, so callers can reserve memory for workflows that pad, tile, or -% otherwise enlarge an image before display. +% When MaxPixels is finite, produces a lightweight preview by taking every +% Nth row and column. N is the smallest integer stride whose estimated +% processing area does not exceed MaxPixels. The estimate is source rows +% times source columns times Expansion, so callers can reserve memory for +% workflows that pad, tile, or otherwise enlarge an image before display. +% The default is Inf and preserves native pixels; each caller owns whether a +% display budget is appropriate for its workflow. % % This is sampling rather than interpolating resize. The first source pixel % is always retained, and image class and channel count are preserved. A @@ -21,9 +23,9 @@ % imageData - Nonempty numeric or logical 2-D or 3-D image array. % % Name-Value Arguments: -% MaxPixels - Positive display-area budget. The default is 1.2e6 pixels. -% Invalid, empty, or nonpositive values fall back to the -% default rather than throwing an error. +% MaxPixels - Positive display-area budget or Inf. Default: Inf, retaining +% native pixels. Invalid, empty, or nonpositive values fall +% back to the default rather than throwing an error. % Expansion - Positive multiplier applied only to estimatedPixels. The % default is 1. Invalid values fall back to 1. % @@ -92,9 +94,7 @@ end function value = defaultMaxPixels() - % Constant: 1.2 megapixels balances interactive preview responsiveness - % with enough spatial detail for image measurement workflows. - value = 1.2e6; + value = Inf; end function validateImageData(imageData) @@ -106,7 +106,7 @@ function validateImageData(imageData) function value = positiveScalar(value, defaultValue) value = double(value); - if isempty(value) || ~isscalar(value) || ~isfinite(value) || value <= 0 + if isempty(value) || ~isscalar(value) || isnan(value) || value <= 0 value = defaultValue; return; end diff --git a/+labkit/+image/version.m b/+labkit/+image/version.m index b0d0dc7f8..8fd2c2702 100644 --- a/+labkit/+image/version.m +++ b/+labkit/+image/version.m @@ -28,6 +28,6 @@ % See also labkit.contract.versionInfo, % labkit.contract.checkRequirements - info = labkit.contract.versionInfo("image", "2.0.2", ">=2.0 <3", ... + info = labkit.contract.versionInfo("image", "2.0.3", ">=2.0 <3", ... "stable", "GUI-free image file input, basic processing, and preview-budget helpers for responsive image apps."); end diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index ca4a18b1e..80629cffa 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -37,6 +37,8 @@ Use `labkittest.explain` to find the exact owner and contract. not an app's task orchestration. - `labkit.app` owns the App SDK. Registry mutation, queueing, concrete controls, native adapters, and lifecycle handles remain private. +- Shared image utilities retain native pixels by default. A finite preview + budget is an explicit caller-owned product decision, not a facade default. - `labkit.contract` owns MATLAB-native version requirements and range checks, not app discovery or package management. - Do not introduce MATLAB classes or a third-party runtime dependency without @@ -56,6 +58,9 @@ Use `labkittest.explain` to find the exact owner and contract. remain legal and unique; references must resolve before UI mutation. - View snapshots must preserve unchanged graphics and viewports. Renderers own incremental overlay changes; interaction specs own user gestures. +- One interaction target has one active gesture owner. A managed movable + rectangle accepts movement from its visible box or interior, not only a thin + edge; display-only affordances remain non-pickable. - The App runtime composes complete `labkit.app.view.Snapshot` values from `labkit.app.layout.*` defaults, strict state bindings, framework-owned state, and the App's dynamic view fragment; private reconciliation owns diffs. @@ -78,7 +83,13 @@ Use `labkittest.explain` to find the exact owner and contract. internal unless App authors need a clear stable contract. - Persistence writes only the current project envelope. Ordered app migrations and declared legacy importers are read-only compatibility hooks and must not - introduce app-id branches in the framework. + introduce app-id branches in the framework. Treat them as supported + persistence contracts while they have App-owned evidence, not as migration + ledger entries; removing one is an explicit saved-data compatibility change. +- Do not keep old and current fields on one live facade value. Migrate consumers + together, express the breaking facade range, and remove aliases as one + change. Defaults apply only to omitted options; an explicitly unknown + scientific mode fails rather than selecting a plausible fallback. - Resource replacement for the same scope and id is intentional and idempotent; use distinct ids for resources that coexist. - Diagnostic output must stay app-neutral and sanitized. diff --git a/.agents/dos-and-donts.md b/.agents/dos-and-donts.md new file mode 100644 index 000000000..39d2009f1 --- /dev/null +++ b/.agents/dos-and-donts.md @@ -0,0 +1,113 @@ +# Working Dos and Don'ts + +This file is the repository's compact experience reservoir. After each +meaningful checkpoint, record only a high-value lesson that would save future +investigation, retry, or design cost. Let lessons accumulate and survive +repeated use before promoting them; this is not a chronological work log or a +second architecture guide. + +## Record, promote, and compress + +- Add only a lesson whose rediscovery would cost meaningful investigation or + retry time. Record the durable cause and better practice, never a transcript, + successful command, transient failure, version, or one-off filename. +- Merge with an existing lesson before adding another. Do not promote a new + observation immediately merely because a destination exists. After it has + accumulated supporting cases and remained useful over time, promote policy + to `AGENTS.md`, procedure to a skill, enforceable behavior to a test, or + product/API meaning to source help or a manual. +- When promotion is proven, merge or remove the reservoir copy. Regularly + compress related lessons into fewer principle-first statements and delete + stale, duplicated, disproven, or low-value detail. + +## Incubating lessons + +### Debug and native presentation + +- Generate synthetic inputs deliberately without preloading them. Validate + both the pack contract and a native launch; finite schema placeholders can + still violate small-image control limits or native presentation. +- Validate interdependent native UI properties through real construction: + constructor name-values may be applied in a different order than written, + and layout-manager resize hooks vary by MATLAB release. Set dependent values + only after their legal domain exists, give responsive resize to an explicit + container owner, and retain a rendered GUI check. Responsive assertions must + respect layout minimums and headless virtual-screen bounds; a smaller window + cannot reduce a table that is already at its readable-width floor. +- Logging severity, capture detail, display filtering, persistence, and + synthetic-input generation are independent policies. Do not bundle them into + a Debug launch; an always-on bounded flight recorder is what makes an + ordinary post-incident session diagnosable. +- A self-contained repair entrypoint owns only minimum health detection, + repair, and delegation. Do not give it a second log/session schema; canonical + diagnostics begin after the installed framework is available. +- Make retained semantic events privacy-safe before they enter memory or disk. + Free-form messages can leak more readily than attributes; export-time + redaction is defense in depth, not the primary boundary. + +### Interaction and previews + +- Overlapping gestures need one active owner. A movable ROI must accept its + visible interior or center as a normal drag target; state geometry alone + cannot prove pointer ownership. +- Shared image code keeps native pixels by default. Any finite preview budget + is an explicit App decision, and pixel-unit parameters follow the preview + scale. + +### Validation and compatibility + +- A component version change proves that its contract moved; it does not prove + that every component manual needs new prose. For cross-App behavior, choose + one canonical owner first and keep App pages limited to workflow-critical or + App-specific differences. +- Moving behavior to a new owner does not authorize retiring its visible or + interactive contract. Recreate the old appearance, selection paths, status, + and failure semantics at the new boundary before deleting the old owner. +- Automatic instrumentation wraps the existing failure boundary, not a wider + convenience block: preserve specific validation/error identifiers, and test + nested diagnostic ownership by parent/root ancestry rather than assuming + every health record attaches directly to the outer callback. +- A destructive updater has one shared write gateway for GUI and programmatic + callers. Reject invalid roots and both Git-marker forms before network I/O; + use a sibling backup, preserve explicit local data, validate the replacement, + and prove success and rollback with path entries that actually existed. +- A changed `projectSpec` needs nonempty owner-level persistence evidence. + Treat a client timeout during a durable MATLAB run as unknown until its + progress artifact or terminal log confirms the executor result. +- Treat MATLAB local functions as complete structural blocks. After inserting + or moving one, inspect the preceding and following `end` boundaries before + running tests; a misplaced boundary can silently nest later helpers and turn + a simple edit defect into misleading runtime failures. +- For destructive path checks, compare the resolved existing target with an + absolute normalized expected path that does not follow links. Canonicalizing + both sides can hide a redirect, while legacy canonical APIs can miss Windows + junctions; prove both external and same-root redirects through the public + operation before allowing recursive deletion. +- After a bulk text rewrite, scan changed MATLAB files for unexpected UTF-8 + BOMs before reviewing semantics. A shell encoding default can otherwise + create hundreds of noisy first-line changes and conceal the real migration. + If terminal output looks corrupted, reread with explicit UTF-8 and inspect + code points before changing source; display decoding is not source damage. +- Before starting a durable background MATLAB test run, derive the repository + root from the runner file, convert suite paths to absolute character cells, + and assert both the container type and file existence. Reusing this + preflight prevents repeated no-test runs caused by changed working + directories or string-cell inputs. For a quick focused run, keep those + assertions inside MATLAB rather than building a second cross-shell preflight. +- Treat parameter providers and reset-path probes as executable test + infrastructure: run suite construction, not only selected test bodies. After + `restoredefaultpath`, a probe must use only dependencies it explicitly + restored; calling a test helper that just disappeared from the path defeats + the isolation contract. +- A fixture constrains only the contract under test. An unrelated facade + version range can turn a path-isolation test into a stale compatibility test + and produce a convincing but false isolation failure after a major upgrade. +- A GUI environment tag is metadata, not a visibility fixture. Tests that + construct a native runtime must establish and restore hidden mode themselves + so a focused `runtests` invocation cannot open product windows. +- Callback unit tests must use a contract-complete context or an explicit + narrow fake of every operation the callback invokes. Old partial test + backends can make a supported production API look broken during migration. +- Exact one-way old-data readers are bounded persistence support; simultaneous + old/new fields on live values are competing models. Defaults cover omitted + options, while explicit unknown scientific modes fail visibly. diff --git a/.agents/migration_guide.md b/.agents/migration_guide.md index 8fb101101..e6680dfe5 100644 --- a/.agents/migration_guide.md +++ b/.agents/migration_guide.md @@ -2,178 +2,845 @@ This file records only active architecture migration or compatibility-retirement debt. Current supported behavior belongs in `docs/`; execution rules belong in -the nearest `AGENTS.md`; exact validation commands belong in -`docs/development/maintain-and-release/testing.md`; completed work belongs in -component history. +the nearest `AGENTS.md`; completed work belongs in component history. ## Active debt -Last audited: 2026-07-23. +Last audited: 2026-07-26. ```text toolbox-product-debt: none -architecture-migration-debt: test-framework-parity-closure +architecture-migration-debt: unified session logging and incident diagnostics +compatibility-retirement-debt: debug launch and split status/diagnostic APIs +``` + +## Unified session logging and incident diagnostics + +### Implementation hold and agent coordination + +Status: **repair-only Launcher architecture approved; continue only through the ordered checkpoints below**. + +Any agent working on this migration must stop before adding more App migrations, +committing the current all-App batch, or treating a draft `SessionJournal`, +viewer, or `CallbackContext.log` implementation as an accepted contract. +Preserve existing work, inspect the shared worktree, and reread this complete +entry before continuing. Do not overwrite, discard, or silently reshape another +agent's uncommitted files. + +Implementation may resume only as the ordered checkpoints below. In particular: + +- preserve `labkit_launcher.m` as a self-contained **repair-only** entry that + can run when `+labkit`, Apps, docs, or scripts are missing or damaged; +- do not add a bootstrap journal, bootstrap schema, session importer, or any + second logging implementation to the repair entry; repair does not depend on + a Runtime logger class; +- establish a privacy-safe semantic event before any persistent, exported, or + default Log-viewer projection; +- prove the minimal in-memory stream and operation state machine before + migrating every App; +- do not commit hundreds of framework, App, fixture, and compatibility changes + as one checkpoint; isolate the contract, persistence, repair/delegation, + viewer, synthetic-input rename, and App-family migrations; +- run the focused exit gate for a checkpoint before starting the next one. + +An agent that discovers this hold while work is in progress must report which +checkpoint its current files represent, which later-phase files are already +present, and what remains unverified. It must not call a broad staged diff +“complete” merely because construction or one headless log test passes. + +### Launcher ownership and repair boundary + +The launcher has three separate layers: + +1. `labkit_launcher.m` is the single-file rescue entry. It uses only native + MATLAB for a boot-critical minimum check, user-visible repair errors, and + the one GitHub ZIP repair transaction. It does not discover Apps, create + Runtime diagnostics, or own a session journal. +2. A focused hidden installed capability under + `+labkit/+app/+internal/+launcher/` (optionally behind one hidden `Launcher` + facade) owns Launcher UI composition, catalog routing, and normal startup. + It starts only after the rescue entry can call the installed entry. Once the + SDK is loaded, Launcher actions use the canonical Runtime operation/session + contract; no repair-to-Runtime handoff schema exists. +3. `tools//` owns only MATLAB tools with independent value: + a callable entry, explicit inputs/results or observable side effect, stable + error behavior, and independent tests/docs. Launcher-only callbacks, table + formatting, status wording, menus, and routing remain with the installed + Launcher. Existing docs, codecheck, profiling, and deployment tools are + called directly with only necessary UI/path adaptation. + +Version browsing or rollback is a `tools/deployment` candidate only when it +has an independent callable contract and invokes the root's unique repair +transaction; it must not copy ZIP replacement logic. + +Repair detects incompleteness lazily. Rescue startup checks only that the +installed Launcher entry exists and is callable. If entry loading, dependency +resolution, or App startup fails structurally (for example a missing file or +undefined symbol), preserve the concrete error and offer GitHub repair. Do not +label ordinary scientific input, validation, or user-operation failures as an +incomplete install. ZIP candidate validation is deliberately narrower: require +a LabKit root, root launcher, installed entry, and expected `apps`/`+labkit` +top-level content before replacement. Packages may keep using +`packaged_app_manifest.json` for package semantics, but rescue does not require +it and this migration adds no install manifest, hash, or file inventory. + +### Baseline problem and evidence + +At migration start, LabKit did not have one session log. It had several +partially overlapping channels: + +- `CallbackContext.appendStatus` adds unstructured display strings to an + unbounded `StatusLog`; every App exposes that row through a `logPanel`. +- `CallbackContext.reportError` records an exception in Runtime state and emits + an event, but does not itself create a useful user-facing log row or alert. +- `DiagnosticRecorder` records structured lifecycle, operation, failure, and + checkpoint events, but ordinary launches retain them only in memory. Disk + recording is coupled to a verbose diagnostic launch. +- Launcher `Open Debug` combines two unrelated choices: verbose diagnostics and + construction of a synthetic sample pack. A normal launch cannot later recover + the same diagnostic history. +- Launcher failures are collapsed into one status message before an App Runtime + exists. Direct App entrypoints and Launcher entrypoints therefore do not + produce equivalent evidence. +- Framework documentation describes project, dialog, resource, and interaction + tracing more broadly than Runtime currently instruments. +- Current tests exercise clean sample startup and a few manifest outcomes, but + do not directly prove the recorder schema, sanitization, retention, + degradation, operation ancestry, or an actual GUI callback-to-log workflow. + +The visible result is a Log tab with too little context for users, while the +more useful evidence usually does not exist after an ordinary incident. The +same action can also produce different wording and detail depending on its +entry path. + +### External design evidence + +The target uses stable ideas from mature logging systems without adding a +third-party runtime or network dependency: + +- The [OpenTelemetry Logs Data Model](https://opentelemetry.io/docs/specs/otel/logs/data-model/) + separates timestamp, observed timestamp, severity, body, resource, + instrumentation scope, attributes, event name, and trace/span correlation. +- [.NET logging](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging/overview) + separates categories and structured messages from provider-specific level + filters, and uses scopes to correlate one operation. Its + [providers](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging/providers) + demonstrate that one record can feed multiple independently filtered sinks. +- [Python logging](https://docs.python.org/3/howto/logging.html) separates + loggers, handlers, levels, filters, and formatters. Its + [rotating handlers](https://docs.python.org/3/library/logging.handlers.html) + provide the retention pattern needed for a bounded local journal. +- [Qt logging categories](https://doc.qt.io/qt-6/qloggingcategory.html) make + runtime category filtering independent of Debug versus Release builds and + avoid formatting disabled detail. +- [Apple unified logging privacy](https://developer.apple.com/documentation/os/generating-log-messages-from-your-code) + treats dynamic user data as private rather than assuming every formatted + value is safe to persist. +- [systemd-journald](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html) + demonstrates bounded storage, independent sink thresholds, rate limits, and + explicit accounting for discarded records. + +The design consequence is that severity, capture detail, display detail, +persistence, synthetic inputs, and build configuration are independent axes. +There is no product-level “debug session” mode. + +### Target product behavior + +1. Every ordinary installed Launcher or direct App launch creates its canonical + Runtime session only after the App SDK is available. The repair-only entry + creates no log/session schema; it delegates when possible and otherwise + offers repair for structural installation failure. +2. A bounded local flight recorder is always on. It captures sufficient + structured `DEBUG`-and-higher evidence to diagnose a later failure without + requiring the user to predict that failure. `TRACE` is opt-in because of its + potential volume. +3. The App Log viewer shows concise, actionable user information. Its filter + does not decide what the recorder captured and cannot erase earlier detail. +4. A user can export retained current-session history after a problem. Runtime + archive recovery preserves safe evidence for abandoned sessions. +5. “Complete history” means the retained semantic action chain, state + transitions, outcomes, durations, warnings, and exceptions. It does not mean + raw scientific arrays, workspace snapshots, images, or every call frame. +6. Any retention, rate-limit, or sink failure is represented explicitly; the + system must never pretend a truncated journal is complete. +7. Synthetic inputs are an explicit tool action. Generating them does not + mutate current App state, load a project, start analysis, or change logging + policy. + +The framework-owned **Tools** menu keeps diagnostics out of each App's primary +scientific workflow: + +```text +Tools +|- Diagnostics +| |- Show Logs +| |- Export Diagnostic Bundle +| |- Copy Problem Summary +| `- Clear View +`- Developer Tools + |- Generate Synthetic Inputs + `- Trace Capture +``` + +Display filtering belongs in the Log viewer. Trace capture and synthetic-input +generation are ordinary-session tools, not a separate launch mode. + +`Clear Log View` clears only the current viewer projection. It does not silently +delete the flight recorder. `ERROR` leaves the App usable when recovery is +possible and exposes the correlation ID and export action. `CRITICAL` means the +session is not safe for further product actions; diagnostic export remains +available. + +Launcher removes **Open Debug**. Repair failures remain user-visible repair +errors; diagnostics and export begin only after the installed SDK/Runtime is +available. + +### Severity and audience contract + +Use one spelling and one meaning everywhere: + +| Level | Meaning | Default App view | Flight recorder | +| --- | --- | --- | --- | +| `TRACE` | High-volume implementation steps useful only for deep investigation | Hidden | Opt-in | +| `DEBUG` | Parameters, branch choices, counts, state transitions, and timings useful to a developer | Hidden | Captured with bounds | +| `INFO` | Normal user-meaningful progress or completion | Shown when useful | Captured | +| `WARNING` | Unexpected or degraded behavior from which the operation can continue or recover | Shown | Captured | +| `ERROR` | The requested operation failed, while the process/session may remain usable | Shown prominently | Captured with exception | +| `CRITICAL` | The session is unsafe or a required foundation failed | Shown prominently | Captured and flushed | + +Severity is not an audience. Each record also declares +`Audience="user"|"developer"`. A developer-oriented warning remains a warning; +the viewer may hide it from its default user projection only when it cannot +help the user act. All `WARNING`-and-higher records are visible by default +unless the message is explicitly marked as an internal duplicate of another +user-facing record. + +Do not use `INFO` as a callback trace, `WARNING` for ordinary validation +guidance, or `ERROR` merely because a fallback was used successfully. Expected +invalid user input stays inline validation unless it changed state or caused an +operation to fail. + +### Canonical structured record + +The repository owns a minimal versioned record schema. Schema v1 contains: + +```text +schemaVersion +sequence +timestampUtc +elapsedSeconds + +severity +audience +category +eventName +message +attributes + +sessionId +appId +operationId +parentOperationId +rootActionId + +operationResult +stateDisposition +durationSeconds +exception +``` + +Do not persist `observedTimestampUtc` until an asynchronous or cross-process +collector makes it observably different from the event timestamp. Derive the +OpenTelemetry severity number from `severity` in an export projection instead +of storing two authorities. Store App, App SDK, MATLAB, platform, capture +policy, and resolved-entrypoint facts once in the session manifest rather than +repeating them in every event. Source function/line belongs only in a sanitized +exception stack or an explicit developer checkpoint; it is not a routine +record field or stable public contract. + +`operationResult` and `stateDisposition` are independent. An operation that +throws after the Runtime restores its transaction records +`operationResult=failed` and `stateDisposition=rolledBack`; it never uses +`rolledBack` as a competing result value. The schema uses the two explicit +top-level fields rather than a scalar-or-struct compatibility shape. + +`message` is privacy-safe readable prose and may evolve. It must not contain a +raw path, original filename, user/subject/device/sample identifier, or +scientific value copied from an input. `eventName` and attribute keys are the +machine-stable contract used by tests and diagnostic tooling. Attributes are +deny-by-default: finite scalar numeric/logical values, controlled enum/unit/ +reason/Runtime-alias text, and an explicitly bounded dimensions shape are the +only ordinary retained values. Attribute validation enforces nesting, field, +and serialized-byte limits. It rejects subject, device-serial, sample, signal, +and other identifier/scientific-value fields; numeric arrays; arbitrary free +text; handles; classes; graphics objects; tables; and workspace values before +the event enters memory, a projection, or disk. + +Operation ancestry, not a full call stack on every line, reconstructs the +normal action chain. A root user action creates `rootActionId`; nested Runtime +and App operations create parent-linked IDs. A full sanitized exception stack +is captured at `ERROR` and `CRITICAL`, and may be requested for an explicit +diagnostic checkpoint. This keeps routine logging useful without turning it +into a profiler. + +Framework category roots are stable: + +```text +launcher.lifecycle +runtime.lifecycle +runtime.callback +runtime.presentation +runtime.project +runtime.source +runtime.dialog +runtime.result +runtime.resource +runtime.interaction +app.. ``` -### Test framework parity closure - -Owner: `tests/`. - -Baseline: legacy test tree at `0d882669`; catalog cutover at `9faa9f06`. - -#### Observable effect - -The catalog cutover correctly removed legacy selector and wrapper mechanics, -but it did not establish that every high-value legacy behavior has current -evidence. A green catalog proves its selected contracts, not historical -behavioral parity. This debt closes that proof gap without restoring stage -folders, selector compatibility, generic test helpers, or one-App wrapper -classes. - -#### Verified framework findings - -- `tests/cases`, `tests/runner`, and `tests/shared` were replaced by - owner-first specs, parameterized App conformance, `labkittest`, and the - narrow `+testfixtures` package. The cutover deleted 28,840 test lines and - added 5,524; most deletion is test code rather than product code. -- Build and CI now select `Env:path-isolated` on Linux, macOS, and Windows. - Its catalog-selected aggregate test resets paths before every public App, - then returns a complete failure report. This retains the App boundary without - requiring a second concurrent named-user license checkout. -- `changedFast` now widens unmapped, Build, framework, and policy paths to - every automated environment: headless, hidden-GUI, and path-isolated. -- `AppSmokeConformanceSpec` now verifies every compiled layout target that - must materialize as exactly one native semantic component. Interaction - declarations remain view targets but correctly share their owning axes; they - are not false component IDs. This restores the common structural assertion - formerly duplicated across the App GUI layout wrappers. -- Every production `apps/**/*.m` and `+labkit/**/*.m` source now has an - explicit `locate` result. The guardrail includes thin public launchers, which - select the same definition, hidden-GUI, and path-isolated closure as their - App package. The `labkit.contract` facade has direct requirement, version, - incompatibility, and assertion evidence under `framework/contract`. -- `ManualChecks` passed its bounded trial: a mapped `buildLayout` path emits - an owner-derived instruction through `explain`, `plan.json`, and `run`, while - source/result/scientific paths remain empty and manual work never passes an - automated plan. - -#### High-value App audit - -The audit compares legacy methods and assertions with current owner specs. -`Covered` means the observable behavior has an identifiable current proof. -`Gap` means no current proof was found. `Split` means the behavior is partly -covered but named legacy branches still require an explicit decision. A legacy -GUI workflow is not considered covered by a launch smoke test. - -| App | Current high-value evidence | Parity disposition | -| --- | --- | --- | -| DIC Postprocess | strain domain, finite ROI and edge trim, overlay generation, source/result/presentation, bounded load/generate/display/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| DIC Preprocess | masks, crop geometry, toolbox-free alignment, history, source/result/project/presentation, bounded pair/alignment/crop/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Chrono Overlay | definition/version metadata, project migration, pulse alignment, export interpolation, presentation, generic semantic layout, bounded load/plot/export/restore workflow | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| CIC | core metrics, baseline/source and access policies, nominal current, batch recompute, result schema, success/failed summary, display-unit fallback, stable plot requests, generic semantic layout, bounded workflow | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| CSC | full/cathodic/anodic charge branches, zero-crossing subdivision, invalid statuses, all-cycle and voltage/current CSV export, edge-cycle filtering, presentation, generic semantic layout, bounded compare-and-plot workflow | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| EIS | impedance mapping, source summary, result schema, presentation, generic semantic layout, bounded load/plot/export/restore workflow | Covered: direct source/result/plot specifications plus the hidden-GUI workflow replace the legacy export/layout wrappers. | -| VT Resistance | steady/center-window and raw-voltage policies, batch recompute, result schema/failed rows/CSV, presentation, generic semantic layout, bounded load/recompute/plot/export/restore workflow | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Gait Analysis | Video Marker producer-reader compatibility, segmentation/timing roles, project migration, CSV result, presentation, bounded navigation/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Batch Crop | core/rotated crop geometry, padded-edge policy, duplicate tasks/outputs, preview viewport preservation, physical size export, manifest and overwrite policy, generic semantic layout | Covered: direct geometry, task, result, and preview specifications replace legacy automated behavior; native ROI pointer feel remains an explicit ManualCheck. | -| Curvature | circle/length science, invalid-curve branches, fit/length task fingerprints, source migration, result schema, source/presentation, bounded trace/fit/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| FLIR Thermal | raw fallback, correction/default warning, extrema, ROI measurement, project/result/source/presentation, shared display range, bounded radiometric display/reading/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Focus Stack | fusion, registration, project/result/source/presentation, empty-source failure, generic semantic layout, bounded load/fuse/export/restore workflow | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Image Enhance | basic enhancement, white balance, white-ROI calibration, subject-preserving enhancement, ROI availability/defaults, preview scaling, per-image export manifest, source/result/presentation, generic semantic layout | Covered: direct capability specs now replace the legacy behavioral and layout tests without a wrapper class. | -| Image Match | white-balance, tone, protected-tone, Lab-style and histogram matching; reference separation; source/result/project/presentation; bounded reference-match/export/restore workflow; generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Video Marker | editable skeleton lifecycle, coarse/subpixel deterministic tracking, prediction cache, legacy project and annotation migration, marker/coordinate provenance, bounded marking/prediction/calibration/export/restore workflow, project/result/source/presentation | Covered: direct capability and hidden-GUI workflow specs replace the legacy wrappers. The optional Vision Toolbox comparator is explicitly retired: production has no Toolbox dependency, and deterministic synthetic tracking behavior is the retained contract. | -| Figure Studio | style, overlay order, source limits/geometry, composite FIG import/export, canvas/title/log-axis edge cases, version migrations, axes-source handoff, bounded FIG preview/export workflow, project/presentation, generic semantic layout | Covered: direct capability and bounded GUI specs replace the legacy high-value behavior without restoring its wrappers. | -| Nerve Response Analysis | train detection, roles, CAP metrics, migration, source/result/presentation, synthetic filter-record/protocol session, bounded analysis/export/reset/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| Response Review Stats | CSV parsing, aligned metrics, migration, result/presentation, bounded load/preview/export/reset/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| RHS Preview | role assignment, timing, migration, result/source/presentation, synthetic recording/filter discovery, bounded preview/ROI/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | -| T-test Wizard | input table, layered labels, Welch/pooled/paired/directional/error statistics, project migration, CSV result schema, generic semantic layout | Covered: direct source, run, persistence, result, and structural specs replace the legacy core/layout wrappers. | -| ECG Print | input controls, signal products, migration, result/presentation, bounded load/analyze/four-plot/export/restore workflow, generic semantic layout | Covered: direct capability and hidden-GUI workflow specs replace the legacy behavioral and layout wrappers. | - -Repository guardrails are now classified as follows. Sensitive-data hygiene is -retained as a platform-independent tracked-text contract (user/drive paths and -sample timestamps). App/package ownership and launcher conformance are retained -through `TestCatalogSpec`, public-App source routing, definition conformance, -and path-isolated evidence. Public help and rendered documentation remain -validated by the dedicated docs build/check tasks. Release/version, dependency, -magic-number, rectangle-geometry, and Code Analyzer implementation scans are -explicitly retired: their old private-text heuristics do not prove a public -contract and conflict with the current ownership/metadata model. Their durable -rules live in `AGENTS.md`, version metadata, public help, and release workflow. - -#### ManualChecks trial - -`ManualChecks` can carry a useful responsibility handoff, but cannot be a -passing substitute for automated evidence. Retain the field only if this -checkpoint proves all of the following: - -1. a mapped layout/definition source adds a stable, owner-derived manual check - with an explicit non-automatable boundary; -2. `explain`, `plan.json`, and `run` expose it without altering automatic pass - or fail status; -3. catalog self-tests prove that lower-level scientific, source, result, and - presentation changes do not acquire generic manual noise; and -4. the wording names a concrete action, not a generic instruction to "test - manually". - -Manual checks cover only native dialogs, pointer feel, visual design, -real-data suitability, and scientific interpretation. They never replace a -legacy automated GUI, state, calculation, export, or migration proof. If this -trial cannot meet those conditions with a small path-derived implementation, -remove `ManualChecks` from the plan/artifacts instead of retaining empty -architecture. - -#### Execution plan - -1. **Framework closure.** Add a path-isolated profile to Build and CI; make - isolation aggregate every App failure; make changed fallback include the - required environments; add the source-route/exemption guardrail; trial the - bounded ManualChecks model above. -2. **Scientific and state closure.** Restore CIC first, then Batch Crop, - Video Marker, Figure Studio, Image Enhance, T-test Wizard, and each - `Split` branch as small capability-owned specs. One legacy method can map - to several direct contracts; do not recreate its old class shell. -3. **GUI closure.** Add structural/wiring proofs and one bounded workflow only - where headless owner evidence cannot prove the behavior. Keep native - dialogs, pointer feel, visual review, real data, and interpretation as - manual boundaries. -4. **System closure.** Rebuild only high-signal repository guardrails using - public contracts or official metadata, then record every intentional - retirement in this table or its successor audit. - -#### Acceptance and removal - -The debt remains active until: - -- every row above is `Covered` or has an explicit, reviewed retirement; -- all retained isolation, GUI, scientific, state, migration, export, and - system evidence is selected by the appropriate mapped change and stable CI - profile; -- `ManualChecks` either meets its trial conditions or is removed completely; -- focused specs, `changedFast`, and the required cross-platform CI profiles - pass for the final commit; and -- no legacy runner, selector, folder, or wrapper compatibility is restored. - -## Intentional compatibility - -Read-only saved-data compatibility is not automatically migration debt. Retain -it when current user files need it and the current writer emits only the -current format: - -- Video Marker imports its declared legacy project variable and writes the - current `labkitProject` envelope. -- Current App project specs migrate supported older payload versions through - one version-aware `Migrate` entry. -- `labkit.dta` retains documented legacy field aliases beside canonical, - unit-explicit fields until a future major-version decision. - -Do not use a saved-data promise to justify old source layouts, launch -factories, migration callback collections, or undocumented UI nodes. +Examples of stable event names are `session.started`, `callback.started`, +`callback.completed`, `callback.failed`, `project.opened`, +`source.validation_failed`, `analysis.completed`, `result.exported`, and +`journal.records_dropped`. Messages and attributes may provide App-specific +detail without inventing competing event vocabularies. + +### App-facing API and ownership + +Extend the existing sealed `labkit.app.CallbackContext`; do not expose a public +logger class, sink registry, provider collection, or generic service bag: + +```matlab +callbackContext.log( ... + "info", "analysis.completed", ... + "Analysis completed for 12 valid items.", ... + Category="analysisRun", ... + Attributes=struct("validItemCount", 12)) +``` + +Optional named inputs are `Category`, `Audience`, `Attributes`, and +`Exception`. The context supplies App/session/operation identity. It validates +severity, applies privacy-safe attribute limits, and never lets a logging sink +failure change the scientific operation outcome. + +Apps log domain meaning only: accepted inputs, scientific branch selection, +meaningful counts, completion, degradation, and export. Runtime logs callback +dispatch, presentation, project transactions, source/dialog/result/resource +mechanics, interaction dispatch, rollback, and exception conversion. Pure +scientific functions do not receive a logger; they return results, diagnostics, +or typed failures for the callback boundary to record. + +The App-provided `message` is not a place to concatenate a path or filename. +For example, log `"Selected source loaded."` with a Runtime-owned +`sourceAlias`, not `"Loaded " + filepath`. Runtime source mechanics create +session-local aliases such as `source-3`; Apps do not invent redaction. A +selected full path needed by the current workflow is rendered from current App +state and does not pass through the semantic log event. + +Project/save/load transactions emit start, validation, commit or rollback, and +one final outcome under one operation ID. A caught exception cannot produce a +misleading success record. Failure presentation and diagnostic recording share +the same correlation ID. + +### Framework-owned Log viewer + +Replace the 21 App-authored `layout.logPanel` declarations with one standard +Runtime viewer so behavior cannot drift by App. The compact view has columns +**Time | Level | Area | Message**, severity counts, search, follow/pause, and +filters for level, audience, category, and root user action. + +Selecting a row reveals safe structured attributes, operation ancestry, +duration/outcome, source location when available, and exception details. +Warnings and errors expose copy/export actions. The viewer visibly reports +when `TRACE` was disabled, older segments expired, repeated records were +coalesced, or records were dropped. + +The default projection is user-audience `INFO` and above plus all +`WARNING`-and-higher records. The status panel remains a current-state summary; +it is no longer the implicit “last log line.” Viewer updates are incremental +and virtualized/batched rather than repeatedly joining the full session history. + +### Always-on journal, projections, sinks, and retention + +A semantic action first becomes one validated privacy-safe event. Internal +projections then feed independent sinks: + +1. an in-memory ring for immediate viewer updates; +2. a persistent projection for the bounded segmented JSON Lines flight + recorder; +3. the App viewer projection; +4. MATLAB command-window output when explicitly enabled for development; +5. an export projection that adds derived interoperability fields and + human-readable text without reintroducing private values. + +Changing one sink's threshold does not change another sink. Formatting happens +after filtering, and an unavailable sink is isolated and reported through the +remaining sinks when possible. + +The user-owned journal location is: + +```text +/LabKit/logs/sessions// +``` + +It is not stored in the repository, installation tree, current working +directory, or beside user data. A source checkout may offer a convenience link +to that location, but `artifacts/` is not the product persistence contract. +The Runtime manifest records only canonical Runtime session facts. Repair does +not create a session folder when no App can be resolved. Tests inject a private +temporary Runtime store. + +Initial implementation bounds, to be confirmed by profiling before the +contract is frozen, are: + +- rotate a JSONL segment at 10 MiB; +- retain at most five segments for the active session; +- retain at most ten closed sessions per App; +- expire closed sessions after 14 days; +- cap retained storage at 50 MiB per App, deleting oldest closed sessions first; +- enforce a profiled per-session bound and a global LabKit bound in addition to + the per-App bound; +- coalesce repeated identical `TRACE`/`DEBUG` records in a bounded window and + emit a summary with the suppressed count; +- never rate-limit or coalesce distinct `ERROR`/`CRITICAL` records; +- emit `journal.records_dropped` with count, reason, category, and interval + whenever capacity, rate limiting, or serialization rejects evidence. + +The concrete byte/count values above are profiling hypotheses, not frozen +schema v1 guarantees. Pruning removes expired and oldest closed sessions first, +never the active session, while satisfying session, App, and global bounds. + +The writer keeps one active segment handle and a small bounded buffer. It +flushes at measured record/byte thresholds; before and after +`WARNING`/`ERROR`/`CRITICAL`; at selected operation terminal points; and on +close or rotation. Flushing an error also flushes its preceding buffered +context. It does not open and close the JSONL file for every record. + +Instrumentation records root actions, operation boundaries, meaningful branch +decisions, and settled interaction state. It does not record every pointer +move, renderer property assignment, or repeated preview setter. Bounded +coalescing handles repetitive `DEBUG`; only opt-in `TRACE` may contain +fine-grained steps. + +Normal shutdown closes and flushes the manifest. An active-operation marker and +atomic manifest replacement allow the next launch to identify an abandoned +session. Journal cleanup occurs outside a user callback's critical path. +Failure to create, rotate, flush, or prune a journal never prevents the App +from starting or completing scientific work. Such failures increment explicit +drop/degradation accounting visible through any surviving sink; a broad catch +that silently changes only an internal counter is insufficient. + +### Privacy and diagnostic bundles + +The live App UI may render a user-selected path directly from current App state +when the workflow requires it. That value does not enter the logging pipeline. +The semantic event accepted by the recorder is already persistence-safe: + +```text +App/Runtime meaning + -> validated privacy-safe semantic event + |- default Log-viewer projection + |- persistent journal projection + `- export/interoperability projection +``` + +`eventName`, `category`, safe attributes, and operation lineage carry durable +meaning. The human message passes the same sanitizer and privacy validation +before entering the retained in-memory ring or any disk sink. Runtime replaces +source paths, filenames, and identifiers with stable session aliases; Apps do +not perform their own redaction. + +Persistent and exported projections redact home, temporary, artifact, +shared-drive, and user-selected roots as a defense in depth; replace filenames +with stable session aliases; and exclude by default: + +- project/source contents, images, numeric arrays, tables, and workspace values; +- subject, user, device, sample, and proprietary identifiers; +- original filenames and full local/network paths; +- source-file timestamps and arbitrary metadata copied from input files. + +Safe diagnostic facts include component versions, semantic role aliases, +dimensions, counts, selected enum/options, units, validation outcomes, +durations, exception identifiers, and repository-owned source locations. +Free-form App attributes pass through a deny-by-default type/size filter. + +**Export Diagnostic Bundle** creates a ZIP only after an explicit user action: + +```text +README.txt +manifest.json +events.jsonl +session.log.txt +errors.json +redaction-report.json +``` + +`README.txt` explains capture bounds and missing intervals. The redaction report +lists categories of removed data, never the removed values. Export snapshots +the already-sanitized journal; export-time scanning is an additional guard, not +the first privacy boundary. Synthetic packs, projects, scientific +inputs/results, screenshots, and source files are not included by default. +LabKit does not upload, email, or transmit a bundle and does not add telemetry +or analytics through this migration. + +### Unified launch and synthetic-input workflow + +The rescue entry and installed Launcher have different responsibilities. The +rescue entry only checks whether the installed Launcher can be called, performs +the unique ZIP repair transaction when it cannot, and preserves structural load +errors in its repair prompt. It neither discovers Apps nor writes diagnostics. + +The installed Launcher is an entrypoint composition owner. It lists/routs Apps +and invokes independent maintenance tools, but does not duplicate their +implementations. After SDK loading, Launcher actions and direct App entrypoints +use the same canonical Runtime session/operation contract. Runtime may create +or associate a normal session at the appropriate action boundary; this ledger +does not freeze a repair handoff or an early-startup lineage shape. + +The Runtime lease remains a persistence safety contract: active-session +recovery may abandon only a proven stale same-host process, and must retain +remote, live, malformed, or otherwise uncertain sessions without pruning them. + +Rename the misleading `BuildDebugSample` contract to +`BuildSyntheticSample`, and App-owned `+debug` fixture packages to +`+syntheticInputs`. **Generate Synthetic Inputs** validates and writes an +explicit pack/folder chosen by the user, then logs the outcome. It does not +load that pack, reconstruct state, run callbacks, or change the current +project. Sample generation is available in ordinary launches and has no effect +on journal level. + +Launcher removes its verbose-plus-sample `Open Debug` branch and +repository-specific diagnostics folder. The standard Runtime Tools menu owns +diagnostics after normal startup; rescue remains a repair affordance, not a +second diagnostic product. + +### Compatibility retirement + +This is a coordinated App SDK major migration. The temporary bridges used to +keep intermediate commits reviewable are retired on `debug-repair`: + +| Retired surface | Supported replacement | +| --- | --- | +| `appendStatus(message)` | semantic `CallbackContext.log` user event | +| `reportError(exception)` | semantic failure event with `Exception` | +| diagnostic `checkpoint` / `count` | structured developer event and attributes | +| `DiagnosticRecorder` | canonical `SessionEventStream` + `SessionDiagnostics` | +| launch diagnostic options | ordinary always-on session plus runtime trace tools | +| `BuildDebugSample` / App `+debug` | explicit `BuildSyntheticSample` / `+syntheticInputs` | +| Launcher **Open Debug** | normal launch, Diagnostics tools, Developer Tools | +| App-authored `logPanel` | framework session viewer | + +`RuntimeKernel.StatusLog` is now a private, bounded visible-status projection +fed by user-audience semantic events; it is not an App API, journal, or second +logging schema. Synthetic input generation owns a separate `RootFolder` and +does not share diagnostic options or storage. + +No permanent `Intentional compatibility` exemption is accepted for retired +surfaces. A genuine saved-project migration remains a versioned persistence +contract; it must not be confused with keeping duplicate logging APIs. + +### Implementation sequence and checkpoints + +Each phase is a small logical branch checkpoint with focused tests and a prompt +push. Intermediate branch commits do not independently accumulate App release +semantics; versions, manuals, and structured history describe the final net +change before merge. + +#### Phase 1: characterize and freeze the minimal contracts + +- Add direct characterization tests for current status/error/recorder behavior, + clean Launcher/direct startup, synthetic sample generation, sanitization, and + abandoned sessions. +- Specify only the minimal Runtime event schema, severity/audience meanings, + privacy classification, operation state machine, and manifest ownership. +- Specify retention dimensions and pruning order without freezing byte/count + hypotheses before measurement. +- Measure normal App startup, callback dispatch, representative interaction, + viewer update, and recorder throughput/memory. + +Exit gate: every known current gap has a failing target test or a documented +manual-only acceptance check; the self-contained Launcher boundary has an +explicit compatibility test; baseline performance artifacts are recorded. + +Phase 1 records the automated, non-interactive baselines for normal App +startup, one callback dispatch, and recorder throughput/memory. The following +manual-only evidence is a deferred gate, not a substitute for automation: using +synthetic inputs, capture a native managed-interaction baseline **before Phase +5 first changes interaction behavior**, and capture the legacy +status/log-panel/viewer baseline **before Phase 6 first replaces that viewer**. +Record the host MATLAB release, scenario, and whether any measured interval +includes human wait time. Repeat both manual checks during final manual +acceptance. Do not use a `-batch` GUI run as a substitute for either check. + +#### Phase 2: private in-memory canonical stream + +- Implement the private event record, validator, session context, operation + scope/state machine, and bounded in-memory ring. +- Extend sealed `CallbackContext` with the smallest `log` operation justified by + repeated App need. +- Adapt legacy status/error/checkpoint/count calls into the stream without + deleting their callers yet. +- Prove one callback produces one complete operation chain and rollback produces + one terminal failure without a false-success record. + +Do not implement the segmented writer, Launcher import, full viewer, or +all-App migration in this checkpoint. + +Exit gate: the in-memory stream, privacy validation, operation ancestry, legacy +adapters, and sink-failure isolation pass focused headless evidence. + +#### Phase 3: persistence and privacy projections + +- Implement the persistent projection, session manifest, segmented buffered + writer, session/App/global retention, rotation, coalescing, explicit drop + accounting, and abandoned-operation marker. +- Generate all session identities through one private owner that does not read + or advance MATLAB's global random-number state. Prove unchanged `rng` state + for direct stream, default journal, and default Runtime construction paths. +- Replace the scalar terminal outcome model everywhere with orthogonal + `operationResult` and `stateDisposition` fields. A failed callback whose + state is restored records `failed` plus `rolledBack` and one `.failed` + terminal event; no live reader accepts both scalar and structured aliases. +- Implement safe bundle snapshot/export from the already-sanitized journal. +- Enforce the deny-by-default attribute contract, including semantic sensitive + key rejection plus nesting and serialized-byte bounds. +- Surface journal unavailability and dropped-record degradation in the + surviving in-memory stream through a non-recursive path that never projects + the health event back into the failed sink. +- Prove buffered context is flushed with warnings/errors and journal failure + cannot change the scientific operation outcome. + +Exit gate: an ordinary headless session retains bounded, sanitized, +correlation-complete evidence after failure or interruption, with measured +throughput and no per-record open/close path. Session creation does not change +MATLAB RNG state; operation result and state disposition are reconstructable; +sensitive attributes never reach a sink; degradation is visible without +recursion; and every test Runtime injects an explicit temporary journal. + +#### Phase 4: repair-only Launcher delegation + +- Keep `labkit_launcher.m` self-contained and narrow: boot-critical installed + entry existence/callability, clear repair errors, and the one GitHub ZIP + repair transaction. Do not add a journal, schema, session importer, or App + catalog to it. +- Move installed UI composition and catalog routing to a focused hidden + `labkit.app.internal.launcher` capability. Menus call independently owned + tools through their stable entries; Launcher-only glue remains internal. +- Detect incomplete installation lazily: structural installed-entry, dependency, + or App-startup load failures retain their concrete cause and offer repair; + scientific input and ordinary user-operation failures do not. +- Validate a ZIP candidate only as a LabKit root with root launcher, installed + entry, and expected `apps`/`+labkit` content before replacement. Do not add a + new install manifest, hash, or file inventory. +- Root repair defaults to a trusted latest stable release, with a documented + stable-tag fallback. It does not retain main-branch selection or a product + version browser; an independently useful deployment tool may offer deliberate + version selection while invoking this same root transaction. +- Preserve the Runtime active-session lease contract from Phase 3. Archive + recovery may abandon only proven stale same-host sessions; live, remote, + malformed, and uncertain sessions remain visible and unpruned. This is not a + Launcher lease or inspection algorithm. +- After installed SDK loading, Launcher actions use the normal canonical Runtime + stream/operation contract. Do not freeze a repair-to-Runtime handoff shape. + +Exit gate: a single surviving root launcher can repair missing/damaged +`+labkit`; a healthy install delegates to the installed entry; an invalid ZIP +candidate is rejected; no-network repair failure is actionable; and no root +repair path imports `labkit.*` or creates a journal. Runtime archive tests prove +that a second live MATLAB process cannot be abandoned or pruned. + +#### Phase 5: complete Runtime automatic instrumentation + +- Instrument lifecycle, callbacks, presentation, projects, sources, dialogs, + results, resources, managed interactions, rollback, and recovery at semantic + operation boundaries. +- Exclude pointer-move and renderer-setter noise and prove repetitive debug + events are bounded. +- Correct framework documentation so every automatic claim has executable + proof. + +Exit gate: the framework fixture App proves every documented Runtime category +and one root action can be reconstructed across nested operations. + +#### Phase 6: standard viewer and diagnostic tools + +- Build the incremental framework Log viewer, **Tools > Diagnostics** actions, + problem summary, clear-view semantics, and bundle export. +- Add visible truncation, coalescing, retention, trace-disabled, and dropped + record state. +- Validate keyboard navigation, screen scaling, long messages, large journals, + and absence of viewport/callback interference. + +Exit gate: an ordinary hidden-GUI session can generate an incident, inspect +earlier `DEBUG` evidence, filter one root action, and export a privacy-safe +bundle without restarting. + +#### Phase 7: synthetic inputs and App-family migration + +- Rename `BuildDebugSample` and App `+debug` contracts separately from logging + mechanics; add **Tools > Developer Tools** generation without automatic load. +- Migrate one capability family at a time from status strings to stable domain + events and safe attributes. +- Remove App-authored log panels and duplicate exception/status presentation + only after the standard viewer is proven. +- Keep real callback/pointer regressions where interaction is a product + capability; construction-only evidence is insufficient. + +Exit gate: all Apps pass parameterized conformance, each capability family has a +representative real workflow, and no undeclared compatibility use remains. + +Checkpoint A is implemented on `debug-repair`: the public SDK types now live +under `labkit.app.synthetic`, all 21 public Apps and the accepted private +Imager App declare `BuildSyntheticSample` from `+syntheticInputs`, and ordinary +Tools generation validates/publishes without changing runtime state or +suppressing `OnStart`. The 63-case parameterized public native conformance +file, focused Batch Crop contracts, complete App SDK specification, and focused +private generator contract pass. App-family event migration and log-panel +retirement remain active Phase 7 work. + +Checkpoint B removes the duplicate App-authored Log tab from all 21 public +Apps and the accepted private Imager App. The standard Tools session viewer is +the only diagnostic-history surface, and the zero-consumer `logPanel` layout +primitive is deleted. All public layouts compile, all 21 public native launches +pass, and the private end-to-end GUI workflow passes. At that checkpoint, +legacy status/error calls were the remaining bridge users addressed next. + +Checkpoint C migrates every public App and the accepted private Imager App to +semantic status, failure, checkpoint, and count events. It deletes +`appendStatus`, `reportError`, diagnostic checkpoint/count entrypoints, +`DiagnosticRecorder`, launch diagnostic options, and the bridge-only test +schema. RuntimeFactory now always injects one complete `SessionDiagnostics` +service, synthetic packs own an independent `RootFolder`, the focused framework +logging suites pass, and all 63 public hidden native App smoke cases pass. + +#### Phase 8: retire bridges and close debt + +- Audit for zero remaining references to retired launch, logging, and synthetic + APIs in public and accepted private sources. +- Update current framework/App/Launcher manuals and generated documentation. +- Apply the final cross-component version audit and write one structured history + record describing the net behavior and compatibility impact. +- Run branch-review validation, developer-led manual acceptance, final + sensitive-data/diff audit, public/private PR review, merge, and merged-main + CI verification. + +Exit gate: only one supported launch/logging architecture remains and every +completion criterion below is satisfied. + +### Required automated evidence + +One framework fixture App owns complete headless evidence for: + +- severity validation and deterministic derived OpenTelemetry numeric mapping; +- deterministic sequence/time ordering and stable event/attribute schema; +- parent/root operation correlation across success, caught failure, rollback, + nested callback, and abandoned operation; +- `ERROR` exception capture without duplicate or false-success records; +- sanitization of roots, filenames, identifiers, metadata, exception messages, + and stacks; +- attribute type/size rejection and safe summarization; +- segment rotation, age/count/byte pruning, active-session preservation, + repeated-record coalescing, and explicit drop counts; +- atomic manifest/active-operation recovery after interrupted writes; +- sink failure isolation, including unwritable storage and malformed records; +- export bundle file set, schema, ordering, required semantic events, readable + rendering, and absence of excluded scientific/user data without freezing the + complete `session.log.txt` wording; +- zero references to retired logging, launch-debug, and synthetic-debug APIs. + +Hidden-GUI tests must prove: + +- default level/audience projection and live level changes without data loss; +- search, severity/category/root-action filters, follow/pause, clear-view, and + row-detail rendering; +- warning/error visibility, correlation ID, and export actions; +- large-journal incremental updates without rebuilding the full text control; +- **Tools > Developer Tools** synthetic generation leaves App state and current + inputs unchanged. + +Launcher/system tests must prove that the root file repairs missing/damaged +`+labkit`, a healthy root delegates to the installed entry, an invalid ZIP +candidate is rejected before replacement, and a no-network repair failure is +actionable. They must distinguish structural load/dependency failure (repair +affordance) from ordinary App scientific/input failure, prove no automatic +synthetic behavior, and prove the repair file imports no `labkit.*` symbol or +creates a journal. + +All Apps share parameterized conformance proving the standard viewer/tools are +available, no legacy logging API remains, definition/synthetic-input contracts +are legal, and at least one stable domain event is declared. This conformance +does not repeat full journal failure/export workflows per App. + +Each capability family selects a representative App for one real hidden-GUI +workflow. Apps with an actual interaction or logging regression retain their +focused callback/pointer test. The framework fixture, not every production App, +systematically covers callback, project, source, dialog, result, resource, +interaction, presentation, rollback, journal, export, and viewer failures. + +Performance/capacity tests profile the implementation rather than bless +untested constants. The initial acceptance target is under five percent median +overhead for representative startup and ordinary callbacks with `TRACE` off, +no observable scientific-output change, bounded memory/disk use, and no +quadratic viewer rebuild through at least 10,000 retained records. Any relaxed +threshold needs measured platform evidence and rationale. + +Repository data-hygiene tests scan source, tests, fixtures, and exported bundle +schema fixtures for local roots, real filenames, users, subjects, devices, +proprietary metadata, and recognizable sample values. Golden evidence asserts +semantic structure and exclusions, not full message prose. + +### Manual acceptance + +On at least one representative App from every capability family: + +1. launch normally and confirm there is no automatic data or action; +2. complete a normal workflow and judge `INFO` rows for usefulness/noise; +3. trigger a recoverable warning and a controlled callback/transaction failure; +4. reveal prior `DEBUG` history after the failure and follow one root action + through its nested operations; +5. export a bundle, inspect its readable timeline and redaction report, and + search it for identifying/local/scientific data; +6. generate synthetic inputs and confirm current App state is unchanged; +7. terminate a session mid-operation and export its recovered journal through + the next normal Runtime/archive inspection; +8. verify viewer scaling, keyboard use, copy actions, follow/pause, and long + messages on supported platforms. + +Native dialogs, pointer feel, perceived log usefulness, privacy judgment, and +abrupt process termination are not considered proven by hidden GUI tests. + +### Completion and ledger removal + +The migration is complete only when: + +- repair is a self-contained, journal-free rescue entry; healthy installed + Launcher and direct App paths use the canonical Runtime contract only after + SDK loading; +- ordinary sessions have a bounded, always-on, privacy-safe flight recorder; +- severity, audience, category, event, operation scope, and sink thresholds + have one canonical contract; +- all documented automatic Runtime categories have executable evidence; +- every public App uses the standard viewer/tools and domain logging contract; +- synthetic inputs are explicit, state-neutral, and independent of logging; +- incident export works for current and Runtime-recovered abandoned sessions; +- old status/diagnostic/debug surfaces and every temporary bridge are deleted; +- per-session, per-App, and global retention plus degradation, privacy, + performance, automated, and manual acceptance gates pass; +- current manuals, versions, generated docs, and one final cross-component + structured history record describe the supported net behavior; +- the public repository and accepted private App repository are merged into + their respective `main` branches, and CI passes for the exact merged commits. + +Delete this active entry in the final zero-debt squash change. Preserve durable +behavior and rationale in the owning manuals and structured component history; +do not create a future-state migration page under `docs/`. ## Maintaining the ledger Open an entry only for a concrete current problem with an owner, observable -effect, focused test, completion criteria, and removal condition. Do not treat -file length, helper count, or a possible future abstraction as migration debt. +effect, focused test, completion criteria, and removal condition. Do not copy a +completed audit, supported behavior inventory, speculative cleanup, file-size +concern, or possible future abstraction into this file. Temporary MathWorks Toolbox use must record the exact source symbol, product, owner, repository fallback, fallback test, idempotency evidence, numeric parity @@ -182,4 +849,4 @@ machine-readable declaration lives in `tests/+labkittest/toolboxDebt.m`. When an entry is resolved, delete it and any debt-only guardrail in the same change. Preserve durable decisions and evidence in the owning manual and -component history when project policy requires them. +component history. diff --git a/.agents/skills/labkit-app-builder/SKILL.md b/.agents/skills/labkit-app-builder/SKILL.md index f7db61ed0..15fe717d9 100644 --- a/.agents/skills/labkit-app-builder/SKILL.md +++ b/.agents/skills/labkit-app-builder/SKILL.md @@ -79,6 +79,15 @@ complex App it composes layout fragments from capability packages in user order. `+workbench/present.m` composes their snapshot fragments with `Snapshot.include`. Renderers live with the plot capability they draw. +When the App declares `BuildSyntheticSample`, make it return a validated synthetic +project and anonymous artifacts without changing startup semantics. There is +no separate Debug launch; generated inputs are selected +deliberately through the ordinary Developer Tools action. Seed interactive +fields with finite representative +values that survive the smallest supported native control limits. Validate the +sample contract headlessly and launch the sample project through the native +adapter; clean construction alone does not prove the sample is operational. + On the App SDK paved road, bind ordinary project/session fields directly in `labkit.app.layout.*` and let runtime defaults complete the view snapshot. Add a direct callback only for real business effects and a view operation only @@ -107,7 +116,9 @@ names must state their capability directly; do not add general buckets such as 3. Implement GUI-free readers/calculations/result builders with synthetic tests. 4. Implement feature-owned snapshot fragments, renderers, and managed - interactions. + interactions. Give overlapping gestures one active owner; a movable + rectangle must expose its visible interior or center as an ordinary drag + target. 5. Keep selection cheap and batch loading lazy; separate preview-resolution work from original-resolution Run/Export. 6. Add portable project references, relinking, current-envelope save, and only diff --git a/.agents/skills/labkit-boundary-guard/SKILL.md b/.agents/skills/labkit-boundary-guard/SKILL.md index c6f158ec8..10c368e3b 100644 --- a/.agents/skills/labkit-boundary-guard/SKILL.md +++ b/.agents/skills/labkit-boundary-guard/SKILL.md @@ -25,6 +25,13 @@ Before moving code into `+labkit`, prove that it: Otherwise keep it in the app. Duplication, helper length, and a desire to make an App callback file shorter are not sufficient evidence. +A shared facade may implement caller-selected preview sampling, but it retains +native pixels by default; the App owns every finite responsiveness-versus- +fidelity budget. Likewise, a read-only exact saved-data migration is a bounded +persistence contract, while simultaneous old and current fields on a live +value are competing models. Retire live aliases and their consumers together +under an explicit breaking facade range. + Use this escalation order for new behavior: 1. keep product meaning in the owning App capability; diff --git a/.agents/skills/labkit-test-planner/SKILL.md b/.agents/skills/labkit-test-planner/SKILL.md index 4a668951e..49512151b 100644 --- a/.agents/skills/labkit-test-planner/SKILL.md +++ b/.agents/skills/labkit-test-planner/SKILL.md @@ -29,6 +29,11 @@ ambiguous contracts. These are authoring decisions, not a reason to invent a test wrapper or folder. A missing-contract or zero-selection result is a failure to supply evidence, never a passing test result. +For focused MATLAB execution, add `tests` to the path and call +`labkittest.run`; `buildtool test --tasks` is not a catalog selector. Every +changed `projectSpec.m` must explain to nonempty App-owned `persistence` +evidence even when an end-to-end save/restore workflow also passes. + ## Choose Evidence Use the smallest behavior that proves the change: @@ -62,6 +67,11 @@ Use host permission for every MATLAB invocation. Keep GUI runs hidden and do not automate manual native-dialog or pointer workflows in `-batch`. Use only minimal synthetic fixtures; never track sensitive lab data. +When a legitimate all-App or GUI run can outlive one tool request, start one +hidden MATLAB process with durable redirected output, then poll its progress +artifact or log. A client timeout is not test evidence and is not a MATLAB +failure; record the executor's terminal result. + ## Report Report the exact owner/contract or profile command, selected identity count, diff --git a/AGENTS.md b/AGENTS.md index 79c80383a..e96bc7c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,12 @@ under `docs/`. - Validate an edited skill and exercise the changed script path. Record durable policy here or in the nearest scoped `AGENTS.md`; keep step-by-step agent procedure in skills rather than duplicating it in human manuals. +- Treat `.agents/dos-and-donts.md` as an experience reservoir. After each + meaningful checkpoint, add only high-value experience whose rediscovery + would be costly. Let lessons accumulate and survive repeated use before + promoting them to the nearest `AGENTS.md`, skill, test, source contract, or + manual; then actively compress or remove the reservoir copy plus stale, + duplicated, disproven, and low-value detail. ## Architecture and implementation @@ -56,24 +62,11 @@ under `docs/`. need the stable contract or extending an existing API would turn it into an ambiguous bucket. - App-facing packages are `labkit.app`, `image`, `thermal`, `dta`, `rhs`, - and `biosignal`. Do not - create public `analysis`, `data`, `io`, `util`, or app-specific helper - surfaces. -- Migrated Apps use `labkit.app.Definition`, `labkit.app.layout.*`, - `labkit.app.view.Snapshot`, typed `labkit.app.event.*` payloads, and - `labkit.app.CallbackContext`. Layout controls bind directly to concrete - semantic callbacks; there is no App-authored handler or renderer registry. - Entrypoints call `definition().launch(...)`. Apps do not own lifecycle - timers, readiness, callback queues, or concrete framework layout. -- `+workbench/buildLayout.m` is the visible product assembly boundary. - Complex Apps compose feature-owned layout and snapshot fragments from - capability packages; renderers live with the plot capability they draw. - Runtime callbacks name `applicationState`, typed event values, and - `callbackContext` explicitly, then delegate scientific work through narrow - inputs rather than forwarding SDK objects or a generic App object. -- Keep app entrypoints thin and app helpers under the owning app package. Name - packages and functions for the capability they own, not `helpers`, `utils`, - `process`, `handle`, or `manage`. + and `biosignal`. Do not create public `analysis`, `data`, `io`, `util`, or + app-specific helper surfaces. +- App shape, capability naming, callbacks, persistence, and Debug behavior are + governed by `apps/AGENTS.md`; App SDK internals and facade contracts are + governed by `+labkit/AGENTS.md`. - Do not convert struct state into classes, merge all apps into one entrypoint, or change implementation language without explicit approval. - File budgets count nonblank, non-comment MATLAB code. They are review @@ -81,10 +74,6 @@ under `docs/`. makes workflow order clearer. - Path collections use string or cell arrays. A scalar folder becomes `string(folder)`; never reshape an unknown char path with `(:)`. -- Sanitize UI numeric values to finite scalars before storing them. -- Repeated labels/choices that also drive state have one app-local owner. -- Interactive rectangles use managed interaction specs. Display-only graphics - disable hit testing, and overlay edits preserve the current viewport. - Scientific constants have semantic names and nearby rationale. Structural indices, UI geometry, versions, and synthetic fixtures are exempt. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 6372024cf..46ed6dcfb 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -6,8 +6,8 @@ framework owns common lifecycle and interaction mechanics. ## Read before editing Read the app source and nearby tests, `docs/apps/README.md`, and -`docs/development/app-development.md`. Read only the relevant framework or -library manual for APIs the app actually uses. App tests live under +`docs/development/build-apps/app-development.md`. Read only the relevant +framework or library manual for APIs the app actually uses. App tests live under `tests/specs/apps////`. Use `labkittest.explain` to find the exact owner and contract; App authors never invent test paths. @@ -17,7 +17,7 @@ find the exact owner and contract; App authors never invent test paths. `definition().launch(...)`. - `definition.m` is the single product contract. It declares stable identity, version, requirements, layout, and references to optional project, session, - presenter, debug-sample, and Start capabilities. + presenter, synthetic-input, and Start capabilities. It performs no IO, computation, export, handle creation, or lifecycle mutation. - A static App needs only the entrypoint, definition, and @@ -25,9 +25,9 @@ find the exact owner and contract; App authors never invent test paths. callbacks directly; do not create `definitionActions.m`, `stateHandlers.m`, callback bags, or renderer registries. - Add one `projectSpec.m` only for durable App-owned state. It returns a - `ProjectContract` owning local - create, validate, version-aware migrate, resume, relink, and declared - read-only legacy-import functions as needed; Runtime owns the migration loop. + `labkit.app.project.Schema` owning local create, validate, version-aware + migrate, resume, relink, and declared read-only legacy-import functions as + needed; Runtime owns the migration loop. - Add root `createSession.m` only to reconstruct App-specific transient data with the fixed `(project,context)` signature. Resolve opaque portable sources through `context.resolveSourcePaths`. @@ -57,6 +57,12 @@ find the exact owner and contract; App authors never invent test paths. - Do not add package-root lifecycle `run.m`, `+ui/runApp.m`, app-family `private/` workflow helpers, string dispatchers, or app-specific packages outside the owning app tree. +- `BuildSyntheticSample` creates a validated, reproducible synthetic project and + artifacts; it never authorizes startup work or automatic project loading. + Runtime exposes generation as an ordinary Developer Tools action; every + launch follows the same App startup path. + Interactive sample values are finite, representative, and valid for the + smallest native controls that will render them. ## Ownership and behavior @@ -81,8 +87,9 @@ find the exact owner and contract; App authors never invent test paths. view requires. Large batches stay lazy unless a useful first view requires full parsing. - Preview work uses current/display-resolution data; original-resolution batch - work belongs at Run or Export. Pixel-unit preview parameters scale with - preview resolution. + work belongs at Run or Export. Every finite preview pixel budget is an + explicit App-owned responsiveness decision; pixel-unit preview parameters + scale with preview resolution. - Repeatable Run/Export workflows use immutable task snapshots and deterministic fingerprints when stale or duplicated work is possible. @@ -95,15 +102,21 @@ find the exact owner and contract; App authors never invent test paths. Do not mutate registries, restore figure callbacks, create interaction runtimes, or add startup timers/readiness flags. - Interactive rectangles use managed `rectangle` or `regionSelection` specs. - Display-only rectangles disable hit testing. + One preview axis has at most one active editor for overlapping gestures; + combine placement and movement in one interaction instead of depending on + focus order. Movable rectangles expose an ordinary interior/center drag + affordance, while display-only graphics disable hit testing. +- Before narrowing a native control's dynamic limits, reconcile its bound value + into the new finite range. Defaults and synthetic projects must also remain + valid for the smallest supported source geometry. - Placing or editing overlays must preserve the user's viewport unless the user explicitly requests fit/reset. - File and folder dialogs outside `fileList` and alerts use CallbackContext. - External files in saved projects use portable references and field-specific relinking. Current saves use the project envelope; compatibility importers are read-only. -- Caught exceptions that allow the app to continue are reported through the - RuntimeContext before alerting or logging recovery. +- Caught exceptions that allow the App to continue are reported through + `CallbackContext` before alerting or logging recovery. ## Version, docs, and tests @@ -111,10 +124,12 @@ find the exact owner and contract; App authors never invent test paths. the App's `definition.m`, owned documentation, and component history before direct-main push or merge. - Test GUI wiring semantically: controls, choices, events, workflow outcomes, - viewport behavior, and traces. Test calculations and exports directly with - synthetic inputs. + viewport behavior, and traces. A synthetic project is validated headlessly + and launched through the native adapter; test calculations and exports + directly with minimal synthetic inputs. - Use the owning app-family unit suite and the app's hidden-GUI suite. Add project guardrails for entrypoint, boundary, fixture, or validation-policy - changes. Exact commands belong in `docs/development/testing.md`. + changes. Exact commands belong in + `docs/development/maintain-and-release/testing.md`. - Record only concrete compatibility or transitional retirement work in `.agents/migration_guide.md`; ordinary refactoring and file size are not debt. diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m index 2f2cd0a9f..570b09ff6 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/generate.m @@ -21,12 +21,15 @@ applicationState.session.cache.strain = ... dic_postprocess.sourceFiles.loadNcorrStrain(matPath); applicationState = prepare(applicationState); - callbackContext.appendStatus( ... + callbackContext.log("info", "dic_postprocess.analysisrun.generate.status", ... "Generated EXX/EYY overlays and ROI summary."); catch exception - callbackContext.reportError("Generate failed", exception); + callbackContext.log("error", "dic_postprocess.analysisrun.generate.exception", "Generate failed", ... + Category="failure", Audience="developer", Exception=exception); callbackContext.alert(exception.message, "DIC postprocess error"); - callbackContext.appendStatus("Generate failed: " + exception.message); + callbackContext.log("info", ... + "dic_postprocess.analysisrun.generate.status", ... + "DIC postprocess generation failed."); end end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m index 25c153953..e0af9c933 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+analysisRun/refreshOutputs.m @@ -9,7 +9,7 @@ end if applicationState.project.parameters.colorMax <= ... applicationState.project.parameters.colorMin - callbackContext.appendStatus( ... + callbackContext.log("info", "dic_postprocess.analysisrun.refreshoutputs.status", ... "Option update skipped: Color max must exceed color min."); return; end @@ -21,8 +21,9 @@ applicationState.session.cache.overlayExx = overlayExx; applicationState.session.cache.overlayEyy = overlayEyy; catch exception - callbackContext.reportError("Option update skipped", exception); - callbackContext.appendStatus( ... - "Option update skipped: " + exception.message); + callbackContext.log("error", "dic_postprocess.analysisrun.refreshoutputs.exception", "Option update skipped", ... + Category="failure", Audience="developer", Exception=exception); + callbackContext.log("info", "dic_postprocess.analysisrun.refreshoutputs.status", ... + "DIC option update was skipped."); end end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m index 731596962..b70928dd2 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/exportSummary.m @@ -15,7 +15,7 @@ choice = callbackContext.chooseOutputFile( ... ["*.csv", "CSV files (*.csv)"], defaultName); if choice.Cancelled - callbackContext.appendStatus("Export summary cancelled."); + callbackContext.log("info", "dic_postprocess.resultfiles.exportsummary.status", "Export summary cancelled."); return; end filepath = string(choice.Value); @@ -33,7 +33,9 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.summaryManifestPath = ... string(written.Value); -callbackContext.appendStatus("Exported summary CSV: " + filepath); +callbackContext.log("info", ... + "dic_postprocess.resultfiles.exportsummary.status", ... + "Exported the DIC summary."); end function filepath = pathForRole(sources, role, context) diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m index 2533cc727..719b0b168 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+resultFiles/saveOverlays.m @@ -10,7 +10,7 @@ end choice = callbackContext.chooseOutputFolder(""); if choice.Cancelled - callbackContext.appendStatus("Save overlay PNGs cancelled."); + callbackContext.log("info", "dic_postprocess.resultfiles.saveoverlays.status", "Save overlay PNGs cancelled."); return; end folder = string(choice.Value); @@ -38,9 +38,8 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.overlayManifestPath = ... string(written.Value); -callbackContext.appendStatus( ... - "Saved clean overlay PNGs: " + ... - fullfile(folder, exxName) + " and " + fullfile(folder, eyyName)); +callbackContext.log("info", "dic_postprocess.resultfiles.saveoverlays.status", ... + "Saved clean overlay PNGs."); end function filepath = pathForRole(sources, role, context) diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m b/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m index ecf9fd19e..64c0d6bf2 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+sourceFiles/invalidateResults.m @@ -6,5 +6,5 @@ applicationState.session.cache.strain = struct(); applicationState.session.cache.overlayExx = []; applicationState.session.cache.overlayEyy = []; -callbackContext.appendStatus("Updated DIC postprocess inputs."); +callbackContext.log("info", "dic_postprocess.sourcefiles.invalidateresults.status", "Updated DIC postprocess inputs."); end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m b/apps/dic/dic_postprocess/+dic_postprocess/+syntheticInputs/writeSamplePack.m similarity index 96% rename from apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m rename to apps/dic/dic_postprocess/+dic_postprocess/+syntheticInputs/writeSamplePack.m index ca77a54e2..89731717c 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+debug/writeSamplePack.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+syntheticInputs/writeSamplePack.m @@ -5,7 +5,7 @@ function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write DIC postprocess debug files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end matPath = sampleContext.samplePath( ... @@ -46,7 +46,7 @@ sampleContext.artifact( ... "malformedStrain", "boundaryInput", malformedMatPath, ... Expectation="rejects")}; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-strain-overlay", ... InitialProject=project, Artifacts=artifacts); end diff --git a/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m b/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m index 7d896813a..78b54bfff 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/+workbench/buildLayout.m @@ -50,10 +50,7 @@ Title="ROI Strain Summary", ... Columns=["Metric" "EXX" "EYY"]), ... labkit.app.layout.statusPanel( ... - "summaryText", Title="Summary")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; + "summaryText", Title="Summary")})})}; workspace = labkit.app.layout.workspace( ... labkit.app.layout.plotArea("overlayAxes", ... @dic_postprocess.overlayPreview.draw, ... diff --git a/apps/dic/dic_postprocess/+dic_postprocess/definition.m b/apps/dic/dic_postprocess/+dic_postprocess/definition.m index 50689e369..a4bb0af55 100644 --- a/apps/dic/dic_postprocess/+dic_postprocess/definition.m +++ b/apps/dic/dic_postprocess/+dic_postprocess/definition.m @@ -6,13 +6,13 @@ Title="DIC Strain Postprocess", ... DisplayName="DIC Postprocess", ... Family="DIC", ... - AppVersion="1.5.1", ... - Updated="2026-07-20", ... + AppVersion="1.6.0", ... + Updated="2026-07-26", ... Requirements=labkit.contract.requirements( ... - "app", ">=1 <2", "image", ">=2.0 <3"), ... + "app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=dic_postprocess.projectSpec(), ... CreateSession=@dic_postprocess.createSession, ... Workbench=dic_postprocess.workbench.buildLayout(), ... PresentWorkbench=@dic_postprocess.workbench.present, ... - BuildDebugSample=@dic_postprocess.debug.writeSamplePack); + BuildSyntheticSample=@dic_postprocess.syntheticInputs.writeSamplePack); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m index 5bbfec5d1..f1ca2c505 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyCropRoi.m @@ -29,6 +29,6 @@ dic_preprocess.analysisRun.cropSummary(rectangle); applicationState = ... dic_preprocess.analysisRun.clearResults(applicationState); -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "dic_preprocess.analysisrun.applycroproi.status", sprintf( ... "Cropped current pair with [%g %g %g %g].", rectangle)); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m index c81c10ca2..a8100d985 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/applyPointAlignment.m @@ -11,11 +11,12 @@ [~, transform] = dic_preprocess.analysisRun.alignMovingToReference( ... cache.currentReferenceImage, cache.currentMovingImage, reference, moving); catch ME - context.reportError('Point alignment', ME); + context.log("error", "dic_preprocess.analysisrun.applypointalignment.exception", 'Point alignment', ... + Category="failure", Audience="developer", Exception=ME); context.alert(ME.message, 'Point alignment failed'); return; end state = dic_preprocess.analysisRun.recordAlignment(state, transform, "manual alignment"); -context.appendStatus( ... +context.log("info", "dic_preprocess.analysisrun.applypointalignment.status", ... "Aligned image using " + string(size(reference,1)) + " point pair(s)."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m index 1456a5155..01c4a70a7 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelCropRoi.m @@ -5,5 +5,5 @@ return end applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); -callbackContext.appendStatus("Crop ROI cancelled."); +callbackContext.log("info", "dic_preprocess.analysisrun.cancelcroproi.status", "Crop ROI cancelled."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m index b4e501ea8..fdc5b57c7 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/cancelPointMatching.m @@ -6,5 +6,5 @@ end applicationState = dic_preprocess.analysisRun.stopEditors(applicationState); applicationState.session.workflow.details = {'Point matching cancelled.'}; -callbackContext.appendStatus("Cancelled point matching."); +callbackContext.log("info", "dic_preprocess.analysisrun.cancelpointmatching.status", "Cancelled point matching."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m index 4ab611cac..1dcd3c22b 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/resetToOriginals.m @@ -17,5 +17,5 @@ applicationState.project.parameters.previewMode = "Current pair"; applicationState.session.workflow.details = {'Restored original image pair.'}; applicationState = dic_preprocess.analysisRun.clearResults(applicationState); -callbackContext.appendStatus("Reset the working pair to originals."); +callbackContext.log("info", "dic_preprocess.analysisrun.resettooriginals.status", "Reset the working pair to originals."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m index 78890904c..d200689d2 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/runAutomaticRegistration.m @@ -10,11 +10,12 @@ [~, transform, method] = dic_preprocess.analysisRun.autoAlignMovingToReference( ... cache.currentReferenceImage, cache.currentMovingImage); catch ME - context.reportError('Automatic alignment', ME); + context.log("error", "dic_preprocess.analysisrun.runautomaticregistration.exception", 'Automatic alignment', ... + Category="failure", Audience="developer", Exception=ME); context.alert("Automatic alignment failed:" + newline + ME.message, 'Auto align failed'); return; end state = dic_preprocess.analysisRun.recordAlignment(state, transform, "automatic alignment"); -context.appendStatus( ... +context.log("info", "dic_preprocess.analysisrun.runautomaticregistration.status", ... "Automatically aligned current pair using " + string(method) + "."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m index e5932ac22..66774fafd 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/startCropRoi.m @@ -16,5 +16,5 @@ applicationState.session.workflow.mode = "crop"; applicationState.session.workflow.details = ... dic_preprocess.analysisRun.cropSelectionSummary(rectangle); -callbackContext.appendStatus("Started crop ROI on the current pair."); +callbackContext.log("info", "dic_preprocess.analysisrun.startcroproi.status", "Started crop ROI on the current pair."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m index 3b38fced6..8b8d69ac2 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+analysisRun/undoEdit.m @@ -21,6 +21,6 @@ "Restored state before %s.", snapshot.description)}; applicationState = ... dic_preprocess.analysisRun.clearResults(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", "dic_preprocess.analysisrun.undoedit.status", ... "Undid " + string(snapshot.description) + "."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m index 3635d441c..1de7426e1 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/applyBoundary.m @@ -18,7 +18,7 @@ boundaryMask, operation); applicationState.project.parameters.previewMode = "ROI mask"; applicationState = dic_preprocess.analysisRun.clearResults(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", "dic_preprocess.maskediting.applyboundary.status", ... upperFirst(operation) + "ed boundary on the ROI mask canvas."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m index 2a4ac2962..4d5308c05 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearBoundary.m @@ -2,5 +2,5 @@ function applicationState = clearBoundary( ... applicationState, callbackContext) applicationState.project.annotations.maskPoints = zeros(0, 2); -callbackContext.appendStatus("Cleared mask ROI boundary anchors."); +callbackContext.log("info", "dic_preprocess.maskediting.clearboundary.status", "Cleared mask ROI boundary anchors."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m index 8be62a490..298465f8d 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/clearCanvas.m @@ -8,5 +8,5 @@ applicationState.project, "clear mask canvas"); applicationState.project.annotations.maskImage = []; applicationState = dic_preprocess.analysisRun.clearResults(applicationState); -callbackContext.appendStatus("Cleared ROI mask canvas."); +callbackContext.log("info", "dic_preprocess.maskediting.clearcanvas.status", "Cleared ROI mask canvas."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m index ca78351f4..e52fbfe37 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/previewBoundary.m @@ -6,7 +6,7 @@ if accepted applicationState.project.annotations.maskImage = mask; applicationState.project.parameters.previewMode = "ROI mask"; - callbackContext.appendStatus("Previewed ROI mask boundary."); + callbackContext.log("info", "dic_preprocess.maskediting.previewboundary.status", "Previewed ROI mask boundary."); elseif ~isempty(applicationState.project.annotations.maskImage) applicationState.project.parameters.previewMode = "ROI mask"; else diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m index b57a5bb65..af6a4c7ca 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/startEdit.m @@ -12,5 +12,5 @@ applicationState.session.workflow.details = ... dic_preprocess.maskEditing.maskDraftDetails( ... applicationState.project.annotations.maskPoints); -callbackContext.appendStatus("Started ROI mask editing."); +callbackContext.log("info", "dic_preprocess.maskediting.startedit.status", "Started ROI mask editing."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m index 87432731d..b27630457 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+maskEditing/undoEdit.m @@ -11,6 +11,6 @@ applicationState.project, snapshot); applicationState.project.parameters.previewMode = "ROI mask"; applicationState = dic_preprocess.analysisRun.clearResults(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", "dic_preprocess.maskediting.undoedit.status", ... "Undid mask edit: " + string(snapshot.description) + "."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m index 63bb0afed..c185ba599 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveCurrentImages.m @@ -11,7 +11,7 @@ end choice = callbackContext.chooseOutputFolder(""); if choice.Cancelled - callbackContext.appendStatus("Save current images cancelled."); + callbackContext.log("info", "dic_preprocess.resultfiles.savecurrentimages.status", "Save current images cancelled."); return end folder = string(choice.Value); @@ -28,7 +28,7 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.currentImagesManifestPath = ... string(written.Value); -callbackContext.appendStatus("Saved current DIC image pair."); +callbackContext.log("info", "dic_preprocess.resultfiles.savecurrentimages.status", "Saved current DIC image pair."); end function file = resultFile(id, filepath) diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m index ee7719641..f9d26a84c 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+resultFiles/saveMask.m @@ -16,7 +16,7 @@ choice = callbackContext.chooseOutputFile( ... ["*.png", "PNG mask"], "roi_mask.png"); if choice.Cancelled - callbackContext.appendStatus("Save ROI mask cancelled."); + callbackContext.log("info", "dic_preprocess.resultfiles.savemask.status", "Save ROI mask cancelled."); return end filepath = string(choice.Value); @@ -32,5 +32,6 @@ ManifestName=string(name) + ".labkit.json"); written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.maskManifestPath = string(written.Value); -callbackContext.appendStatus("Saved ROI mask: " + filepath); +callbackContext.log("info", "dic_preprocess.resultfiles.savemask.status", ... + "Saved the ROI mask."); end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m index f19dce23a..efc689323 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+sourceFiles/sourceChanged.m @@ -20,6 +20,6 @@ applicationState.project.results.currentImagesManifestPath = ""; applicationState.project.results.maskManifestPath = ""; if ~isempty(selection.Indices) - callbackContext.appendStatus("Updated DIC source image."); + callbackContext.log("info", "dic_preprocess.sourcefiles.sourcechanged.status", "Updated DIC source image."); end end diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m b/apps/dic/dic_preprocess/+dic_preprocess/+syntheticInputs/writeSamplePack.m similarity index 93% rename from apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m rename to apps/dic/dic_preprocess/+dic_preprocess/+syntheticInputs/writeSamplePack.m index 9651fc8c4..58bdc938a 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+debug/writeSamplePack.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+syntheticInputs/writeSamplePack.m @@ -5,7 +5,7 @@ function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write DIC preprocess debug image files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end referencePath = sampleContext.samplePath( ... @@ -42,7 +42,7 @@ sampleContext.artifact( ... "malformedImage", "boundaryInput", malformedPath, ... Expectation="rejects")}; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-image-pair", ... InitialProject=project, Artifacts=artifacts); end @@ -67,7 +67,7 @@ function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 - error("dic_preprocess:debug:SampleWriteFailed", "Could not write debug sample file: %s.", filepath); + error("dic_preprocess:syntheticInputs:SampleWriteFailed", "Could not write synthetic input file: %s.", filepath); end cleanup = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); diff --git a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m index 42257ee4c..421b71031 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/+workbench/buildLayout.m @@ -13,10 +13,7 @@ labkit.app.layout.section("summarySection", "Summary", { ... labkit.app.layout.statusPanel("summary", Title="Summary")}), ... labkit.app.layout.section("detailsSection", "Details", { ... - labkit.app.layout.statusPanel("details", Title="Details")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; + labkit.app.layout.statusPanel("details", Title="Details")})})}; matchPoints = labkit.app.interaction.pairedAnchors("matchPoints", ... @dic_preprocess.analysisRun.changeMatchPoints, ... Axes=["reference", "moving"], ... diff --git a/apps/dic/dic_preprocess/+dic_preprocess/definition.m b/apps/dic/dic_preprocess/+dic_preprocess/definition.m index 3a9ab4952..9ff72ac8a 100644 --- a/apps/dic/dic_preprocess/+dic_preprocess/definition.m +++ b/apps/dic/dic_preprocess/+dic_preprocess/definition.m @@ -4,9 +4,9 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_DICPreprocess_app", ... AppId="dic_preprocess", Title="DIC Image Preprocess", ... - DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.6.1", ... - Updated="2026-07-20", Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + DisplayName="DIC Preprocess", Family="DIC", AppVersion="1.7.0", ... + Updated="2026-07-26", Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=dic_preprocess.projectSpec(), CreateSession=@dic_preprocess.createSession, ... Workbench=dic_preprocess.workbench.buildLayout(), PresentWorkbench=@dic_preprocess.workbench.present, ... - BuildDebugSample=@dic_preprocess.debug.writeSamplePack); + BuildSyntheticSample=@dic_preprocess.syntheticInputs.writeSamplePack); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m index 1f5d27d1d..bf0c13056 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+overlayPlot/draw.m @@ -100,18 +100,9 @@ function renderEmpty(ax, signal) function values = signalValues(item, signal) if signal == "voltage" - values = firstAvailable(item, "Vf_V", "Vf"); + values = item.Vf_V(:); else - values = firstAvailable(item, "Im_A", "Im"); - end -end - -function values = firstAvailable(item, preferred, fallback) - values = []; - if isfield(item, preferred) && ~isempty(item.(preferred)) - values = item.(preferred)(:); - elseif isfield(item, fallback) && ~isempty(item.(fallback)) - values = item.(fallback)(:); + values = item.Im_A(:); end end @@ -129,7 +120,7 @@ function renderEmpty(ax, signal) end function values = alignedTime(item) - values = firstAvailable(item, "tAligned_s", "tAligned"); + values = item.tAligned_s(:); end function values = samplePoint(item) diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m index 399eb5f71..58eba890b 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/buildOverlayExportTable.m @@ -33,32 +33,26 @@ end function t = chronoAlignedTime(item) - if isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) - t = item.tAligned_s(:); - elseif isfield(item, 'tAligned') && ~isempty(item.tAligned) - t = item.tAligned(:); - else + if ~isfield(item, 'tAligned_s') || isempty(item.tAligned_s) t = []; + else + t = item.tAligned_s(:); end end function v = chronoVoltage(item) - if isfield(item, 'Vf_V') && ~isempty(item.Vf_V) - v = item.Vf_V(:); - elseif isfield(item, 'Vf') && ~isempty(item.Vf) - v = item.Vf(:); - else + if ~isfield(item, 'Vf_V') || isempty(item.Vf_V) v = []; + else + v = item.Vf_V(:); end end function i = chronoCurrent(item) - if isfield(item, 'Im_A') && ~isempty(item.Im_A) - i = item.Im_A(:); - elseif isfield(item, 'Im') && ~isempty(item.Im) - i = item.Im(:); - else + if ~isfield(item, 'Im_A') || isempty(item.Im_A) i = []; + else + i = item.Im_A(:); end end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m index 9016b8ded..023041769 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+resultFiles/exportSelectedCurves.m @@ -38,7 +38,9 @@ written = context.writeResultPackage(folder, result); state.project.results.lastExport = struct( ... "csvPath", outputPath, "manifestPath", string(written.Value)); -context.appendStatus("Exported CSV: " + outputPath); +context.log("info", ... + "chrono_overlay.resultfiles.exportselectedcurves.status", ... + "Exported selected curve data."); end function items = selectedItems(items, indices) diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m index c6d412b9c..a0b461cb9 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+sourceFiles/alignByPulseGap.m @@ -27,62 +27,40 @@ end if isfield(pulse, 'ok') && pulse.ok - alignTime = 0.5 * (pulse.gap_start + pulse.gap_end); + alignTime = 0.5 * (pulse.gap.start_s + pulse.gap.end_s); if isfinite(alignTime) - item.alignTime = alignTime; - item.tAligned = t - alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; + item.alignTime_s = alignTime; + item.tAligned_s = t - alignTime; msg = sprintf('%s: aligned to cathodic/anodic blank center at %.9g s (gap %.9g to %.9g s, %s).', ... - itemName, alignTime, pulse.gap_start, pulse.gap_end, pulse.method); + itemName, alignTime, pulse.gap.start_s, pulse.gap.end_s, pulse.method); return; end - item.alignTime = t(1); - item.tAligned = t - item.alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; + item.alignTime_s = t(1); + item.tAligned_s = t - item.alignTime_s; msg = sprintf('%s: blank center not found, fallback to first sample (%s).', itemName, pulseMsg); return; end - item.alignTime = t(1); - item.tAligned = t - item.alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; + item.alignTime_s = t(1); + item.tAligned_s = t - item.alignTime_s; msg = sprintf('%s: pulse gap not found, fallback to first sample (%s).', itemName, pulseMsg); end function t = chronoTime(item) - if isfield(item, 't_s') && ~isempty(item.t_s) - t = item.t_s; - elseif isfield(item, 't') && ~isempty(item.t) - t = item.t; - else + if ~isfield(item, 't_s') || isempty(item.t_s) t = []; + else + t = item.t_s; end t = t(:); end function pulse = emptyPulse() - pulse = struct( ... - 'ok', false, ... - 'method', '-', ... - 'message', '', ... - 'cath_start', NaN, ... - 'cath_end', NaN, ... - 'anod_start', NaN, ... - 'anod_end', NaN, ... - 'Ic_nominal', NaN, ... - 'Ia_nominal', NaN, ... - 'pre_start', NaN, ... - 'pre_end', NaN, ... - 'gap_start', NaN, ... - 'gap_end', NaN, ... - 'post_start', NaN, ... - 'post_end', NaN); - + pulse = struct('ok', false, 'method', '-', 'message', ''); + pulse.pre = struct('start_s', NaN, 'end_s', NaN); pulse.cath = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); - pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); pulse.gap = struct('start_s', NaN, 'end_s', NaN, 'center_s', NaN); + pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); + pulse.post = struct('start_s', NaN, 'end_s', NaN); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+syntheticInputs/writeSamplePack.m similarity index 94% rename from apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m rename to apps/electrochem/chrono_overlay/+chrono_overlay/+syntheticInputs/writeSamplePack.m index fe2aca09b..ba670031d 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+debug/writeSamplePack.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ % Expected caller: Chrono Overlay debug-sample tooling and unit tests. % Input is a LabKit debug context. Output is a deterministic synthetic DTA % sample pack. Side effects: writes anonymous debug input files under the -% debug samples folder and records a session manifest when available. +% synthetic inputs folder and records a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Chrono Overlay debug DTA files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end currentPath = sampleContext.samplePath("chrono_overlay/current.DTA"); @@ -21,7 +21,7 @@ project.inputs.sources = [ ... sampleContext.sourceRecord("dta1", "chrono", currentPath, true), ... sampleContext.sourceRecord("dta2", "chrono", voltagePath, true)]; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-chrono-overlay", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("currentPulse", "chrono", currentPath), ... @@ -126,8 +126,8 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("chrono_overlay:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("chrono_overlay:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m index 175c4f1ea..d20f9e724 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+workbench/buildLayout.m @@ -34,9 +34,7 @@ Label="Show file-name legend", Kind="logical", ... Bind="project.parameters.showLegend"), ... labkit.app.layout.field("showGrid", Label="Show grid", ... - Kind="logical", Bind="project.parameters.showGrid")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.logPanel("appLog")})}; + Kind="logical", Bind="project.parameters.showGrid")})})}; workspace = labkit.app.layout.workspace( ... labkit.app.layout.plotArea("overlayPlots", ... @chrono_overlay.overlayPlot.draw, ... diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m index 4a2307b65..cf6e924ff 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/definition.m @@ -7,13 +7,13 @@ Title="Gamry Multi-DTA Plot Export GUI", ... DisplayName="Chrono Overlay", ... Family="Electrochem", ... - AppVersion="1.5.1", ... - Updated="2026-07-20", ... + AppVersion="1.6.0", ... + Updated="2026-07-26", ... Requirements=labkit.contract.requirements( ... - "app", ">=1 <2", "dta", ">=2.0 <3"), ... + "app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=chrono_overlay.projectSpec(), ... CreateSession=@chrono_overlay.createSession, ... Workbench=chrono_overlay.workbench.buildLayout(), ... PresentWorkbench=@chrono_overlay.workbench.present, ... - BuildDebugSample=@chrono_overlay.debug.writeSamplePack); + BuildSyntheticSample=@chrono_overlay.syntheticInputs.writeSamplePack); end diff --git a/apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m b/apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m index 3f75c4fab..82fc45ae0 100644 --- a/apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m +++ b/apps/electrochem/cic/+cic/+analysisPlot/plotRequest.m @@ -46,20 +46,20 @@ x = A.pt; xLabel = char(choices.xAxes(2)); coords = struct( ... - 'cathStartX', interp1Safe(A.t, A.pt, A.pulse.cath_start), ... - 'cathEndX', interp1Safe(A.t, A.pt, A.pulse.cath_end), ... - 'anodStartX', interp1Safe(A.t, A.pt, A.pulse.anod_start), ... - 'anodEndX', interp1Safe(A.t, A.pt, A.pulse.anod_end), ... + 'cathStartX', interp1Safe(A.t, A.pt, A.pulse.cath.start_s), ... + 'cathEndX', interp1Safe(A.t, A.pt, A.pulse.cath.end_s), ... + 'anodStartX', interp1Safe(A.t, A.pt, A.pulse.anod.start_s), ... + 'anodEndX', interp1Safe(A.t, A.pt, A.pulse.anod.end_s), ... 'emcX', interp1Safe(A.t, A.pt, A.t_emc), ... 'emaX', interp1Safe(A.t, A.pt, A.t_ema)); else x = A.t; xLabel = char(choices.xAxes(1)); coords = struct( ... - 'cathStartX', A.pulse.cath_start, ... - 'cathEndX', A.pulse.cath_end, ... - 'anodStartX', A.pulse.anod_start, ... - 'anodEndX', A.pulse.anod_end, ... + 'cathStartX', A.pulse.cath.start_s, ... + 'cathEndX', A.pulse.cath.end_s, ... + 'anodStartX', A.pulse.anod.start_s, ... + 'anodEndX', A.pulse.anod.end_s, ... 'emcX', A.t_emc, ... 'emaX', A.t_ema); end diff --git a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m index 50247afc0..9bab8729a 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m +++ b/apps/electrochem/cic/+cic/+analysisRun/computeCIC.m @@ -325,8 +325,8 @@ function V = computeVoltageTransientMetrics(t, Vf, pulse, delay_s) V = struct(); - V.t_emc = pulse.cath_end + delay_s; - V.t_ema = pulse.anod_end + delay_s; + V.t_emc = pulse.cath.end_s + delay_s; + V.t_ema = pulse.anod.end_s + delay_s; V.ok = V.t_emc >= min(t) && V.t_emc <= max(t) && ... V.t_ema >= min(t) && V.t_ema <= max(t); if ~V.ok @@ -342,23 +342,23 @@ V.Emc = interp1Safe(t, Vf, V.t_emc); V.Ema = interp1Safe(t, Vf, V.t_ema); - V.Epre = medianInWindow(t, Vf, pulse.pre_start, pulse.pre_end); - V.Ebetween = medianInWindow(t, Vf, pulse.gap_start, pulse.gap_end); - V.Epost = medianInWindow(t, Vf, pulse.post_start, pulse.post_end); + V.Epre = medianInWindow(t, Vf, pulse.pre.start_s, pulse.pre.end_s); + V.Ebetween = medianInWindow(t, Vf, pulse.gap.start_s, pulse.gap.end_s); + V.Epost = medianInWindow(t, Vf, pulse.post.start_s, pulse.post.end_s); [V.Eipp, V.baselineCathSource, V.baselineCathWindow] = chooseBaselineCandidate( ... [V.Epre, V.Ebetween, V.Epost, 0], ... {'pre-pulse median', 'interpulse median', 'post-pulse median', 'zero fallback'}, ... - [pulse.pre_start pulse.pre_end; pulse.gap_start pulse.gap_end; pulse.post_start pulse.post_end; NaN NaN]); + [pulse.pre.start_s pulse.pre.end_s; pulse.gap.start_s pulse.gap.end_s; pulse.post.start_s pulse.post.end_s; NaN NaN]); [V.Eipp_gap, V.baselineAnodSource, V.baselineAnodWindow] = chooseBaselineCandidate( ... [V.Ebetween, V.Epre, V.Epost, V.Eipp], ... {'interpulse median', 'pre-pulse median', 'post-pulse median', 'cathodic baseline fallback'}, ... - [pulse.gap_start pulse.gap_end; pulse.pre_start pulse.pre_end; pulse.post_start pulse.post_end; V.baselineCathWindow]); + [pulse.gap.start_s pulse.gap.end_s; pulse.pre.start_s pulse.pre.end_s; pulse.post.start_s pulse.post.end_s; V.baselineCathWindow]); - V.tc_s = max(0, pulse.cath_end - pulse.cath_start); - V.ta_s = max(0, pulse.anod_end - pulse.anod_start); - V.tip_s = max(0, pulse.anod_start - pulse.cath_end); - V.t_conset = pulse.cath_start + delay_s; - V.t_aonset = pulse.anod_start + delay_s; + V.tc_s = max(0, pulse.cath.end_s - pulse.cath.start_s); + V.ta_s = max(0, pulse.anod.end_s - pulse.anod.start_s); + V.tip_s = max(0, pulse.anod.start_s - pulse.cath.end_s); + V.t_conset = pulse.cath.start_s + delay_s; + V.t_aonset = pulse.anod.start_s + delay_s; V.Vc_on = interp1Safe(t, Vf, V.t_conset); V.Va_on = interp1Safe(t, Vf, V.t_aonset); V.Va_cath_mag = abs(V.Eipp - V.Vc_on); @@ -420,8 +420,8 @@ end Q = struct(); - cathMask = (t >= pulse.cath_start) & (t <= pulse.cath_end); - anodMask = (t >= pulse.anod_start) & (t <= pulse.anod_end); + cathMask = (t >= pulse.cath.start_s) & (t <= pulse.cath.end_s); + anodMask = (t >= pulse.anod.start_s) & (t <= pulse.anod.end_s); Q.cathMask = cathMask; Q.anodMask = anodMask; @@ -434,18 +434,18 @@ Q.Ic_est_A = median(Im(cathMask), 'omitnan'); Q.Ia_est_A = median(Im(anodMask), 'omitnan'); if ~isfinite(Q.Ic_est_A) - Q.Ic_est_A = pulse.Ic_nominal; + Q.Ic_est_A = pulse.cath.current_A; end if ~isfinite(Q.Ia_est_A) - Q.Ia_est_A = pulse.Ia_nominal; + Q.Ia_est_A = pulse.anod.current_A; end if useMeasuredCurrent Qc = abs(trapz(t(cathMask), Im(cathMask))); Qa = abs(trapz(t(anodMask), Im(anodMask))); else - Qc = abs(pulse.Ic_nominal * (pulse.cath_end - pulse.cath_start)); - Qa = abs(pulse.Ia_nominal * (pulse.anod_end - pulse.anod_start)); + Qc = abs(pulse.cath.current_A * (pulse.cath.end_s - pulse.cath.start_s)); + Qa = abs(pulse.anod.current_A * (pulse.anod.end_s - pulse.anod.start_s)); end Q.Qc_C = Qc; diff --git a/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m b/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m index cd78c0efd..8a49534f9 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m +++ b/apps/electrochem/cic/+cic/+analysisRun/presetChanged.m @@ -16,7 +16,7 @@ applicationState.session.cache.items = ... cic.analysisRun.recomputeLoaded( ... items, applicationState.project.parameters); - callbackContext.appendStatus(sprintf( ... + callbackContext.log("info", "cic.analysisrun.presetchanged.status", sprintf( ... "Reanalyzed %d loaded CIC file(s) for the selected preset.", ... numel(items))); end diff --git a/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m b/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m index 7434f47be..3dbd3924b 100644 --- a/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m +++ b/apps/electrochem/cic/+cic/+analysisRun/settingsChanged.m @@ -11,6 +11,6 @@ cic.analysisRun.recomputeLoaded( ... items, applicationState.project.parameters); applicationState.project.results.lastExport = []; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "cic.analysisrun.settingschanged.status", sprintf( ... "Reanalyzed %d loaded CIC file(s).", numel(items))); end diff --git a/apps/electrochem/cic/+cic/+resultFiles/exportResults.m b/apps/electrochem/cic/+cic/+resultFiles/exportResults.m index a38ed50b5..3fd44af06 100644 --- a/apps/electrochem/cic/+cic/+resultFiles/exportResults.m +++ b/apps/electrochem/cic/+cic/+resultFiles/exportResults.m @@ -11,7 +11,7 @@ choice = callbackContext.chooseOutputFile( ... ["*.csv", "CSV files"], "cic_results.csv"); if choice.Cancelled - callbackContext.appendStatus("CIC result export cancelled."); + callbackContext.log("info", "cic.resultfiles.exportresults.status", "CIC result export cancelled."); return end filepath = string(choice.Value); @@ -37,5 +37,6 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.lastExport = struct( ... "csvPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus("Exported CIC CSV: " + filepath); +callbackContext.log("info", "cic.resultfiles.exportresults.status", ... + "Exported CIC results."); end diff --git a/apps/electrochem/cic/+cic/+debug/writeSamplePack.m b/apps/electrochem/cic/+cic/+syntheticInputs/writeSamplePack.m similarity index 94% rename from apps/electrochem/cic/+cic/+debug/writeSamplePack.m rename to apps/electrochem/cic/+cic/+syntheticInputs/writeSamplePack.m index 20fd36337..626173f07 100644 --- a/apps/electrochem/cic/+cic/+debug/writeSamplePack.m +++ b/apps/electrochem/cic/+cic/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: App SDK runtime DebugSample lifecycle and unit tests. Input is a +% Expected caller: App SDK runtime SyntheticInputs lifecycle and unit tests. Input is a % LabKit debug context. Output is a deterministic synthetic chrono DTA sample % pack. Side effects: writes anonymous debug input files and records a session % manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write CIC debug chrono DTA files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end currentPath = sampleContext.samplePath("cic/representative.DTA"); @@ -18,7 +18,7 @@ project = cic.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "dta1", "chrono", currentPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-chrono", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("representative", "chrono", currentPath), ... @@ -113,7 +113,7 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("cic:debug:SampleWriteFailed", "Could not write debug sample file: %s.", filepath); + error("cic:syntheticInputs:SampleWriteFailed", "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/electrochem/cic/+cic/+workbench/buildLayout.m b/apps/electrochem/cic/+cic/+workbench/buildLayout.m index 4b59c378b..8524cbc9d 100644 --- a/apps/electrochem/cic/+cic/+workbench/buildLayout.m +++ b/apps/electrochem/cic/+cic/+workbench/buildLayout.m @@ -75,10 +75,7 @@ Tooltip="Export cathodic, anodic, and total injected charge or CIC together with pulse-window and safety results.")}), ... analysisSettings, plotDisplay, plotSelections}), ... labkit.app.layout.tab("summaryResults", "Summary + Results", { ... - summary, results}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; + summary, results})}; plots = labkit.app.layout.plotArea("plotAxes", ... @cic.analysisPlot.draw, Title="Plots", Layout="stack", ... AxisIds=["top", "bottom"], ... diff --git a/apps/electrochem/cic/+cic/definition.m b/apps/electrochem/cic/+cic/definition.m index 3708cb4fc..9ae130abe 100644 --- a/apps/electrochem/cic/+cic/definition.m +++ b/apps/electrochem/cic/+cic/definition.m @@ -3,10 +3,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CIC_app", AppId="cic", ... Title="Gamry CIC GUI (Voltage Transient)", DisplayName="CIC", ... - Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=cic.projectSpec(), CreateSession=@cic.createSession, ... Workbench=cic.workbench.buildLayout(), ... PresentWorkbench=@cic.workbench.present, ... - BuildDebugSample=@cic.debug.writeSamplePack); + BuildSyntheticSample=@cic.syntheticInputs.writeSamplePack); end diff --git a/apps/electrochem/csc/+csc/+resultFiles/exportResults.m b/apps/electrochem/csc/+csc/+resultFiles/exportResults.m index 6a835f69f..0b078f83d 100644 --- a/apps/electrochem/csc/+csc/+resultFiles/exportResults.m +++ b/apps/electrochem/csc/+csc/+resultFiles/exportResults.m @@ -10,7 +10,7 @@ choice = callbackContext.chooseOutputFile( ... ["*.csv", "CSV files"], "csc_all_cycles.csv"); if choice.Cancelled - callbackContext.appendStatus("CSC result export cancelled."); + callbackContext.log("info", "csc.resultfiles.exportresults.status", "CSC result export cancelled."); return end filepath = string(choice.Value); @@ -37,7 +37,8 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.lastResultsExport = struct( ... "csvPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus("Exported CSC CSV: " + filepath); +callbackContext.log("info", "csc.resultfiles.exportresults.status", ... + "Exported CSC results."); end function package = resultPackage( ... diff --git a/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m b/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m index 7d909d554..907c52e5c 100644 --- a/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m +++ b/apps/electrochem/csc/+csc/+resultFiles/exportVoltageCurrent.m @@ -11,7 +11,7 @@ choice = callbackContext.chooseOutputFile( ... ["*.csv", "CSV files"], "csc_cv_data.csv"); if choice.Cancelled - callbackContext.appendStatus( ... + callbackContext.log("info", "csc.resultfiles.exportvoltagecurrent.status", ... "Voltage/current export cancelled."); return end @@ -46,6 +46,6 @@ applicationState.project.results.lastVoltageCurrentExport = struct( ... "csvPaths", string(info.files), ... "manifestPath", string(written.Value)); -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "csc.resultfiles.exportvoltagecurrent.status", sprintf( ... "Exported %d CV data CSV file(s).", numel(info.files))); end diff --git a/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m b/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m index 88b922ed6..627120fbe 100644 --- a/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m +++ b/apps/electrochem/csc/+csc/+sourceFiles/reloadSelected.m @@ -22,5 +22,6 @@ csc.analysisRun.analysisChoices().allCycles; applicationState.project.results.lastResultsExport = []; applicationState.project.results.lastVoltageCurrentExport = []; -callbackContext.appendStatus("Reloaded: " + paths(index)); + callbackContext.log("info", "csc.sourcefiles.reloadselected.status", ... + "Reloaded the selected source."); end diff --git a/apps/electrochem/csc/+csc/+debug/writeSamplePack.m b/apps/electrochem/csc/+csc/+syntheticInputs/writeSamplePack.m similarity index 92% rename from apps/electrochem/csc/+csc/+debug/writeSamplePack.m rename to apps/electrochem/csc/+csc/+syntheticInputs/writeSamplePack.m index 15e17f047..b2cc01058 100644 --- a/apps/electrochem/csc/+csc/+debug/writeSamplePack.m +++ b/apps/electrochem/csc/+csc/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: CSC debug launch and unit tests. Input is a +% Expected caller: CSC synthetic-input generation and unit tests. Input is a % LabKit debug context. Output is a deterministic synthetic CV/CT DTA sample % pack. Side effects: writes anonymous debug input files and records a session % manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write CSC debug CV/CT DTA files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end cvPath = sampleContext.samplePath("csc/representative.DTA"); @@ -18,7 +18,7 @@ project = csc.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "dta1", "cvct", cvPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-cvct", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("representative", "cvct", cvPath), ... @@ -81,7 +81,7 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("csc:debug:SampleWriteFailed", "Could not write debug sample file: %s.", filepath); + error("csc:syntheticInputs:SampleWriteFailed", "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/electrochem/csc/+csc/+workbench/buildLayout.m b/apps/electrochem/csc/+csc/+workbench/buildLayout.m index 746edc94e..5d6242039 100644 --- a/apps/electrochem/csc/+csc/+workbench/buildLayout.m +++ b/apps/electrochem/csc/+csc/+workbench/buildLayout.m @@ -68,9 +68,7 @@ labkit.app.layout.tab("filesAnalysis", "Files + Analysis", { ... sourceSection, curveSection, plotSection}), ... labkit.app.layout.tab("summaryResults", "Summary + Results", { ... - comparison}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.logPanel("appLog")})}; + comparison})}; plot = labkit.app.layout.plotArea("plotAxes", ... @csc.analysisPlot.draw, Title="Plots", Layout="stack", ... AxisIds=["top", "bottom"], AxisTitles=["Top Plot", "Bottom Plot"], ... diff --git a/apps/electrochem/csc/+csc/definition.m b/apps/electrochem/csc/+csc/definition.m index d5be11605..0e3d865d2 100644 --- a/apps/electrochem/csc/+csc/definition.m +++ b/apps/electrochem/csc/+csc/definition.m @@ -3,10 +3,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CSC_app", AppId="csc", ... Title="Gamry DTA GUI (literature CSC)", DisplayName="CSC", ... - Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=csc.projectSpec(), CreateSession=@csc.createSession, ... Workbench=csc.workbench.buildLayout(), ... PresentWorkbench=@csc.workbench.present, ... - BuildDebugSample=@csc.debug.writeSamplePack); + BuildSyntheticSample=@csc.syntheticInputs.writeSamplePack); end diff --git a/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m b/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m index 28ce32db5..fb584e617 100644 --- a/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m +++ b/apps/electrochem/eis/+eis/+analysisRun/valuesForAxis.m @@ -2,42 +2,50 @@ % tests. Inputs are an EIS item struct and axis label. Output is the selected % numeric vector. No side effects. -function values = valuesForAxis(item, axisName) +function values = valuesForAxis(item, axisName, impedanceUnit) + if nargin < 3 + units = eis.impedanceDisplay.catalog(); + impedanceUnit = units.choices(3); + end items = eis.overlayPlot.axisItems(); + isImpedance = false; switch axisName case char(items(1)) - values = itemField(item, 'freq_Hz', 'Freq'); + values = item.freq_Hz; case char(items(2)) - values = log10(itemField(item, 'freq_Hz', 'Freq')); + values = log10(item.freq_Hz); case char(items(3)) - values = itemField(item, 'time_s', 'Time'); + values = item.time_s; case char(items(4)) - values = itemField(item, 'point', 'Pt'); + values = item.point; case char(items(5)) - values = itemField(item, 'Zreal_ohm', 'Zreal'); + values = item.Zreal_ohm; + isImpedance = true; case char(items(6)) - values = itemField(item, 'Zimag_ohm', 'Zimag'); + values = item.Zimag_ohm; + isImpedance = true; case char(items(7)) - values = itemField(item, 'negZimag_ohm', 'negZimag'); + values = item.negZimag_ohm; + isImpedance = true; case char(items(8)) - values = itemField(item, 'Zmod_ohm', 'Zmod'); + values = item.Zmod_ohm; + isImpedance = true; case char(items(9)) - values = itemField(item, 'Zphz_deg', 'Zphz'); + values = item.Zphz_deg; case char(items(10)) - values = itemField(item, 'Idc_A', 'Idc'); + values = item.Idc_A; case char(items(11)) - values = itemField(item, 'Vdc_V', 'Vdc'); + values = item.Vdc_V; otherwise error('Unsupported axis selection: %s', axisName); end -end - -function values = itemField(item, canonicalName, legacyName) - if isfield(item, canonicalName) && ~isempty(item.(canonicalName)) - values = item.(canonicalName); - elseif isfield(item, legacyName) && ~isempty(item.(legacyName)) - values = item.(legacyName); - else - values = []; + if isImpedance + units = eis.impedanceDisplay.catalog(); + index = find(string(impedanceUnit) == units.choices, 1); + if isempty(index) + error("eis:InvalidImpedanceUnit", ... + "Unsupported impedance display unit: %s.", impedanceUnit); + end + values = values ./ units.ohmsPerUnit(index); end end diff --git a/apps/electrochem/eis/+eis/+impedanceDisplay/catalog.m b/apps/electrochem/eis/+eis/+impedanceDisplay/catalog.m new file mode 100644 index 000000000..63574723b --- /dev/null +++ b/apps/electrochem/eis/+eis/+impedanceDisplay/catalog.m @@ -0,0 +1,16 @@ +% Expected callers: EIS project, layout, plotting, and export capabilities. +% Output is the single App-owned impedance display-unit catalog. +function units = catalog() +%CATALOG Return supported impedance display units and conversion metadata. +% +% Output: +% units - Scalar struct. choices contains UI labels, ohmsPerUnit converts +% base ohms to the selected display unit, and exportTokens contains +% ASCII unit suffixes for result column names. + +omega = string(char(hex2dec("03A9"))); +units = struct( ... + "choices", ["m" + omega, omega, "k" + omega, "M" + omega], ... + "ohmsPerUnit", [1e-3, 1, 1e3, 1e6], ... + "exportTokens", ["mohm", "ohm", "kohm", "Mohm"]); +end diff --git a/apps/electrochem/eis/+eis/+overlayPlot/axisItems.m b/apps/electrochem/eis/+eis/+overlayPlot/axisItems.m index 0fab99204..76c2b739d 100644 --- a/apps/electrochem/eis/+eis/+overlayPlot/axisItems.m +++ b/apps/electrochem/eis/+eis/+overlayPlot/axisItems.m @@ -3,6 +3,6 @@ function items = axisItems() items = [ ... "Freq (Hz)", "log10(Freq)", "Time (s)", "Point #", ... - "Zreal (ohm)", "Zimag (ohm)", "-Zimag (ohm)", ... - "Zmod (ohm)", "Zphz (deg)", "Idc (A)", "Vdc (V)"]; + "Zreal", "Zimag", "-Zimag", "Zmod", ... + "Zphz (deg)", "Idc (A)", "Vdc (V)"]; end diff --git a/apps/electrochem/eis/+eis/+overlayPlot/draw.m b/apps/electrochem/eis/+eis/+overlayPlot/draw.m index e45267450..b130afcf3 100644 --- a/apps/electrochem/eis/+eis/+overlayPlot/draw.m +++ b/apps/electrochem/eis/+eis/+overlayPlot/draw.m @@ -10,8 +10,10 @@ function draw(axesById, model) ax.XScale = scaleName(p.logX); ax.YScale = scaleName(p.logY); title(ax, 'EIS Overlay'); - xlabel(ax, eis.overlayPlot.labelForAxis(p.xName)); - ylabel(ax, eis.overlayPlot.labelForAxis(p.yName)); + xlabel(ax, eis.overlayPlot.labelForAxis( ... + p.xName, p.impedanceUnit)); + ylabel(ax, eis.overlayPlot.labelForAxis( ... + p.yName, p.impedanceUnit)); end if model.viewAction == "equal" labkit.app.plot.fitAxesToGraphics(ax, EqualDataUnits=true); diff --git a/apps/electrochem/eis/+eis/+overlayPlot/labelForAxis.m b/apps/electrochem/eis/+eis/+overlayPlot/labelForAxis.m index 47d932024..c5decbedc 100644 --- a/apps/electrochem/eis/+eis/+overlayPlot/labelForAxis.m +++ b/apps/electrochem/eis/+eis/+overlayPlot/labelForAxis.m @@ -1,6 +1,14 @@ % Expected caller: EIS app runner. Input is an axis selection label. Output is % the display label used by the app. No side effects. -function txt = labelForAxis(axisName) - txt = axisName; +function txt = labelForAxis(axisName, impedanceUnit) + if nargin < 2 + units = eis.impedanceDisplay.catalog(); + impedanceUnit = units.choices(3); + end + items = eis.overlayPlot.axisItems(); + txt = string(axisName); + if any(txt == items(5:8)) + txt = txt + " (" + string(impedanceUnit) + ")"; + end end diff --git a/apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m b/apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m index 3e48603d6..1f24dcb31 100644 --- a/apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m +++ b/apps/electrochem/eis/+eis/+overlayPlot/plotOverlay.m @@ -20,7 +20,8 @@ hold(ax, 'on'); plottedLines = gobjects(1, numel(items)); for k = 1:numel(items) - [x, y] = filteredXY(items(k), opts.xName, opts.yName, opts.logX, opts.logY); + [x, y] = filteredXY(items(k), opts.xName, opts.yName, ... + opts.impedanceUnit, opts.logX, opts.logY); plottedLines(k) = plot(ax, x, y, ... 'LineWidth', opts.lineWidth, ... 'Marker', marker, ... @@ -36,10 +37,12 @@ ax.YScale = ternary(opts.logY, 'log', 'linear'); labkit.app.plot.fitAxesToGraphics(ax, plottedLines(isgraphics(plottedLines))); - xlabel(ax, labelForAxis(opts.xName)); - ylabel(ax, labelForAxis(opts.yName)); + xLabel = eis.overlayPlot.labelForAxis(opts.xName, opts.impedanceUnit); + yLabel = eis.overlayPlot.labelForAxis(opts.yName, opts.impedanceUnit); + xlabel(ax, xLabel); + ylabel(ax, yLabel); title(ax, sprintf('%s vs %s (%d file%s)', ... - labelForAxis(opts.yName), labelForAxis(opts.xName), numel(items), pluralS(numel(items)))); + yLabel, xLabel, numel(items), pluralS(numel(items)))); if opts.showGrid grid(ax, 'on'); @@ -54,10 +57,6 @@ end end -function txt = labelForAxis(axisName) - txt = axisName; -end - function opts = fillPlotOptions(opts) items = eis.overlayPlot.axisItems(); if ~isfield(opts, 'xName') @@ -69,6 +68,10 @@ if ~isfield(opts, 'logX') opts.logX = false; end + if ~isfield(opts, 'impedanceUnit') + units = eis.impedanceDisplay.catalog(); + opts.impedanceUnit = units.choices(3); + end if ~isfield(opts, 'logY') opts.logY = false; end @@ -89,9 +92,10 @@ end end -function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) - x = eis.analysisRun.valuesForAxis(item, xName); - y = eis.analysisRun.valuesForAxis(item, yName); +function [x, y] = filteredXY( ... + item, xName, yName, impedanceUnit, useLogX, useLogY) + x = eis.analysisRun.valuesForAxis(item, xName, impedanceUnit); + y = eis.analysisRun.valuesForAxis(item, yName, impedanceUnit); valid = isfinite(x) & isfinite(y); x = x(valid); y = y(valid); diff --git a/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m b/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m index abf54aa24..6c458506e 100644 --- a/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m +++ b/apps/electrochem/eis/+eis/+resultFiles/buildExportTable.m @@ -2,11 +2,16 @@ % structs, axis labels, and log flags. Output is the stable EIS export table. % No file side effects. -function T = buildExportTable(items, xName, yName, useLogX, useLogY) +function T = buildExportTable( ... + items, xName, yName, impedanceUnit, useLogX, useLogY) if nargin < 4 - useLogX = false; + units = eis.impedanceDisplay.catalog(); + impedanceUnit = units.choices(3); end if nargin < 5 + useLogX = false; + end + if nargin < 6 useLogY = false; end @@ -15,7 +20,8 @@ yCell = cell(1, numel(items)); for i = 1:numel(items) - [x, y] = filteredXY(items(i), xName, yName, useLogX, useLogY); + [x, y] = filteredXY(items(i), xName, yName, ... + impedanceUnit, useLogX, useLogY); xCell{i} = x(:); yCell{i} = y(:); maxLen = max(maxLen, numel(x)); @@ -24,16 +30,19 @@ T = table((1:maxLen).', 'VariableNames', {'RowIndex'}); for i = 1:numel(items) safeName = matlab.lang.makeValidName(items(i).name); - xVar = matlab.lang.makeValidName(sprintf('X_%s_%s', sanitizeAxisName(xName), safeName)); - yVar = matlab.lang.makeValidName(sprintf('Y_%s_%s', sanitizeAxisName(yName), safeName)); + xVar = matlab.lang.makeValidName(sprintf('X_%s_%s', ... + exportAxisName(xName, impedanceUnit), safeName)); + yVar = matlab.lang.makeValidName(sprintf('Y_%s_%s', ... + exportAxisName(yName, impedanceUnit), safeName)); T.(xVar) = padWithNaN(xCell{i}, maxLen); T.(yVar) = padWithNaN(yCell{i}, maxLen); end end -function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) - x = eis.analysisRun.valuesForAxis(item, xName); - y = eis.analysisRun.valuesForAxis(item, yName); +function [x, y] = filteredXY( ... + item, xName, yName, impedanceUnit, useLogX, useLogY) + x = eis.analysisRun.valuesForAxis(item, xName, impedanceUnit); + y = eis.analysisRun.valuesForAxis(item, yName, impedanceUnit); valid = isfinite(x) & isfinite(y); x = x(valid); y = y(valid); @@ -61,3 +70,18 @@ out = regexprep(lower(txt), '[^a-z0-9]+', '_'); out = regexprep(out, '^_+|_+$', ''); end + +function out = exportAxisName(axisName, impedanceUnit) +out = sanitizeAxisName(axisName); +items = eis.overlayPlot.axisItems(); +if ~any(string(axisName) == items(5:8)) + return +end +units = eis.impedanceDisplay.catalog(); +index = find(string(impedanceUnit) == units.choices, 1); +if isempty(index) + error("eis:InvalidImpedanceUnit", ... + "Unsupported impedance display unit: %s.", impedanceUnit); +end +out = sprintf("%s_%s", out, units.exportTokens(index)); +end diff --git a/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m b/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m index 58f986789..d2fc02496 100644 --- a/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m +++ b/apps/electrochem/eis/+eis/+resultFiles/exportCurrentPlot.m @@ -19,12 +19,14 @@ end path = string(chosen.Value); p = state.project.parameters; -tableValue = eis.resultFiles.buildExportTable(items, p.xName, p.yName, p.logX, p.logY); +tableValue = eis.resultFiles.buildExportTable(items, p.xName, p.yName, ... + p.impedanceUnit, p.logX, p.logY); writetable(tableValue, path); [folder, base, extension] = fileparts(path); output = labkit.app.result.File("eisPlotData", "primary", string(base) + string(extension), MediaType="text/csv"); package = labkit.app.result.Package(Outputs={output}, Inputs=struct("sources", state.project.inputs.sources), Parameters=p, Summary=struct("fileCount", numel(items))); written = context.writeResultPackage(folder, package); state.project.results.lastExport = struct("csvPath", path, "manifestPath", string(written.Value)); -context.appendStatus("Exported CSV: " + path); +context.log("info", "eis.resultfiles.exportcurrentplot.status", ... + "Exported the current EIS plot data."); end diff --git a/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m b/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m index f3411f510..6f6a40f0f 100644 --- a/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m +++ b/apps/electrochem/eis/+eis/+sourceFiles/buildSummary.m @@ -5,7 +5,7 @@ summary = cell(0, 1); summary{end+1} = sprintf('Loaded files: %d', numel(items)); for i = 1:numel(items) - freq = itemField(items(i), 'freq_Hz', 'Freq'); + freq = items(i).freq_Hz; fmin = min(freq, [], 'omitnan'); fmax = max(freq, [], 'omitnan'); summary{end+1} = sprintf('%s | N=%d | Freq %.4g to %.4g Hz | order: %s', ... @@ -13,16 +13,6 @@ end end -function values = itemField(item, canonicalName, legacyName) - if isfield(item, canonicalName) && ~isempty(item.(canonicalName)) - values = item.(canonicalName); - elseif isfield(item, legacyName) && ~isempty(item.(legacyName)) - values = item.(legacyName); - else - values = []; - end -end - function txt = ternary(cond, a, b) if cond txt = a; diff --git a/apps/electrochem/eis/+eis/+debug/writeSamplePack.m b/apps/electrochem/eis/+eis/+syntheticInputs/writeSamplePack.m similarity index 91% rename from apps/electrochem/eis/+eis/+debug/writeSamplePack.m rename to apps/electrochem/eis/+eis/+syntheticInputs/writeSamplePack.m index d84c06a39..ba6b02cf3 100644 --- a/apps/electrochem/eis/+eis/+debug/writeSamplePack.m +++ b/apps/electrochem/eis/+eis/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: EIS debug launch and unit tests. Input is a +% Expected caller: EIS synthetic-input generation and unit tests. Input is a % LabKit debug context. Output is a deterministic synthetic EIS DTA sample % pack. Side effects: writes anonymous debug input files and records a session % manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write EIS debug ZCURVE DTA files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end eisPath = sampleContext.samplePath("eis/representative.DTA"); @@ -18,7 +18,7 @@ project = eis.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "dta1", "eis", eisPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-impedance", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("representative", "eis", eisPath), ... @@ -77,7 +77,7 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("eis:debug:SampleWriteFailed", "Could not write debug sample file: %s.", filepath); + error("eis:syntheticInputs:SampleWriteFailed", "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/electrochem/eis/+eis/+workbench/buildLayout.m b/apps/electrochem/eis/+eis/+workbench/buildLayout.m index c0c455a56..74a532e3c 100644 --- a/apps/electrochem/eis/+eis/+workbench/buildLayout.m +++ b/apps/electrochem/eis/+eis/+workbench/buildLayout.m @@ -2,6 +2,7 @@ function layout = buildLayout() %BUILDLAYOUT Assemble EIS file-bound overlay controls. axes = eis.overlayPlot.axisItems(); +units = eis.impedanceDisplay.catalog(); files = labkit.app.layout.fileList("files", Label="Files", ... Filters=["*.DTA;*.dta", "Gamry DTA (*.DTA)", "*.*", "All files"], ... ChooseLabel="Add DTA files", FolderLabel="Add folder", ... @@ -19,6 +20,9 @@ plotOptions = labkit.app.layout.section("plotOptions", "Plot Options", { ... labkit.app.layout.field("xAxis", Label="X axis", Kind="choice", Choices=axes, Bind="project.parameters.xName"), ... labkit.app.layout.field("yAxis", Label="Y axis", Kind="choice", Choices=axes, Bind="project.parameters.yName"), ... + labkit.app.layout.field("impedanceUnit", Label="Impedance unit", ... + Kind="choice", Choices=units.choices, ... + Bind="project.parameters.impedanceUnit"), ... labkit.app.layout.slider("lineWidth", Label="Line width", Limits=[.1 10], Step=.1, Bind="project.parameters.lineWidth"), ... labkit.app.layout.slider("markerSize", Label="Marker size", Limits=[1 20], Step=1, Bind="project.parameters.markerSize"), ... labkit.app.layout.field("showMarkers", Label="Show markers", Kind="logical", Bind="project.parameters.showMarkers"), ... @@ -36,12 +40,13 @@ labkit.app.layout.statusPanel("summary", Title="Summary")}); layout = labkit.app.layout.workbench({ ... labkit.app.layout.tab("filesAnalysis", "Files + Analysis", {filesSection, plotOptions}), ... - labkit.app.layout.tab("summaryResults", "Summary + Results", {summary}), ... - labkit.app.layout.tab("log", "Log", {labkit.app.layout.logPanel("appLog")})}, ... + labkit.app.layout.tab("summaryResults", "Summary + Results", {summary})}, ... Workspace=labkit.app.layout.workspace( ... labkit.app.layout.plotArea("plot", @eis.overlayPlot.draw, ... Title="EIS Overlay", AxisIds="main", ... - AxisTitles="EIS Overlay", XLabels=axes(5), YLabels=axes(7)), ... + AxisTitles="EIS Overlay", ... + XLabels="Zreal (" + units.choices(3) + ")", ... + YLabels="-Zimag (" + units.choices(3) + ")"), ... Title="Plot"), ... Usage=usageLines()); end @@ -50,7 +55,7 @@ lines = [ ... "Usage:"; ... "1. Open one or more EIS .DTA files containing ZCURVE."; ... - "2. Choose any X and Y axis combination."; ... + "2. Choose any X/Y combination and the impedance display unit."; ... "3. Use Zreal vs -Zimag for a Nyquist plot."; ... "4. Use Freq vs Zmod or Zphz for Bode-style plots."; ... "5. CSV export writes one shared row index with X/Y pairs per file."]; diff --git a/apps/electrochem/eis/+eis/definition.m b/apps/electrochem/eis/+eis/definition.m index 2da2879ad..77d5c17b8 100644 --- a/apps/electrochem/eis/+eis/definition.m +++ b/apps/electrochem/eis/+eis/definition.m @@ -3,10 +3,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_EIS_app", AppId="eis", ... Title="Gamry EIS Multi-DTA Plot GUI", DisplayName="EIS Overlay", ... - Family="Electrochem", AppVersion="1.5.2", Updated="2026-07-21", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=eis.projectSpec(), CreateSession=@eis.createSession, ... Workbench=eis.workbench.buildLayout(), ... PresentWorkbench=@eis.workbench.present, ... - BuildDebugSample=@eis.debug.writeSamplePack); + BuildSyntheticSample=@eis.syntheticInputs.writeSamplePack); end diff --git a/apps/electrochem/eis/+eis/projectSpec.m b/apps/electrochem/eis/+eis/projectSpec.m index 07eade364..8069f0c6a 100644 --- a/apps/electrochem/eis/+eis/projectSpec.m +++ b/apps/electrochem/eis/+eis/projectSpec.m @@ -2,17 +2,20 @@ % Expected caller: eis.definition. Output owns the current payload version, % creation defaults, and validation. Side effects are none. function spec = projectSpec() - spec = labkit.app.project.Schema(Version=1, ... - Create=@createProject, Validate=@validateProject); + spec = labkit.app.project.Schema(Version=2, ... + Create=@createProject, Validate=@validateProject, ... + Migrate=@migrateProject); end function project = createProject() axes = eis.overlayPlot.axisItems(); + units = eis.impedanceDisplay.catalog(); project = struct(); project.inputs = struct("sources", ... struct([])); project.parameters = struct( ... "xName", axes(5), "yName", axes(7), ... + "impedanceUnit", units.choices(3), ... "lineWidth", 1.4, "markerSize", 6, ... "showMarkers", true, "logX", false, "logY", false, ... "showLegend", true, "showGrid", true); @@ -26,11 +29,14 @@ 'eis:InvalidProject', 'EIS project sources are missing.'); p = project.parameters; fields = ["xName", "yName", "lineWidth", "markerSize", ... - "showMarkers", "logX", "logY", "showLegend", "showGrid"]; + "impedanceUnit", "showMarkers", "logX", "logY", ... + "showLegend", "showGrid"]; axes = eis.overlayPlot.axisItems(); + units = eis.impedanceDisplay.catalog(); assert(isstruct(p) && isscalar(p) && ... all(isfield(p, cellstr(fields))) && ... any(string(p.xName) == axes) && any(string(p.yName) == axes) && ... + any(string(p.impedanceUnit) == units.choices) && ... finiteScalar(p.lineWidth, 0.1, 10) && ... finiteScalar(p.markerSize, 1, 20) && ... logicalScalar(p.showMarkers) && logicalScalar(p.logX) && ... @@ -43,6 +49,23 @@ accepted = true; end +function project = migrateProject(project, fromVersion) +switch double(fromVersion) + case 1 + project.parameters.xName = unitNeutralAxis(project.parameters.xName); + project.parameters.yName = unitNeutralAxis(project.parameters.yName); + units = eis.impedanceDisplay.catalog(); + project.parameters.impedanceUnit = units.choices(2); + otherwise + error("eis:UnsupportedProjectMigration", ... + "EIS cannot migrate project version %d.", fromVersion); +end +end + +function name = unitNeutralAxis(name) +name = erase(string(name), " (ohm)"); +end + function tf = finiteScalar(value, minimum, maximum) tf = isnumeric(value) && isscalar(value) && isfinite(double(value)) && ... double(value) >= minimum && double(value) <= maximum; diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m index ed594f95a..223921995 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisPlot/draw.m @@ -72,9 +72,9 @@ function drawOne(ax, model) "cathBaseStart", "cathBaseEnd", "anodBaseStart", "anodBaseEnd", ... "cathSteadyStart", "cathSteadyEnd", ... "anodSteadyStart", "anodSteadyEnd"]; - times = [a.pulse.cath_start, a.pulse.cath_end, ... - a.pulse.anod_start, a.pulse.anod_end, ... - a.pulse.pre_start, a.pulse.pre_end, ... + times = [a.pulse.cath.start_s, a.pulse.cath.end_s, ... + a.pulse.anod.start_s, a.pulse.anod.end_s, ... + a.pulse.pre.start_s, a.pulse.pre.end_s, ... a.anodBaselineStart, a.anodBaselineEnd, ... a.cathSteadyStart, a.cathSteadyEnd, ... a.anodSteadyStart, a.anodSteadyEnd]; diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m index 08c856ca5..19077f753 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/computeResistance.m @@ -155,8 +155,8 @@ return; end - [cStart, cEnd] = selectSteadyWindow(pulse.cath_start, pulse.cath_end, A.windowMode); - [aStart, aEnd] = selectSteadyWindow(pulse.anod_start, pulse.anod_end, A.windowMode); + [cStart, cEnd] = selectSteadyWindow(pulse.cath.start_s, pulse.cath.end_s, A.windowMode); + [aStart, aEnd] = selectSteadyWindow(pulse.anod.start_s, pulse.anod.end_s, A.windowMode); cathMask = t >= cStart & t <= cEnd; anodMask = t >= aStart & t <= aEnd; if nnz(cathMask) < 2 || nnz(anodMask) < 2 @@ -176,14 +176,14 @@ A.Vc_ss_V = median(Vf(cathMask), 'omitnan'); A.Va_ss_V = median(Vf(anodMask), 'omitnan'); - A.cathBaselineStart = pulse.pre_start; - A.cathBaselineEnd = pulse.pre_end; - A.anodBaselineStart = pulse.post_start; - A.anodBaselineEnd = pulse.post_end; + A.cathBaselineStart = pulse.pre.start_s; + A.cathBaselineEnd = pulse.pre.end_s; + A.anodBaselineStart = pulse.post.start_s; + A.anodBaselineEnd = pulse.post.end_s; [A.Vc_baseline_V, A.cathBaselineWindow_s] = estimateBaseline( ... - t, Vf, pulse.pre_start, pulse.pre_end, 0); + t, Vf, pulse.pre.start_s, pulse.pre.end_s, 0); [A.Va_baseline_V, A.anodBaselineWindow_s] = estimateBaseline( ... - t, Vf, pulse.post_start, pulse.post_end, chooseFinite(A.Vc_baseline_V, 0)); + t, Vf, pulse.post.start_s, pulse.post.end_s, chooseFinite(A.Vc_baseline_V, 0)); A.dVc_V = A.Vc_ss_V - A.Vc_baseline_V; A.dVa_V = A.Va_ss_V - A.Va_baseline_V; diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m index 641098123..de2ccefea 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+analysisRun/settingsChanged.m @@ -12,6 +12,6 @@ applicationState.session.cache.items = ... vt_resistance.analysisRun.recomputeItems(items, options); applicationState.project.results.lastExport = []; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "vt_resistance.analysisrun.settingschanged.status", sprintf( ... "Reanalyzed %d loaded VT file(s).", numel(items))); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m index 216566a82..4d4e1625f 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+resultFiles/exportResults.m @@ -10,7 +10,7 @@ choice = callbackContext.chooseOutputFile( ... ["*.csv", "CSV files"], "vt_steady_resistance_results.csv"); if choice.Cancelled - callbackContext.appendStatus("VT result export cancelled."); + callbackContext.log("info", "vt_resistance.resultfiles.exportresults.status", "VT result export cancelled."); return end filepath = string(choice.Value); @@ -31,5 +31,7 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.lastExport = struct( ... "csvPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus("Exported VT CSV: " + filepath); +callbackContext.log("info", ... + "vt_resistance.resultfiles.exportresults.status", ... + "Exported VT results."); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m b/apps/electrochem/vt_resistance/+vt_resistance/+syntheticInputs/writeSamplePack.m similarity index 94% rename from apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m rename to apps/electrochem/vt_resistance/+vt_resistance/+syntheticInputs/writeSamplePack.m index 17f998ed5..8e4773e08 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+debug/writeSamplePack.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: VT Resistance definition debug sample and unit tests. +% Expected caller: VT Resistance definition synthetic input and unit tests. % Input is a LabKit debug context. Output is a deterministic synthetic chrono % DTA sample pack. Side effects: writes anonymous debug input files and records % a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write VT resistance debug chrono DTA files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end currentPath = sampleContext.samplePath("vt_resistance/representative.DTA"); @@ -18,7 +18,7 @@ project = vt_resistance.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "dta1", "chrono", currentPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-resistance", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("representative", "chrono", currentPath), ... @@ -118,7 +118,7 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("vt_resistance:debug:SampleWriteFailed", "Could not write debug sample file: %s.", filepath); + error("vt_resistance:syntheticInputs:SampleWriteFailed", "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m index 633d89b05..8df66b486 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+workbench/buildLayout.m @@ -57,9 +57,7 @@ Tooltip="Export cathodic, anodic, and averaged voltage-transient resistance results with detection diagnostics.")}), ... analysis, display, plotSelections}), ... labkit.app.layout.tab("summaryResults", "Summary + Results", { ... - summary, results}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.logPanel("appLog")})}; + summary, results})}; plots = labkit.app.layout.plotArea("plotAxes", ... @vt_resistance.analysisPlot.draw, Title="Plots", Layout="stack", ... AxisIds=["top", "bottom"], AxisTitles=["Top Plot", "Bottom Plot"]); diff --git a/apps/electrochem/vt_resistance/+vt_resistance/definition.m b/apps/electrochem/vt_resistance/+vt_resistance/definition.m index 9c67f68a8..5fe5d068a 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/definition.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/definition.m @@ -3,10 +3,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VTResistance_app", AppId="vt_resistance", ... Title="VT Steady Resistance", DisplayName="VT Resistance", ... - Family="Electrochem", AppVersion="1.5.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "dta", ">=2.0 <3"), ... + Family="Electrochem", AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "dta", ">=3 <4"), ... ProjectSchema=vt_resistance.projectSpec(), CreateSession=@vt_resistance.createSession, ... Workbench=vt_resistance.workbench.buildLayout(), ... PresentWorkbench=@vt_resistance.workbench.present, ... - BuildDebugSample=@vt_resistance.debug.writeSamplePack); + BuildSyntheticSample=@vt_resistance.syntheticInputs.writeSamplePack); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m index cbbddc71e..e77e38b03 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m +++ b/apps/gait/gait_analysis/+gait_analysis/+analysisRun/runFromWorkbench.m @@ -16,16 +16,19 @@ state.session.cache.filepath, pose, options); if state.project.results.analysis.ok && ... state.session.cache.lastRunFingerprint == task.fingerprint - context.appendStatus( ... + context.log("info", "gait_analysis.analysisrun.runfromworkbench.status", ... "Gait analysis is already up to date; skipped duplicate run."); return end try result = gait_analysis.analysisRun.computeGait(pose, options); catch cause - context.reportError("Gait analysis failed", cause); + context.log("error", "gait_analysis.analysisrun.runfromworkbench.exception", "Gait analysis failed", ... + Category="failure", Audience="developer", Exception=cause); context.alert(cause.message, "Gait analysis failed"); - context.appendStatus("Gait analysis failed: " + cause.message); + context.log("info", ... + "gait_analysis.analysisrun.runfromworkbench.status", ... + "Gait analysis failed."); return end state.project.parameters = options; @@ -33,6 +36,6 @@ state.project.results.lastExport = []; state.session.cache.lastRunFingerprint = task.fingerprint; state.session.selection.currentStepIndex = 1; -context.appendStatus(sprintf("Gait analysis complete: %d valid step(s).", ... +context.log("info", "gait_analysis.analysisrun.runfromworkbench.status", sprintf("Gait analysis complete: %d valid step(s).", ... sum(result.stepTable.is_valid))); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m index d785fa716..094248571 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/chooseFolder.m @@ -4,10 +4,10 @@ choice = callbackContext.chooseOutputFolder( ... applicationState.session.workflow.outputFolder); if choice.Cancelled - callbackContext.appendStatus("Output folder selection cancelled."); + callbackContext.log("info", "gait_analysis.resultfiles.choosefolder.status", "Output folder selection cancelled."); return end applicationState.session.workflow.outputFolder = string(choice.Value); -callbackContext.appendStatus( ... - "Output folder: " + string(choice.Value)); +callbackContext.log("info", "gait_analysis.resultfiles.choosefolder.status", ... + "Selected the gait output folder."); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m index 94c481346..16fc4feb1 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m +++ b/apps/gait/gait_analysis/+gait_analysis/+resultFiles/exportResults.m @@ -13,7 +13,7 @@ choice = callbackContext.chooseOutputFolder( ... fileparts(applicationState.session.cache.filepath)); if choice.Cancelled - callbackContext.appendStatus("Gait export cancelled."); + callbackContext.log("info", "gait_analysis.resultfiles.exportresults.status", "Gait export cancelled."); return end folder = string(choice.Value); @@ -26,7 +26,8 @@ try outputs = gait_analysis.resultFiles.writeOutputs(folder, stem, result); catch exception - callbackContext.reportError("Gait export failed", exception); + callbackContext.log("error", "gait_analysis.resultfiles.exportresults.exception", "Gait export failed", ... + Category="failure", Audience="developer", Exception=exception); callbackContext.alert(exception.message, ... "Could not export gait CSV files"); return @@ -45,8 +46,8 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.lastExport = struct( ... "outputs", outputs, "manifestPath", string(written.Value)); -callbackContext.appendStatus( ... - "Exported gait CSV set and manifest: " + string(written.Value)); +callbackContext.log("info", "gait_analysis.resultfiles.exportresults.status", ... + "Exported the gait CSV set and manifest."); end function file = resultFile(id, filepath) diff --git a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m index d71ccbda3..01b61181f 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m +++ b/apps/gait/gait_analysis/+gait_analysis/+sourceFiles/adoptPose.m @@ -19,6 +19,6 @@ applicationState.project.parameters = ... gait_analysis.analysisRun.optionsForPose( ... pose, applicationState.project.parameters); -callbackContext.appendStatus( ... - "Loaded pose file: " + applicationState.session.cache.filepath); +callbackContext.log("info", "gait_analysis.sourcefiles.adoptpose.status", ... + "Loaded the selected pose project."); end diff --git a/apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m b/apps/gait/gait_analysis/+gait_analysis/+syntheticInputs/writeSamplePack.m similarity index 95% rename from apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m rename to apps/gait/gait_analysis/+gait_analysis/+syntheticInputs/writeSamplePack.m index acdca16a6..72a41fc01 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+debug/writeSamplePack.m +++ b/apps/gait/gait_analysis/+gait_analysis/+syntheticInputs/writeSamplePack.m @@ -3,7 +3,7 @@ % path, sample identifier, or lab data. function pack = writeSamplePack(sampleContext) arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end project = syntheticVideoMarkerPayload(); labkitProject = struct( ... @@ -19,7 +19,7 @@ initialProject = gait_analysis.projectSpec().Create(); initialProject.inputs.sources = sampleContext.sourceRecord( ... "pose1", "poseCoordinates", posePath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-pose-coordinates", ... InitialProject=initialProject, ... Artifacts={sampleContext.artifact( ... diff --git a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m index 0f2d42e77..286d66671 100644 --- a/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m +++ b/apps/gait/gait_analysis/+gait_analysis/+workbench/buildLayout.m @@ -11,9 +11,6 @@ results = labkit.app.layout.tab( ... "results", "Results + Export", ... [review, {gait_analysis.resultFiles.layoutSection()}]); -log = labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})}); preview = gait_analysis.gaitPreview.layoutArea(); usage = [ ... "1. Open a current Video Marker project or autosave MAT.", ... @@ -22,7 +19,7 @@ "4. Run analysis, then select one step to review its skeleton, angles, lengths, and translations.", ... "5. Export coordinates include raw pixel columns plus optional scaled/origin-shifted columns."]; layout = labkit.app.layout.workbench( ... - {source, options, results, log}, ... + {source, options, results}, ... Workspace=labkit.app.layout.workspace( ... preview, Title="Gait Preview"), ... UsageTitle="Workflow Notes", Usage=usage); diff --git a/apps/gait/gait_analysis/+gait_analysis/definition.m b/apps/gait/gait_analysis/+gait_analysis/definition.m index bd9960a1d..fb70dc7fc 100644 --- a/apps/gait/gait_analysis/+gait_analysis/definition.m +++ b/apps/gait/gait_analysis/+gait_analysis/definition.m @@ -3,10 +3,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_GaitAnalysis_app", AppId="gait_analysis", ... Title="Gait Analysis", DisplayName="Gait Analysis", Family="Gait", ... - AppVersion="2.1.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + AppVersion="2.2.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=gait_analysis.projectSpec(), CreateSession=@gait_analysis.createSession, ... Workbench=gait_analysis.workbench.buildLayout(), ... PresentWorkbench=@gait_analysis.workbench.present, ... - BuildDebugSample=@gait_analysis.debug.writeSamplePack); + BuildSyntheticSample=@gait_analysis.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m index ce96f1111..02b406911 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCenterFromPreview.m @@ -3,8 +3,16 @@ applicationState, value, callbackContext) [applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... applicationState, callbackContext); -if ~loaded || ~isstruct(value) || ~isfield(value, "points") || ... - isempty(value.points) || size(value.points, 2) ~= 2 +if ~loaded + return +end +if isstruct(value) && isfield(value, "points") && ... + ~isempty(value.points) && size(value.points, 2) == 2 + point = double(value.points(1, :)); +elseif isnumeric(value) && numel(value) == 2 && ... + all(isfinite(double(value))) + point = double(reshape(value, 1, [])); +else return end index = batch_crop.sourceFiles.currentIndex(applicationState); @@ -12,12 +20,12 @@ [geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... applicationState.session.cache.canvas, index, item, ... batch_crop.cropGeometry.itemPaddingPercent(item, 0)); -center = batch_crop.cropGeometry.canvasToOriginal( ... - geometry, double(value.points(1, :))); +center = batch_crop.cropGeometry.canvasToOriginal(geometry, point); applicationState = batch_crop.cropGeometry.setCurrentCenter( ... applicationState, center, true); applicationState = batch_crop.cropGeometry.clearDerived(applicationState); -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", ... + "batch_crop.cropgeometry.changecenterfrompreview.status", sprintf( ... "Placed crop center for task %d: x=%.1f, y=%.1f.", index, ... applicationState.project.inputs.items(index).centerXY)); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m index 0b78c1b18..27b29fbc5 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCoordinate.m @@ -12,7 +12,7 @@ applicationState = batch_crop.cropGeometry.setCurrentCenter( ... applicationState, center, true); applicationState = batch_crop.cropGeometry.clearDerived(applicationState); -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "batch_crop.cropgeometry.changecoordinate.status", sprintf( ... "Set crop center for task %d: x=%.1f, y=%.1f.", index, ... applicationState.project.inputs.items(index).centerXY)); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCropRectangle.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCropRectangle.m new file mode 100644 index 000000000..439248711 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/changeCropRectangle.m @@ -0,0 +1,32 @@ +% App-owned managed-ROI callback. Expected caller: the Batch Crop cropRoi +% interaction. Inputs are application state, a canvas [x y width height] +% position, and callback context. The returned state stores only the moved +% original-image crop center and clears derived export state. +function applicationState = changeCropRectangle( ... + applicationState, position, callbackContext) +%CHANGECROPRECTANGLE Move the current crop center from a dragged preview ROI. + +[applicationState, loaded] = batch_crop.sourceFiles.loadCurrent( ... + applicationState, callbackContext); +if ~loaded || ~(isnumeric(position) && numel(position) == 4) || ... + any(~isfinite(double(position))) + return +end +index = batch_crop.sourceFiles.currentIndex(applicationState); +item = batch_crop.sourceFiles.currentItem(applicationState); +[geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + applicationState.session.cache.canvas, index, item, ... + batch_crop.cropGeometry.itemPaddingPercent(item, 0)); +currentPosition = batch_crop.cropGeometry.cropRectanglePosition( ... + geometry, item.centerXY, batch_crop.cropGeometry.currentCropSize(applicationState)); +canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, item.centerXY); +canvasCenter = canvasCenter + double(position(1:2)) - currentPosition(1:2); +center = batch_crop.cropGeometry.canvasToOriginal(geometry, canvasCenter); +applicationState = batch_crop.cropGeometry.setCurrentCenter( ... + applicationState, center, true); +applicationState = batch_crop.cropGeometry.clearDerived(applicationState); +callbackContext.log("info", ... + "batch_crop.cropgeometry.changecroprectangle.status", sprintf( ... + "Moved crop ROI for task %d: x=%.1f, y=%.1f.", index, ... + applicationState.project.inputs.items(index).centerXY)); +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropRectanglePosition.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropRectanglePosition.m new file mode 100644 index 000000000..e4d42e234 --- /dev/null +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/cropRectanglePosition.m @@ -0,0 +1,14 @@ +% App-owned crop-ROI geometry helper. Expected callers: Batch Crop preview +% presentation and managed-ROI callbacks. Inputs are a prepared canvas, +% original-image center, and [width height] crop size. Output is one finite +% [x y width height] canvas rectangle; it has no state or graphics effects. +function position = cropRectanglePosition(geometry, center, cropSize) +%CROPRECTANGLEPOSITION Return the preview-canvas crop rectangle position. + +scale = batch_crop.cropGeometry.geometryScale(geometry); +width = max(1, double(cropSize(1)) * scale); +height = max(1, double(cropSize(2)) * scale); +canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, center); +position = [round(canvasCenter(1) - (width - 1) / 2) - 0.5, ... + round(canvasCenter(2) - (height - 1) / 2) - 0.5, width, height]; +end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/currentGeometry.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/currentGeometry.m index 8165d930f..677abc6d5 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/currentGeometry.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/currentGeometry.m @@ -8,9 +8,9 @@ geometry = cache.geometry; return; end - % Constant: 1.2 megapixels balances draggable crop responsiveness with - % enough preview detail to place the crop accurately. - previewCanvasPixels = 1.2e6; + % Constant: images through 12 megapixels retain native preview pixels; + % larger inputs are sampled to keep interaction responsive. + previewCanvasPixels = 12e6; geometry = batch_crop.cropGeometry.prepareCropCanvas(item.image, struct( ... 'angleDeg', item.angleDeg, ... 'paddingPercent', paddingPercent, ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m index f22dc28b6..98b0cbd2a 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/paddingChanged.m @@ -16,7 +16,7 @@ applicationState = batch_crop.cropGeometry.ensureCurrentCenter(applicationState); applicationState = batch_crop.cropGeometry.clearDerived(applicationState, true); applicationState.session.view.scaleBar = []; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "batch_crop.cropgeometry.paddingchanged.status", sprintf( ... "Updated padding for crop task %d: %.3g%%.", index, ... applicationState.project.inputs.items(index).paddingPercent)); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m index 7ebf686f6..c866c912e 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/present.m @@ -25,7 +25,7 @@ centerYLimits = [1 - double(geometry.padding.top) / scale, ... double(geometry.sourceHeight) + ... double(geometry.padding.bottom) / scale]; - cropUpper = max(1, ceil(2 .* hypot( ... + cropUpper = max(cropUpper, ceil(2 .* hypot( ... double(size(item.image, 2)), double(size(item.image, 1))))); end view = labkit.app.view.Snapshot() ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m index 5479e52c2..44211d4ad 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/rotationChanged.m @@ -13,7 +13,7 @@ applicationState = batch_crop.cropGeometry.ensureCurrentCenter(applicationState); applicationState = batch_crop.cropGeometry.clearDerived(applicationState, true); applicationState.session.view.scaleBar = []; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "batch_crop.cropgeometry.rotationchanged.status", sprintf( ... "Updated rotation for crop task %d: %.3g deg.", index, ... applicationState.project.inputs.items(index).angleDeg)); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m index 1ab61760b..a3ce69cf4 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropGeometry/useSourceCenter.m @@ -22,5 +22,6 @@ applicationState = batch_crop.cropGeometry.setCurrentCenter( ... applicationState, center, true); applicationState = batch_crop.cropGeometry.clearDerived(applicationState); -callbackContext.appendStatus("Set crop " + upper(mode) + " center."); +callbackContext.log("info", "batch_crop.cropgeometry.usesourcecenter.status", ... + "Set crop " + upper(mode) + " center."); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m index e5555d99c..ad1512df2 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/draw.m @@ -20,14 +20,8 @@ function draw(axesById, model) axis(ax, 'image'); ax.YDir = 'reverse'; hold(ax, 'on'); - center = model.center; - plot(ax, [center(1) - 16, center(1) + 16], [center(2), center(2)], ... - 'Color', [0 0.85 1], 'LineWidth', 1.25, ... - 'HitTest', 'off', 'PickableParts', 'none'); - plot(ax, [center(1), center(1)], [center(2) - 16, center(2) + 16], ... - 'Color', [0 0.85 1], 'LineWidth', 1.25, ... - 'HitTest', 'off', 'PickableParts', 'none'); - drawCropRoi(ax, model.cropRectangle); + drawCropCenter(ax, model.center); + drawCropRoiLabel(ax, model.cropRectangle); drawScaleBar(ax, model.scaleBar); hold(ax, 'off'); title(ax, char(model.title)); @@ -36,21 +30,33 @@ function draw(axesById, model) box(ax, 'on'); end -function drawCropRoi(ax, position) - if numel(position) ~= 4 || any(~isfinite(position)) || ... - any(position(3:4) <= 0) +function drawCropCenter(ax, center) + if numel(center) ~= 2 || any(~isfinite(center)) return; end color = [1 0.9 0.15]; - rectangle(ax, 'Position', position, 'EdgeColor', color, ... - 'LineStyle', '-', 'LineWidth', 2, ... + plot(ax, center(1), center(2), 'o', ... + 'MarkerSize', 9, 'LineWidth', 1.5, ... + 'MarkerEdgeColor', color, 'MarkerFaceColor', 'none', ... 'HitTest', 'off', 'PickableParts', 'none'); - text(ax, position(1), max(0.5, position(2) - 3), 'Crop ROI', ... + text(ax, center(1) + 10, center(2) - 10, 'Crop center', ... 'Color', color, 'FontWeight', 'bold', ... 'BackgroundColor', [0 0 0], 'Margin', 2, ... 'HitTest', 'off', 'PickableParts', 'none'); end +function drawCropRoiLabel(ax, position) + if numel(position) ~= 4 || any(~isfinite(position)) || ... + any(position(3:4) <= 0) + return; + end + text(ax, position(1), max(0.5, position(2) - 3), ... + 'Crop ROI — drag center or inside box', ... + 'Color', [1 0.9 0.15], 'FontWeight', 'bold', ... + 'BackgroundColor', [0 0 0], 'Margin', 2, ... + 'HitTest', 'off', 'PickableParts', 'none'); +end + function drawScaleBar(ax, scaleBar) if isempty(scaleBar) return; diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m index b0ef77ad2..77298c5d3 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/present.m @@ -6,12 +6,9 @@ editing = hasImage && ... applicationState.session.workflow.scaleReferenceEditing; imageSize = [1 1]; -centerValue = zeros(0, 2); reference = zeros(0, 2); if hasImage imageSize = [size(geometry.canvas, 1), size(geometry.canvas, 2)]; - centerValue = batch_crop.cropGeometry.originalToCanvas( ... - geometry, item.centerXY); reference = item.scaleCalibration.referenceLine; for k = 1:size(reference, 1) reference(k, :) = batch_crop.cropGeometry.originalToCanvas( ... @@ -20,7 +17,7 @@ end view = labkit.app.view.Snapshot() ... .renderPlot("preview", model) ... - .pointSlots("cropCenter", centerValue, ... + .rectangle("cropRoi", roiPosition(model, hasImage), ... ImageSize=imageSize, Enabled=hasImage && ~editing) ... .scaleReference("scaleReference", reference, ... ImageSize=imageSize, Enabled=editing); @@ -46,18 +43,18 @@ model.yData = [1 size(geometry.canvas, 1)]; model.center = batch_crop.cropGeometry.originalToCanvas( ... geometry, item.centerXY); -model.cropRectangle = cropRectangle(geometry, item.centerXY, ... +model.cropRectangle = batch_crop.cropGeometry.cropRectanglePosition( ... + geometry, item.centerXY, ... batch_crop.cropGeometry.currentCropSize(state)); model.scaleBar = scaleBarOnCanvas(geometry, state.session.view.scaleBar); end -function position = cropRectangle(geometry, center, cropSize) -scale = batch_crop.cropGeometry.geometryScale(geometry); -width = max(1, double(cropSize(1)) * scale); -height = max(1, double(cropSize(2)) * scale); -canvasCenter = batch_crop.cropGeometry.originalToCanvas(geometry, center); -position = [round(canvasCenter(1) - (width - 1) / 2) - 0.5, ... - round(canvasCenter(2) - (height - 1) / 2) - 0.5, width, height]; +function position = roiPosition(model, hasImage) +if hasImage + position = model.cropRectangle; +else + position = [0 0 0 0]; +end end function value = scaleBarOnCanvas(geometry, value) diff --git a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m index ad1430eb2..73db51508 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+cropPreview/renderData.m @@ -1,7 +1,8 @@ % App-owned preview rendering helper. Expected caller: batch-crop preview % redraw logic. Inputs are the full crop geometry and placement metadata. -% Output preserves full canvas coordinate extents while optionally lowering -% preview CData resolution for responsive GUI rendering. +% Output preserves full canvas coordinate extents. Callers may explicitly +% request a lower CData budget; ordinary Batch Crop previews retain the canvas +% resolution selected by cropGeometry.currentGeometry. function render = renderData(geometry, placement, opts) %PREVIEWRENDERDATA Prepare a lightweight preview image for axes rendering. @@ -9,31 +10,18 @@ opts = struct(); end - maxPreviewPixels = double(optionValue(opts, 'MaxPreviewPixels', ... - defaultPreviewPixels())); - if ~isfinite(maxPreviewPixels) || maxPreviewPixels < 1 - maxPreviewPixels = defaultPreviewPixels(); + canvas = geometry.canvas; + scaleFactor = 1; + if isstruct(opts) && isfield(opts, 'MaxPreviewPixels') && ... + ~isempty(opts.MaxPreviewPixels) + [canvas, info] = labkit.image.previewBudget(geometry.canvas, ... + "MaxPixels", opts.MaxPreviewPixels); + scaleFactor = info.scaleFactor; end - [canvas, info] = labkit.image.previewBudget(geometry.canvas, ... - "MaxPixels", maxPreviewPixels); - render = struct( ... 'imageData', canvas, ... 'xData', placement.xData, ... 'yData', placement.yData, ... - 'scaleFactor', info.scaleFactor); -end - -function value = defaultPreviewPixels() - % Constant: 1.2 megapixels balances draggable preview responsiveness - % with sufficient crop-placement detail. - value = 1.2e6; -end - -function value = optionValue(opts, name, defaultValue) - value = defaultValue; - if isstruct(opts) && isfield(opts, name) - value = opts.(name); - end + 'scaleFactor', scaleFactor); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m index 85a5cd680..43eee9c45 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/chooseFolder.m @@ -3,7 +3,8 @@ choice = callbackContext.chooseOutputFolder( ... applicationState.project.parameters.outputFolder); if choice.Cancelled - callbackContext.appendStatus("Export folder selection cancelled."); + callbackContext.log("info", "batch_crop.resultfiles.choosefolder.cancelled", ... + "Export folder selection cancelled."); return end applicationState.project.parameters.outputFolder = string(choice.Value); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m index 51f2ddbec..120cf2cf2 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+resultFiles/exportCrops.m @@ -26,7 +26,8 @@ applicationState.session.cache.paths); items = batch_crop.sourceFiles.loadMissingImages(items); catch cause - callbackContext.reportError("Could not load image", cause); + callbackContext.log("error", "batch_crop.resultfiles.exportcrops.exception", "Could not load image", ... + Category="failure", Audience="developer", Exception=cause); callbackContext.alert(cause.message, "Could not load image"); return end @@ -38,7 +39,7 @@ results = applicationState.project.results; if ~isempty(results.lastExport) && ... results.lastExportFingerprint == plan.fingerprint - callbackContext.appendStatus( ... + callbackContext.log("info", "batch_crop.resultfiles.exportcrops.skipped", ... "Crop export is already up to date; skipped duplicate write."); return end @@ -52,7 +53,8 @@ written = callbackContext.writeResultPackage( ... options.outputFolder, package); catch cause - callbackContext.reportError("Export failed", cause); + callbackContext.log("error", "batch_crop.resultfiles.exportcrops.exception", "Export failed", ... + Category="failure", Audience="developer", Exception=cause); callbackContext.alert(cause.message, "Export failed"); return end @@ -63,9 +65,12 @@ statuses = string({payload.results.status}); savedCount = sum(statuses == "saved"); failedCount = sum(statuses == "failed"); -callbackContext.appendStatus(sprintf( ... - "Exported %d crop(s), %d failed. Manifest: %s", ... - savedCount, failedCount, payload.manifestPath)); +severity = "info"; +if failedCount > 0 + severity = "warning"; +end +callbackContext.log(severity, "batch_crop.resultfiles.exportcrops.completed", ... + sprintf("Exported %d crop(s); %d failed.", savedCount, failedCount)); if failedCount > 0 callbackContext.alert( ... string(failedCount) + ... diff --git a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m index eb4d0e5f9..a2043f2e8 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+scaleCalibration/placeBar.m @@ -21,7 +21,8 @@ parameters.scaleBarColor); applicationState.session.workflow.scaleReferenceEditing = false; catch cause - callbackContext.reportError("Could not place scale bar", cause); + callbackContext.log("error", "batch_crop.scalecalibration.placebar.exception", "Could not place scale bar", ... + Category="failure", Audience="developer", Exception=cause); callbackContext.alert(cause.message, "Could not place scale bar"); end end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m index e3d0c5252..27bd59503 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/duplicateCurrent.m @@ -29,7 +29,8 @@ applicationState = batch_crop.cropGeometry.ensureCurrentCenter( ... applicationState); applicationState = batch_crop.cropGeometry.clearDerived(applicationState, true); -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "batch_crop.sourcefiles.duplicatecurrent.completed", ... "Duplicated crop task " + string(index) + "."); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m index 5015bbef3..410f0f02b 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/loadCurrent.m @@ -32,8 +32,9 @@ applicationState.session.cache.paths(index) = paths(1); loaded = true; catch cause - callbackContext.reportError("Could not load image", cause); - callbackContext.appendStatus( ... - "Could not load crop task " + string(index) + ": " + cause.message); + callbackContext.log("error", "batch_crop.sourcefiles.loadcurrent.exception", "Could not load image", ... + Category="failure", Audience="developer", Exception=cause); + callbackContext.log("error", "batch_crop.sourcefiles.loadcurrent.failed", ... + "Could not load the selected crop task."); end end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m index 7b76a4d71..e34bbb467 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+sourceFiles/selectionChanged.m @@ -48,5 +48,6 @@ applicationState = batch_crop.cropGeometry.initializeCropDefaults( ... applicationState); end -callbackContext.appendStatus("Crop tasks: " + string(numel(retained)) + "."); +callbackContext.log("info", "batch_crop.sourcefiles.selectionchanged.status", ... + "Crop tasks: " + string(numel(retained)) + "."); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m b/apps/image_measurement/batch_crop/+batch_crop/+syntheticInputs/writeSamplePack.m similarity index 80% rename from apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m rename to apps/image_measurement/batch_crop/+batch_crop/+syntheticInputs/writeSamplePack.m index 5006364cd..f0bf494dd 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+debug/writeSamplePack.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: app debug launch and unit tests. Input +% Expected caller: app synthetic-input generation and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic crop sample % pack. Side effects: writes anonymous debug images and records a session % manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Batch Crop debug image files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end imageA = sampleContext.samplePath("batch_crop/source_a.png"); @@ -21,7 +21,13 @@ project.inputs.sources = [ ... sampleContext.sourceRecord("image1", "cropSource", imageA, true), ... sampleContext.sourceRecord("image2", "cropSource", imageB, true)]; - pack = labkit.app.diagnostic.SamplePack( ... + project.inputs.items = batch_crop.cropTasks.forSourceIds( ... + string({project.inputs.sources.id})); + project.inputs.items(1).centerXY = [170 130]; + project.inputs.items(1).centerSet = true; + project.inputs.items(2).centerXY = [186 122]; + project.inputs.items(2).centerSet = true; + pack = labkit.app.synthetic.Pack( ... Scenario="representative-crop-targets", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("sourceA", "cropSource", imageA), ... @@ -57,8 +63,8 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("batch_crop:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("batch_crop:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m b/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m index eedd302a4..09dee0cc4 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m +++ b/apps/image_measurement/batch_crop/+batch_crop/+workbench/buildLayout.m @@ -12,15 +12,13 @@ labkit.app.layout.section("summarySection", "Summary", { ... labkit.app.layout.dataTable("resultTable", ... Title="Batch Results", Columns=["Metric", "Value"]), ... - labkit.app.layout.statusPanel("details", Title="Details")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; -crop = labkit.app.interaction.pointSlots("cropCenter", ... - @batch_crop.cropGeometry.changeCenterFromPreview, Axis="main", ... - Style=struct("color", [0.05 0.45 0.95], ... - "selectedColor", [1 0.9 0.15], ... - "placeSelectedOnBackground", true), ... + labkit.app.layout.statusPanel("details", Title="Details")})})}; +roi = labkit.app.interaction.rectangle("cropRoi", ... + @batch_crop.cropGeometry.changeCropRectangle, Axis="main", ... + Style=struct("color", [1 0.9 0.15], "lineWidth", 2, ... + "resizable", false), ... + Instruction="Click anywhere to place the crop center, or drag the ROI to move it.", ... + OnBackgroundPressed=@batch_crop.cropGeometry.changeCenterFromPreview, ... ViewportPolicy="preserve"); scale = labkit.app.interaction.scaleReference("scaleReference", ... @batch_crop.scaleCalibration.changeReference, Axis="main", ... @@ -30,7 +28,7 @@ labkit.app.layout.plotArea("preview", ... @batch_crop.cropPreview.draw, ... Title="Padded rotation preview + fixed crop", ... - Interactions={crop, scale}), ... + Interactions={roi, scale}), ... Title="Crop Preview"); layout = labkit.app.layout.workbench(controls, Workspace=workspace); end diff --git a/apps/image_measurement/batch_crop/+batch_crop/definition.m b/apps/image_measurement/batch_crop/+batch_crop/definition.m index c900d2270..790881871 100644 --- a/apps/image_measurement/batch_crop/+batch_crop/definition.m +++ b/apps/image_measurement/batch_crop/+batch_crop/definition.m @@ -5,9 +5,9 @@ app = labkit.app.Definition(Entrypoint="labkit_BatchImageCrop_app", ... AppId="batch_crop", Title="Microscope Batch Image Crop", ... DisplayName="Batch Image Crop", Family="Image Measurement", ... - AppVersion="1.8.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + AppVersion="1.9.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=batch_crop.projectSpec(), CreateSession=@batch_crop.createSession, ... Workbench=batch_crop.workbench.buildLayout(), PresentWorkbench=@batch_crop.workbench.present, ... - BuildDebugSample=@batch_crop.debug.writeSamplePack); + BuildSyntheticSample=@batch_crop.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m b/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m index 0a4321002..edddfd6b6 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/densePointCountChanged.m @@ -9,5 +9,7 @@ applicationState.project.parameters.densePointCount = ... max(3, round(pointCount)); applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus("Curvature fit settings changed."); +callbackContext.log("info", ... + "curvature.analysisrun.densepointcountchanged.status", ... + "Curvature fit settings changed."); end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m b/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m index f81d0890b..1a90d2ac0 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/densifyChanged.m @@ -4,5 +4,6 @@ %DENSIFYCHANGED Normalize the fit sampling mode and invalidate results. applicationState.project.parameters.densify = logical(enabled); applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus("Curvature fit settings changed."); +callbackContext.log("info", "curvature.analysisrun.densifychanged.status", ... + "Curvature fit settings changed."); end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m b/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m index 17f5d0672..4e1332f20 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/fit.m @@ -20,7 +20,7 @@ if applicationState.project.results.fit.ok && ... applicationState.session.cache.fitFingerprint == ... task.fingerprint - callbackContext.appendStatus( ... + callbackContext.log("info", "curvature.analysisrun.fit.skipped", ... "Curvature fit already matches current curve and scale."); return end @@ -29,7 +29,8 @@ task.options.doDensify, task.options.denseN, ... task.fitPath(:, 1), task.fitPath(:, 2)); catch ME - callbackContext.reportError("Fit Curvature circle", ME); + callbackContext.log("error", "curvature.analysisrun.fit.exception", "Fit Curvature circle", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Circle fit failed"); return end @@ -40,7 +41,7 @@ applicationState.project.results.lastOverlayExport = []; applicationState.session.cache.fitFingerprint = task.fingerprint; applicationState.session.cache.lengthFingerprint = ""; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", "curvature.analysisrun.fit.completed", sprintf( ... "Fit complete: R = %.6g %s, curvature = %.6g %s.", ... fitResult.R_show, fitResult.unitLen, ... fitResult.kappa_show, fitResult.unitK)); diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m b/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m index 301fafc14..240bc3add 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/measureLength.m @@ -17,21 +17,24 @@ if applicationState.project.results.length.ok && ... applicationState.session.cache.lengthFingerprint == ... task.fingerprint - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "curvature.analysisrun.measurelength.skipped", ... "Curve length already matches current curve and scale."); return end lengthResult = curvature.analysisRun.computeCurveLength( ... task.lengthPath(:, 1), task.lengthPath(:, 2), task.calibration); catch ME - callbackContext.reportError("Measure Curvature curve length", ME); + callbackContext.log("error", "curvature.analysisrun.measurelength.exception", "Measure Curvature curve length", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Curve length failed"); return end applicationState.project.results.length = lengthResult; applicationState.project.results.lastCsvExport = []; applicationState.session.cache.lengthFingerprint = task.fingerprint; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", ... + "curvature.analysisrun.measurelength.completed", sprintf( ... "Curve length measured: %.6g %s.", ... lengthResult.length_show, lengthResult.unitLen)); end diff --git a/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m b/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m index 6f6b6f87c..5a2505567 100644 --- a/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m +++ b/apps/image_measurement/curvature/+curvature/+analysisRun/showDensePointsChanged.m @@ -3,5 +3,7 @@ applicationState, visible, callbackContext) %SHOWDENSEPOINTSCHANGED Update presentation-only dense-point visibility. applicationState.project.parameters.showDensePoints = logical(visible); -callbackContext.appendStatus("Curvature overlay display changed."); +callbackContext.log("info", ... + "curvature.analysisrun.showdensepointschanged.status", ... + "Curvature overlay display changed."); end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/change.m b/apps/image_measurement/curvature/+curvature/+curveEdit/change.m index 63e57c5f4..f00a9a82a 100644 --- a/apps/image_measurement/curvature/+curvature/+curveEdit/change.m +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/change.m @@ -5,7 +5,7 @@ points = normalizePoints(points); applicationState.project.annotations.curvePoints = points; applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", "curvature.curveedit.change.status", ... "Curve edit updated: " + string(size(points, 1)) + " point(s)."); end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m b/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m index 87dd52c72..83162fc29 100644 --- a/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/clear.m @@ -3,5 +3,6 @@ %CLEAR Remove every curve anchor and invalidate measurements. applicationState.project.annotations.curvePoints = zeros(0, 2); applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus("Cleared curve points."); +callbackContext.log("info", "curvature.curveedit.clear.status", ... + "Cleared curve points."); end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m b/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m index 3f2956a7a..9b6c46ccc 100644 --- a/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/toggle.m @@ -8,13 +8,14 @@ end if string(applicationState.session.workflow.editMode) == "curve" applicationState.session.workflow.editMode = "none"; - callbackContext.appendStatus("Finished curve edit."); + callbackContext.log("info", "curvature.curveedit.toggle.finished", ... + "Finished curve edit."); else applicationState.session.workflow.editMode = "curve"; applicationState.session.view.scaleBar = []; applicationState = ... curvature.curveEdit.clearMeasurements(applicationState); - callbackContext.appendStatus( ... + callbackContext.log("info", "curvature.curveedit.toggle.started", ... "Started curve edit. Double-click blank image space to add or " + ... "insert points; drag points to move; double-click a point to delete."); end diff --git a/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m b/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m index cad640f89..adefe4ec0 100644 --- a/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m +++ b/apps/image_measurement/curvature/+curvature/+curveEdit/undo.m @@ -8,5 +8,6 @@ points(end, :) = []; applicationState.project.annotations.curvePoints = points; applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus("Undid last curve point."); +callbackContext.log("info", "curvature.curveedit.undo.status", ... + "Undid last curve point."); end diff --git a/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m b/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m index 4b58d0d55..0c639a93c 100644 --- a/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m +++ b/apps/image_measurement/curvature/+curvature/+resultFiles/exportCsv.m @@ -14,7 +14,8 @@ ["*.csv", "CSV files (*.csv)"], ... defaultOutputPath(applicationState, "curvature_result.csv")); if choice.Cancelled - callbackContext.appendStatus("Result CSV export cancelled."); + callbackContext.log("info", "curvature.resultfiles.exportcsv.cancelled", ... + "Result CSV export cancelled."); return end filepath = string(choice.Value); @@ -26,13 +27,15 @@ filepath, "curvatureResults", "text/csv", ... "curvature_result.labkit.json"); catch ME - callbackContext.reportError("Export Curvature result CSV", ME); + callbackContext.log("error", "curvature.resultfiles.exportcsv.exception", "Export Curvature result CSV", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not export result CSV"); return end applicationState.project.results.lastCsvExport = struct( ... "csvPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus("Exported result CSV: " + filepath); +callbackContext.log("info", "curvature.resultfiles.exportcsv.completed", ... + "Exported the result CSV."); end function written = writeManifest( ... diff --git a/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m b/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m index ff3e05a7d..ee9e7fb36 100644 --- a/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m +++ b/apps/image_measurement/curvature/+curvature/+resultFiles/exportOverlay.m @@ -11,7 +11,9 @@ ["*.png", "PNG image (*.png)"], ... defaultOutputPath(applicationState, "curvature_overlay.png")); if choice.Cancelled - callbackContext.appendStatus("Overlay PNG export cancelled."); + callbackContext.log("info", ... + "curvature.resultfiles.exportoverlay.cancelled", ... + "Overlay PNG export cancelled."); return end filepath = string(choice.Value); @@ -27,13 +29,16 @@ filepath, "curvatureOverlay", "image/png", ... "curvature_overlay.labkit.json"); catch ME - callbackContext.reportError("Export Curvature overlay PNG", ME); + callbackContext.log("error", "curvature.resultfiles.exportoverlay.exception", "Export Curvature overlay PNG", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not export overlay PNG"); return end applicationState.project.results.lastOverlayExport = struct( ... "pngPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus("Exported overlay PNG: " + filepath); +callbackContext.log("info", ... + "curvature.resultfiles.exportoverlay.completed", ... + "Exported the overlay PNG."); end function written = writeManifest( ... diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m index d5302ab78..cecff08b8 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/barSettingChanged.m @@ -8,6 +8,7 @@ end applicationState.project.parameters.scaleBarLength = barLength; applicationState.session.view.scaleBar = []; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "curvature.scalecalibration.barsettingchanged.status", ... "Scale bar setting changed to " + string(changedValue) + "."); end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m index f87a8f57b..5d2e000e8 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeLength.m @@ -14,6 +14,7 @@ struct("referenceLine", calibration.referenceLine)); applicationState.session.view.scaleBar = []; applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "curvature.scalecalibration.changelength.status", ... "Scale reference length set to " + string(referenceLength) + "."); end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m index fb7d47492..c05a08538 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changePixels.m @@ -9,7 +9,8 @@ referencePixels, calibration.referenceLength, calibration.unit); applicationState.session.view.scaleBar = []; applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "curvature.scalecalibration.changepixels.status", ... "Reference pixels set to " + string(referencePixels) + "."); end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m index 729b03de7..a53ab0b27 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeReference.m @@ -13,7 +13,9 @@ struct("referenceLine", endpoints)); applicationState.session.view.scaleBar = []; applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus("Scale reference updated."); +callbackContext.log("info", ... + "curvature.scalecalibration.changereference.status", ... + "Scale reference updated."); end function endpoints = normalizeEndpoints(endpoints) diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m index b38357bd6..641aed8a3 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/changeUnit.m @@ -10,7 +10,8 @@ struct("referenceLine", calibration.referenceLine)); applicationState.session.view.scaleBar = []; applicationState = curvature.curveEdit.clearMeasurements(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "curvature.scalecalibration.changeunit.status", ... "Scale unit set to " + ... string(applicationState.project.annotations.calibration.unit) + "."); end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m index afef7f7d4..78bfba9a7 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/placeBar.m @@ -18,12 +18,14 @@ parameters.scaleBarLength, ... parameters.scaleBarPosition, parameters.scaleBarColor); catch ME - callbackContext.reportError("Place Curvature scale bar", ME); + callbackContext.log("error", "curvature.scalecalibration.placebar.exception", "Place Curvature scale bar", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not place scale bar"); return end applicationState.session.workflow.editMode = "none"; -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", ... + "curvature.scalecalibration.placebar.completed", sprintf( ... "Placed scale bar: %.6g %s.", ... parameters.scaleBarLength, calibration.unit)); end diff --git a/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m b/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m index 27621da00..cf8089fb4 100644 --- a/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m +++ b/apps/image_measurement/curvature/+curvature/+scaleCalibration/toggleReference.m @@ -10,13 +10,16 @@ end if string(applicationState.session.workflow.editMode) == "reference" applicationState.session.workflow.editMode = "none"; - callbackContext.appendStatus("Finished reference-pixel edit."); + callbackContext.log("info", ... + "curvature.scalecalibration.togglereference.finished", ... + "Finished reference-pixel edit."); else applicationState.session.workflow.editMode = "reference"; applicationState.session.view.scaleBar = []; applicationState = ... curvature.curveEdit.clearMeasurements(applicationState); - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "curvature.scalecalibration.togglereference.started", ... "Started reference-pixel edit. Double-click two endpoints and " + ... "drag them to refine the reference line."); end diff --git a/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m b/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m index 74bcb89c3..59cb22271 100644 --- a/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m +++ b/apps/image_measurement/curvature/+curvature/+sourceFiles/selectionChanged.m @@ -13,9 +13,13 @@ if isempty(selection.Indices) || isempty(applicationState.session.cache.image) applicationState.session.workflow.statusMessage = ... "Open an image to trace a curve."; - callbackContext.appendStatus("Image cleared."); + callbackContext.log("info", ... + "curvature.sourcefiles.selectionchanged.cleared", ... + "Image cleared."); else applicationState.session.workflow.statusMessage = "Image loaded."; - callbackContext.appendStatus("Loaded image."); + callbackContext.log("info", ... + "curvature.sourcefiles.selectionchanged.loaded", ... + "Loaded image."); end end diff --git a/apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m b/apps/image_measurement/curvature/+curvature/+syntheticInputs/writeSamplePack.m similarity index 86% rename from apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m rename to apps/image_measurement/curvature/+curvature/+syntheticInputs/writeSamplePack.m index 7a1a1e2ab..929aef842 100644 --- a/apps/image_measurement/curvature/+curvature/+debug/writeSamplePack.m +++ b/apps/image_measurement/curvature/+curvature/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: curvature.definition during debug launch and unit tests. Input +% Expected caller: curvature.definition during synthetic-input generation and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic curvature % image sample pack. Side effects: writes anonymous debug images and records % a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Curvature debug image files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end arcPath = sampleContext.samplePath("curvature/arc.png"); @@ -18,7 +18,7 @@ project = curvature.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "image1", "image", arcPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-arc", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("arc", "image", arcPath), ... @@ -52,8 +52,8 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("curvature:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("curvature:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m b/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m index 4e471da32..6abf6b3b8 100644 --- a/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m +++ b/apps/image_measurement/curvature/+curvature/+workbench/buildLayout.m @@ -14,10 +14,7 @@ Columns=["Metric", "Value"])}), ... labkit.app.layout.section("detailsSection", "Details", { ... labkit.app.layout.statusPanel("detailsText", ... - Title="Details")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; + Title="Details")})})}; curve = labkit.app.interaction.anchorPath("curve", ... @curvature.curveEdit.change, Axis="image", ... Style=struct("closed", false, "color", [0 0.45 0.95]), ... diff --git a/apps/image_measurement/curvature/+curvature/definition.m b/apps/image_measurement/curvature/+curvature/definition.m index 0e449e1f1..7d253a4d7 100644 --- a/apps/image_measurement/curvature/+curvature/definition.m +++ b/apps/image_measurement/curvature/+curvature/definition.m @@ -5,10 +5,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_CurvatureMeasurement_app", AppId="curvature", ... Title="Image Curvature Measurement", DisplayName="Curvature Measurement", ... - Family="Image Measurement", AppVersion="1.5.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + Family="Image Measurement", AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=curvature.projectSpec(), CreateSession=@curvature.createSession, ... Workbench=curvature.workbench.buildLayout(), ... PresentWorkbench=@curvature.workbench.present, ... - BuildDebugSample=@curvature.debug.writeSamplePack); + BuildSyntheticSample=@curvature.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m index 626fff25d..abbfd2c42 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/autoRange.m @@ -21,7 +21,8 @@ applicationState = flir_thermal.thermalSources.storeCurrentAnnotation( ... applicationState, item); applicationState = invalidateResults(applicationState); -callbackContext.appendStatus("Set the selected FLIR auto range."); +callbackContext.log("info", "flir_thermal.displaymapping.autorange.status", ... + "Set the selected FLIR auto range."); end function range = normalizeRange(range) diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m index 61e16a60a..49b591f5a 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeColorMapping.m @@ -6,7 +6,8 @@ if any(mapping == ["Linear", "Log", "Gamma"]) applicationState.project.parameters.colorMapping = mapping; applicationState = invalidateResults(applicationState); - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "flir_thermal.displaymapping.changecolormapping.status", ... "Thermal color mapping: " + mapping + "."); end end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m index fadfea491..76f50a8e1 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeGamma.m @@ -8,6 +8,7 @@ applicationState.project.parameters.gammaValue = gammaValue; applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.displaymapping.changegamma.status", ... "Thermal display gamma: " + string(gammaValue) + "."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m index e13e9f4c9..a99bbcf9f 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMaximum.m @@ -19,7 +19,9 @@ applicationState, item); applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; -callbackContext.appendStatus("Updated current thermal display range."); +callbackContext.log("info", ... + "flir_thermal.displaymapping.changemaximum.status", ... + "Updated current thermal display range."); end function range = normalizedRange(value) diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m index cc3124528..4dcd11aa4 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changeMinimum.m @@ -19,7 +19,9 @@ applicationState, item); applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; -callbackContext.appendStatus("Updated current thermal display range."); +callbackContext.log("info", ... + "flir_thermal.displaymapping.changeminimum.status", ... + "Updated current thermal display range."); end function range = normalizedRange(value) diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m index c5fbb4ee0..27fab15ff 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePalette.m @@ -6,7 +6,9 @@ if any(palette == ["turbo", "iron", "hot", "parula", "gray"]) applicationState.project.parameters.palette = palette; applicationState = invalidateResults(applicationState); - callbackContext.appendStatus("Thermal palette: " + palette + "."); + callbackContext.log("info", ... + "flir_thermal.displaymapping.changepalette.status", ... + "Thermal palette: " + palette + "."); end end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m index 0eac12c75..f98b80319 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/changePreset.m @@ -25,7 +25,9 @@ applicationState = flir_thermal.thermalSources.storeCurrentAnnotation( ... applicationState, item); applicationState = invalidateResults(applicationState); -callbackContext.appendStatus("Thermal range bounds: " + preset + "."); +callbackContext.log("info", ... + "flir_thermal.displaymapping.changepreset.status", ... + "Thermal range bounds: " + preset + "."); end function range = clampRange(range, bounds) diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m index 1bb6d41d4..ddad8fee0 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/groupRange.m @@ -18,7 +18,8 @@ end applicationState = storeAll(applicationState, items); applicationState = invalidateResults(applicationState); -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.displaymapping.grouprange.completed", ... "Applied one shared range to " + string(numel(items)) + ... " thermal images."); end @@ -36,7 +37,8 @@ applicationState.project.annotations.items); ok = numel(items) == numel(applicationState.project.inputs.sources); catch ME - callbackContext.reportError("Load FLIR sources for shared range", ME); + callbackContext.log("error", "flir_thermal.displaymapping.grouprange.exception", "Load FLIR sources for shared range", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not load FLIR sources"); end end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m index f5999d335..7dee9616a 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/perImageRange.m @@ -11,7 +11,8 @@ items = flir_thermal.sourceFiles.readImages( ... paths, struct("SkipInvalid", false)); catch ME - callbackContext.reportError("Load FLIR sources for individual ranges", ME); + callbackContext.log("error", "flir_thermal.displaymapping.perimagerange.exception", "Load FLIR sources for individual ranges", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not load FLIR sources"); return end @@ -49,7 +50,8 @@ end applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.displaymapping.perimagerange.completed", ... "Applied individual auto ranges to " + ... string(numel(items)) + " thermal images."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m index 2cfde684f..8670885bb 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+displayMapping/roundRanges.m @@ -35,6 +35,7 @@ end applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.displaymapping.roundranges.completed", ... "Rounded " + string(count) + " thermal ranges."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m index 8675a6cb3..90faca672 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/chooseOutputFolder.m @@ -5,12 +5,15 @@ choice = callbackContext.chooseOutputFolder( ... applicationState.project.parameters.outputFolder); if choice.Cancelled - callbackContext.appendStatus("FLIR output-folder selection cancelled."); + callbackContext.log("info", ... + "flir_thermal.resultfiles.chooseoutputfolder.cancelled", ... + "FLIR output-folder selection cancelled."); return end applicationState.project.parameters.outputFolder = string(choice.Value); applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; -callbackContext.appendStatus( ... - "FLIR output folder: " + string(choice.Value)); +callbackContext.log("info", ... + "flir_thermal.resultfiles.chooseoutputfolder.selected", ... + "Selected a FLIR output folder."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m index fb23442ed..2160d1828 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportAll.m @@ -19,7 +19,7 @@ payload.resultManifestPath = manifestPath; applicationState.project.results.lastExport = payload; applicationState.project.results.resultManifestPath = manifestPath; -callbackContext.appendStatus(sprintf( ... - "Exported %d FLIR image(s): %s", ... - numel(sources), payload.manifestPath)); +callbackContext.log("info", ... + "flir_thermal.resultfiles.exportall.completed", ... + sprintf("Exported %d FLIR image(s).", numel(sources))); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m index b258f702f..eb7af46a1 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/exportCurrent.m @@ -20,6 +20,7 @@ payload.resultManifestPath = manifestPath; applicationState.project.results.lastExport = payload; applicationState.project.results.resultManifestPath = manifestPath; -callbackContext.appendStatus( ... - "Exported current FLIR image: " + payload.manifestPath); +callbackContext.log("info", ... + "flir_thermal.resultfiles.exportcurrent.completed", ... + "Exported the current FLIR image."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m index ea9885b6f..f14787fb5 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/formatChanged.m @@ -7,7 +7,8 @@ applicationState.project.parameters.exportFormat = formatName; applicationState.project.results.lastExport = []; applicationState.project.results.resultManifestPath = ""; - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "flir_thermal.resultfiles.formatchanged.status", ... "FLIR export image format: " + formatName + "."); end end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m index 50e620310..6ff43b70f 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+resultFiles/writeSelection.m @@ -37,7 +37,8 @@ ManifestName="flir_thermal.labkit.json"); written = callbackContext.writeResultPackage(folder, package); catch ME - callbackContext.reportError("Export FLIR thermal results", ME); + callbackContext.log("error", "flir_thermal.resultfiles.writeselection.exception", "Export FLIR thermal results", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not export FLIR images"); return end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m b/apps/image_measurement/flir_thermal/+flir_thermal/+syntheticInputs/writeSamplePack.m similarity index 94% rename from apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m rename to apps/image_measurement/flir_thermal/+flir_thermal/+syntheticInputs/writeSamplePack.m index 6b9bc7ab2..8df7a58bd 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+debug/writeSamplePack.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: flir_thermal.definition during debug launch and unit tests. Input +% Expected caller: flir_thermal.definition during synthetic-input generation and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic radiometric % JPEG-like sample pack. Side effects: writes anonymous debug files and records % a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write FLIR Thermal debug radiometric files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end warmPath = sampleContext.samplePath("flir_thermal/warm.jpg"); @@ -33,7 +33,7 @@ "thermal1", "thermal-image", warmPath, true), ... sampleContext.sourceRecord( ... "thermal2", "thermal-image", coolPath, true)]; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-thermal-images", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("warm", "thermal-image", warmPath), ... @@ -61,8 +61,8 @@ function writeSyntheticRjpeg(filepath, raw, opts) fid = fopen(char(filepath), "w"); if fid < 0 - error("flir_thermal:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("flir_thermal:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fwrite(fid, jpegBytes, "uint8"); @@ -143,7 +143,7 @@ function putString(index, text, count) imwrite(raw, char(path)); fid = fopen(char(path), "r"); if fid < 0 - error("flir_thermal:debug:SampleWriteFailed", ... + error("flir_thermal:syntheticInputs:SampleWriteFailed", ... "Could not read temporary thermal PNG payload."); end bytes = fread(fid, inf, "*uint8").'; @@ -180,7 +180,7 @@ function writeDirectoryEntry(startIndex, recordType, recordOffset, recordLength) function bytes = jpegWrapper(block) segmentLength = numel(block) + 2; if segmentLength > 65535 - error("flir_thermal:debug:SampleTooLarge", ... + error("flir_thermal:syntheticInputs:SampleTooLarge", ... "Synthetic FLIR APP1 segment is too large."); end bytes = [uint8([255 216 255 225]), ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m index 94c621176..a42555dbf 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changePoint.m @@ -8,5 +8,7 @@ state=flir_thermal.thermalSources.storeCurrentAnnotation(state,item); state.project.results.lastExport=[]; state.project.results.resultManifestPath=""; -context.appendStatus(sprintf('Set manual point: %.2f C.',reading.temperatureC)); +context.log("info", ... + "flir_thermal.temperaturereadings.changepoint.status", ... + "Updated the manual temperature point."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m index 07c341532..b389087eb 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/changeRegion.m @@ -8,5 +8,7 @@ state=flir_thermal.thermalSources.storeCurrentAnnotation(state,item); state.project.results.lastExport=[]; state.project.results.resultManifestPath=""; -context.appendStatus(sprintf('Set temperature ROI: %.2f C.',reading.temperatureC)); +context.log("info", ... + "flir_thermal.temperaturereadings.changeregion.status", ... + "Updated the temperature ROI."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m index 2e9f861a4..066f695ae 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectColdMode.m @@ -3,6 +3,7 @@ applicationState, callbackContext) %SELECTCOLDMODE Configure dragged regions to report their coldest pixel. applicationState.project.parameters.roiMode = "cold"; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.temperaturereadings.selectcoldmode.status", ... "ROI mode: ROI cold spot. Drag on the thermal image to set the ROI."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m index 1b052e548..d78a4d8da 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectHotMode.m @@ -3,6 +3,7 @@ applicationState, callbackContext) %SELECTHOTMODE Configure dragged regions to report their hottest pixel. applicationState.project.parameters.roiMode = "hot"; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.temperaturereadings.selecthotmode.status", ... "ROI mode: ROI hot spot. Drag on the thermal image to set the ROI."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m index 3b71d4c4b..c79d77ed3 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+temperatureReadings/selectMeanMode.m @@ -3,6 +3,7 @@ applicationState, callbackContext) %SELECTMEANMODE Configure dragged regions to report their mean temperature. applicationState.project.parameters.roiMode = "mean"; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "flir_thermal.temperaturereadings.selectmeanmode.status", ... "ROI mode: ROI mean. Drag on the thermal image to set the ROI."); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m index 06d2f0d0f..565db3992 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+thermalSources/selectCurrent.m @@ -22,7 +22,9 @@ labkit.app.event.ListSelection(); applicationState.session.cache.currentItem = []; applicationState = invalidateResults(applicationState); - callbackContext.appendStatus("Cleared loaded FLIR files."); + callbackContext.log("info", ... + "flir_thermal.thermalsources.selectcurrent.cleared", ... + "Cleared loaded FLIR files."); return end @@ -30,7 +32,8 @@ try item = loadItem(sources(index), annotations, callbackContext); catch ME - callbackContext.reportError("Load selected FLIR image", ME); + callbackContext.log("error", "flir_thermal.thermalsources.selectcurrent.exception", "Load selected FLIR image", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not load FLIR image"); return end @@ -52,7 +55,8 @@ if sourceSetChanged applicationState = invalidateResults(applicationState); end -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", ... + "flir_thermal.thermalsources.selectcurrent.selected", sprintf( ... "Selected FLIR image %d of %d.", index, numel(sources))); end diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m b/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m index 43a4d513f..f773c1df7 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/+workbench/buildLayout.m @@ -12,10 +12,7 @@ Title="Current Image", Columns=["Metric", "Value"])}), ... flir_thermal.temperatureReadings.layoutSection(), ... labkit.app.layout.section("detailSection", "Details", { ... - labkit.app.layout.statusPanel("details", Title="Details")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("logPanel", Title="Log")})})}; + labkit.app.layout.statusPanel("details", Title="Details")})})}; reading = labkit.app.interaction.regionSelection( ... "temperatureReading", @flir_thermal.temperatureReadings.changeRegion, ... Axis="thermalImage", ... diff --git a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m index 1c0b383dc..a5656fd37 100644 --- a/apps/image_measurement/flir_thermal/+flir_thermal/definition.m +++ b/apps/image_measurement/flir_thermal/+flir_thermal/definition.m @@ -6,11 +6,11 @@ Entrypoint="labkit_FLIRThermal_app", ... AppId="flir_thermal", ... Title="FLIR Thermal Postprocess", DisplayName="FLIR Thermal", ... - Family="Image Measurement", AppVersion="1.5.1", Updated="2026-07-20", ... + Family="Image Measurement", AppVersion="1.6.0", Updated="2026-07-26", ... Requirements=labkit.contract.requirements( ... - "app", ">=1 <2", "image", ">=2.0 <3", ... + "app", ">=2 <3", "image", ">=2.0 <3", ... "thermal", ">=1.1 <2"), ... ProjectSchema=flir_thermal.projectSpec(), CreateSession=@flir_thermal.createSession, ... Workbench=flir_thermal.workbench.buildLayout(), PresentWorkbench=@flir_thermal.workbench.present, ... - BuildDebugSample=@flir_thermal.debug.writeSamplePack); + BuildSyntheticSample=@flir_thermal.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m index ef9561ebc..566ed8dba 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/applyPreset.m @@ -7,5 +7,6 @@ state.project.parameters.smoothRadius = settings.smoothRadius; state.project.parameters.uncertainBlend = settings.minConfidencePercent; state = focus_stack.analysisRun.invalidate(state, [], context); -context.appendStatus("Fusion preset set to " + state.project.parameters.fusionPreset + "."); +context.log("info", "focus_stack.analysisrun.applypreset.status", ... + "Applied the selected fusion preset."); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m index 7f3d81b29..d5cac499f 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+analysisRun/runFocusStack.m @@ -12,7 +12,8 @@ paths = context.resolveSourcePaths(state.project.inputs.sources); task = focus_stack.analysisRun.runTask(paths, images, options, p.autoRegister); if state.session.cache.result.ok && state.project.results.lastRunFingerprint == task.fingerprint - context.appendStatus("Focus stack result is already up to date."); + context.log("info", "focus_stack.analysisrun.runfocusstack.skipped", ... + "Focus stack result is already up to date."); return; end try @@ -24,9 +25,11 @@ end result = focus_stack.analysisRun.computeFocusStack(aligned, options); catch ME - context.reportError("Focus stacking", ME); + context.log("error", "focus_stack.analysisrun.runfocusstack.exception", "Focus stacking", ... + Category="failure", Audience="developer", Exception=ME); context.alert(ME.message, "Focus stacking failed"); - context.appendStatus("Focus stacking failed: " + string(ME.message)); + context.log("error", "focus_stack.analysisrun.runfocusstack.failed", ... + "Focus stacking failed."); return; end state.session.cache.alignedImages = aligned; @@ -38,9 +41,13 @@ state.project.results.registrationLines = lines; state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; -context.appendStatus(sprintf("Focus stack complete: %d images fused with %s.", result.inputCount, result.method)); -for line = lines(:).' - context.appendStatus(line); +context.log("info", "focus_stack.analysisrun.runfocusstack.completed", ... + sprintf("Focus stack complete: %d images fused.", result.inputCount)); +if ~isempty(lines) + context.log("debug", ... + "focus_stack.analysisrun.runfocusstack.registration_details", ... + sprintf("Recorded %d registration detail(s).", numel(lines)), ... + Audience="developer"); end end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m index a4c195923..634ba252c 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+resultFiles/exportResult.m @@ -12,7 +12,8 @@ defaultPath = fullfile(defaultFolder(applicationState), defaultName); choice = callbackContext.chooseOutputFile(filters, defaultPath); if choice.Cancelled - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "focus_stack.resultfiles.exportresult.cancelled", ... "Export " + kind + " cancelled."); return; end @@ -34,10 +35,12 @@ ManifestName="focus_stack.labkit.json"); written = callbackContext.writeResultPackage(folder, package); catch ME - callbackContext.reportError("Export Focus Stack result", ME); + callbackContext.log("error", "focus_stack.resultfiles.exportresult.exception", "Export Focus Stack result", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not export result"); - callbackContext.appendStatus( ... - "Could not export " + kind + ": " + string(ME.message)); + callbackContext.log("error", ... + "focus_stack.resultfiles.exportresult.failed", ... + "Could not export the Focus Stack result."); return; end applicationState.project.parameters.outputFolder = string(folder); @@ -45,8 +48,9 @@ "kind", kind, "outputPath", filepath, ... "manifestPath", string(written.Value)); applicationState.project.results.resultManifestPath = string(written.Value); -callbackContext.appendStatus( ... - "Exported " + kind + ": " + filepath); +callbackContext.log("info", ... + "focus_stack.resultfiles.exportresult.completed", ... + "Exported the selected Focus Stack result."); end function [name, filters, mediaType] = outputContract(kind) diff --git a/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m index 1caa470ba..809b9a018 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+sourceFiles/chooseFolder.m @@ -9,7 +9,8 @@ end choice = callbackContext.chooseInputFolder(startPath); if choice.Cancelled - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "focus_stack.sourcefiles.choosefolder.cancelled", ... "Focus image folder selection cancelled."); return; end @@ -18,10 +19,12 @@ paths = focus_stack.sourceFiles.findImages(folder); images = focus_stack.sourceFiles.readImages(paths); catch ME - callbackContext.reportError("Load focus image folder", ME); + callbackContext.log("error", "focus_stack.sourcefiles.choosefolder.exception", "Load focus image folder", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not load focus image folder"); - callbackContext.appendStatus( ... - "Could not load focus image folder: " + string(ME.message)); + callbackContext.log("error", ... + "focus_stack.sourcefiles.choosefolder.failed", ... + "Could not load the focus image folder."); return; end incoming = labkit.app.project.emptySourceRecords(); @@ -44,6 +47,7 @@ applicationState.session.selection.sourceImages = ... labkit.app.event.ListSelection( ... Ids=ids, Indices=1:numel(ids)); -callbackContext.appendStatus(sprintf( ... +callbackContext.log("info", ... + "focus_stack.sourcefiles.choosefolder.completed", sprintf( ... "Loaded %d focus image file(s) from folder.", numel(paths))); end diff --git a/apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m b/apps/image_measurement/focus_stack/+focus_stack/+syntheticInputs/writeSamplePack.m similarity index 92% rename from apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m rename to apps/image_measurement/focus_stack/+focus_stack/+syntheticInputs/writeSamplePack.m index 6263ad85d..0005662d3 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+debug/writeSamplePack.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: app debug launch and unit tests. Input +% Expected caller: app synthetic-input generation and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic focus-stack % sample pack. Side effects: writes anonymous debug images and records a % session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Focus Stack debug image files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end [base, detailMask] = baseScene(); @@ -37,7 +37,7 @@ artifacts{end} = sampleContext.artifact( ... "malformed", "boundaryInput", malformedPath, ... Expectation="rejects"); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-focus-stack", ... InitialProject=project, Artifacts=artifacts); end @@ -87,8 +87,8 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("focus_stack:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("focus_stack:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m index dd4527119..8c998583d 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m +++ b/apps/image_measurement/focus_stack/+focus_stack/+workbench/buildLayout.m @@ -54,10 +54,7 @@ labkit.app.layout.dataTable("resultTable", ... Title="Summary", Columns=["Metric" "Value"])}), ... labkit.app.layout.section("detailsSection", "Details", { ... - labkit.app.layout.statusPanel("details", Title="Details")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("logPanel", Title="Log")})})}; + labkit.app.layout.statusPanel("details", Title="Details")})})}; workspace = labkit.app.layout.workspace(labkit.app.layout.plotArea("preview", ... @focus_stack.focusPreview.draw, Title="Focus Stack Preview", ... Layout="pair", AxisIds=["fused" "focusMap"], ... diff --git a/apps/image_measurement/focus_stack/+focus_stack/definition.m b/apps/image_measurement/focus_stack/+focus_stack/definition.m index d03c51353..2711526b4 100644 --- a/apps/image_measurement/focus_stack/+focus_stack/definition.m +++ b/apps/image_measurement/focus_stack/+focus_stack/definition.m @@ -5,10 +5,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FocusStack_app", AppId="focus_stack", ... Title="Microscope Focus Stack Fusion", DisplayName="Focus Stack", ... - Family="Image Measurement", AppVersion="1.6.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + Family="Image Measurement", AppVersion="1.7.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=focus_stack.projectSpec(), CreateSession=@focus_stack.createSession, ... Workbench=focus_stack.workbench.buildLayout(), ... PresentWorkbench=@focus_stack.workbench.present, ... - BuildDebugSample=@focus_stack.debug.writeSamplePack); + BuildSyntheticSample=@focus_stack.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m index 8eb3c1434..458b6d036 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/applyDraft.m @@ -20,5 +20,6 @@ state.session.cache.previewResult = []; state.session.cache.previewResultKey = ""; state = image_enhance.enhancementPipeline.rebuildPreview(state); -context.appendStatus("Applied tool: " + string(step.label)); +context.log("info", "image_enhance.enhancementpipeline.applydraft.completed", ... + "Applied the selected enhancement tool."); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m index 47db8e456..8375618d8 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeBatchMode.m @@ -12,10 +12,12 @@ applicationState = ... image_enhance.enhancementPipeline.rebuildPreview(applicationState); if applicationState.project.parameters.batchMode - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "image_enhance.enhancementpipeline.changebatchmode.shared", ... "Enabled shared batch enhancement history."); else - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "image_enhance.enhancementpipeline.changebatchmode.per_image", ... "Enabled per-image enhancement history."); end end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m index 4068b39f9..93a9d173d 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/changeTool.m @@ -6,7 +6,9 @@ if ~isscalar(toolKind) || ... ~any(toolKind == ... string(image_enhance.enhancementPipeline.toolKinds())) - callbackContext.appendStatus("Ignored an unsupported enhancement tool."); + callbackContext.log("warning", ... + "image_enhance.enhancementpipeline.changetool.ignored", ... + "Ignored an unsupported enhancement tool."); return; end defaults = image_enhance.analysisRun.defaultStepValues(toolKind); diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m index 125aec48b..fa678f539 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/reset.m @@ -15,5 +15,7 @@ applicationState.session.cache.previewResultKey = ""; applicationState = ... image_enhance.enhancementPipeline.rebuildPreview(applicationState); -callbackContext.appendStatus("Reset enhancement history."); +callbackContext.log("info", ... + "image_enhance.enhancementpipeline.reset.completed", ... + "Reset enhancement history."); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m index 2b9bbf126..8489ca8bc 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+enhancementPipeline/undo.m @@ -18,6 +18,7 @@ applicationState.session.cache.previewResultKey = ""; applicationState = ... image_enhance.enhancementPipeline.rebuildPreview(applicationState); -callbackContext.appendStatus( ... - "Undid history step: " + string(removed.label)); +callbackContext.log("info", ... + "image_enhance.enhancementpipeline.undo.completed", ... + "Undid the latest enhancement step."); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m index efd93a1cc..80944f4be 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeMode.m @@ -8,7 +8,8 @@ applicationState.session.view.previewMode = mode; applicationState.session.view.roiEditing = false; else - callbackContext.appendStatus( ... + callbackContext.log("warning", ... + "image_enhance.imagepreview.changemode.ignored", ... "Ignored an unsupported image preview mode."); end end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m index 5e83a4d12..0fea126a3 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+imagePreview/changeWhiteRoi.m @@ -8,7 +8,9 @@ if index < 1 || index > numel(sources) || ... numel(position) ~= 4 || any(~isfinite(position)) || ... any(position(3:4) <= 0) - callbackContext.appendStatus("Ignored an invalid white ROI edit."); + callbackContext.log("warning", ... + "image_enhance.imagepreview.changewhiteroi.ignored", ... + "Ignored an invalid white ROI edit."); return; end sourceId = sources(index).id; diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m index 1087503a4..d168d4185 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/changeFormat.m @@ -4,7 +4,9 @@ %CHANGEFORMAT Select the batch image format and invalidate prior export. format = upper(string(format)); if ~isscalar(format) || ~any(format == ["PNG" "TIFF" "JPEG"]) - callbackContext.appendStatus("Ignored an unsupported export format."); + callbackContext.log("warning", ... + "image_enhance.resultfiles.changeformat.ignored", ... + "Ignored an unsupported export format."); return; end applicationState.project.parameters.exportFormat = format; diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m index 3795fa0d7..45ab92890 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/chooseOutputFolder.m @@ -5,7 +5,8 @@ choice = callbackContext.chooseOutputFolder( ... applicationState.project.parameters.outputFolder); if choice.Cancelled - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "image_enhance.resultfiles.chooseoutputfolder.cancelled", ... "Export folder selection cancelled."); return; end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m index ef5306e63..ee13d4f75 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+resultFiles/exportImages.m @@ -12,7 +12,9 @@ if strlength(folder) == 0 choice = callbackContext.chooseOutputFolder(""); if choice.Cancelled - callbackContext.appendStatus("Image export cancelled."); + callbackContext.log("info", ... + "image_enhance.resultfiles.exportimages.cancelled", ... + "Image export cancelled."); return; end folder = string(choice.Value); @@ -32,7 +34,8 @@ if ~isempty(applicationState.project.results.lastExport) && ... applicationState.project.results.lastExportFingerprint == ... task.fingerprint - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "image_enhance.resultfiles.exportimages.skipped", ... "Enhanced export is already up to date."); return; end @@ -49,10 +52,12 @@ ManifestName="image_enhance.labkit.json"); written = callbackContext.writeResultPackage(folder, package); catch ME - callbackContext.reportError("Export enhanced images", ME); + callbackContext.log("error", "image_enhance.resultfiles.exportimages.exception", "Export enhanced images", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Export failed"); - callbackContext.appendStatus( ... - "Image export failed: " + string(ME.message)); + callbackContext.log("error", ... + "image_enhance.resultfiles.exportimages.failed", ... + "Image export failed."); return; end payload.sourceIds = string({sources.id}); @@ -61,10 +66,15 @@ applicationState.project.results.lastExportFingerprint = task.fingerprint; applicationState.project.results.resultManifestPath = string(written.Value); statuses = string({payload.results.status}); -callbackContext.appendStatus(sprintf( ... - "Exported %d image(s), %d failed. Manifest: %s", ... - sum(statuses == "saved"), sum(statuses == "failed"), ... - char(payload.manifestPath))); +failedCount = sum(statuses == "failed"); +severity = "info"; +if failedCount > 0 + severity = "warning"; +end +callbackContext.log(severity, ... + "image_enhance.resultfiles.exportimages.completed", ... + sprintf("Exported %d image(s); %d failed.", ... + sum(statuses == "saved"), failedCount)); end function [items, steps, itemSteps] = exportSteps( ... diff --git a/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m index abc16e29e..303439da0 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+sourceLibrary/selectPreview.m @@ -22,7 +22,8 @@ paths = callbackContext.resolveSourcePaths(source); items = image_enhance.sourceFiles.readImages(paths); catch ME - callbackContext.reportError("Load image preview", ME); + callbackContext.log("error", "image_enhance.sourcelibrary.selectpreview.exception", "Load image preview", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not load image preview"); return; end diff --git a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m b/apps/image_measurement/image_enhance/+image_enhance/+syntheticInputs/writeAndLogSamplePack.m similarity index 64% rename from apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m rename to apps/image_measurement/image_enhance/+image_enhance/+syntheticInputs/writeAndLogSamplePack.m index 85303ebae..abc848797 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeAndLogSamplePack.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+syntheticInputs/writeAndLogSamplePack.m @@ -1,9 +1,9 @@ -% Expected caller: image_enhance.definition during debug launch. Inputs are a debug -% context and app log callback. Side effects: writes debug samples and logs +% Expected caller: image_enhance.definition during synthetic-input generation. Inputs are a debug +% context and app log callback. Side effects: writes synthetic inputs and logs % their artifact locations without mutating app state. function writeAndLogSamplePack(debugLog, addLog) try - pack = image_enhance.debug.writeSamplePack(debugLog); + pack = image_enhance.syntheticInputs.writeSamplePack(debugLog); addLog(sprintf('Debug sample files: %s', char(pack.sampleFolder))); addLog(sprintf('Debug output folder: %s', char(pack.outputFolder))); catch ME diff --git a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m b/apps/image_measurement/image_enhance/+image_enhance/+syntheticInputs/writeSamplePack.m similarity index 90% rename from apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m rename to apps/image_measurement/image_enhance/+image_enhance/+syntheticInputs/writeSamplePack.m index 347a18146..58bbd830f 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+debug/writeSamplePack.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: image_enhance.definition during debug launch and unit tests. +% Expected caller: image_enhance.definition during synthetic-input generation and unit tests. % Input is a LabKit debug context. Output is a deterministic synthetic image % enhancement sample pack. Side effects: writes anonymous debug images and % records a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Image Enhance debug image files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end sourceA = sampleContext.samplePath("image_enhance/uneven.png"); @@ -24,7 +24,7 @@ "image1", "source-image", sourceA, true), ... sampleContext.sourceRecord( ... "image2", "source-image", sourceB, true)]; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-enhancement", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("uneven", "source-image", sourceA), ... @@ -67,8 +67,8 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("image_enhance:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("image_enhance:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m index 578f7f3a8..080f7b925 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m +++ b/apps/image_measurement/image_enhance/+image_enhance/+workbench/buildLayout.m @@ -96,9 +96,6 @@ labkit.app.layout.section("currentImageSection", "Current Image", { ... labkit.app.layout.dataTable("metricsTable", ... Title="Current Image", Columns=["Metric" "Value"])})}); -logTab = labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("logPanel", Title="Log")})}); interactions = {labkit.app.interaction.rectangle( ... "whiteRoi", @image_enhance.imagePreview.changeWhiteRoi, ... Axis="image", ViewportPolicy="preserve")}; @@ -111,5 +108,5 @@ Interactions=interactions); workspace = labkit.app.layout.workspace(preview, Title="Preview"); layout = labkit.app.layout.workbench( ... - {libraryTab, toolsTab, logTab}, Workspace=workspace); + {libraryTab, toolsTab}, Workspace=workspace); end diff --git a/apps/image_measurement/image_enhance/+image_enhance/definition.m b/apps/image_measurement/image_enhance/+image_enhance/definition.m index 36c864f65..7050d8806 100644 --- a/apps/image_measurement/image_enhance/+image_enhance/definition.m +++ b/apps/image_measurement/image_enhance/+image_enhance/definition.m @@ -4,9 +4,9 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_ImageEnhance_app", ... AppId="image_enhance", Title="Paper Image Enhance", DisplayName="Image Enhance", ... - Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + Family="Image Measurement", AppVersion="1.8.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_enhance.projectSpec(), CreateSession=@image_enhance.createSession, ... Workbench=image_enhance.workbench.buildLayout(), PresentWorkbench=@image_enhance.workbench.present, ... - BuildDebugSample=@image_enhance.debug.writeSamplePack); + BuildSyntheticSample=@image_enhance.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m b/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m index 0c549176b..cb2346524 100644 --- a/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m +++ b/apps/image_measurement/image_match/+image_match/+imagePreview/changeMode.m @@ -7,7 +7,8 @@ any(previewMode == ["Matched" "Original" "Before | After"]) applicationState.session.view.previewMode = previewMode; else - callbackContext.appendStatus( ... + callbackContext.log("warning", ... + "image_match.imagepreview.changemode.ignored", ... "Ignored an unsupported image-match preview mode."); end end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m index c0d77e783..0cda447e1 100644 --- a/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/apply.m @@ -24,5 +24,6 @@ image_match.matchPipeline.invalidateResults(applicationState); applicationState.session.cache = ... image_match.matchPipeline.refreshPreview(cache, steps); -callbackContext.appendStatus("Applied match: " + string(step.label)); +callbackContext.log("info", "image_match.matchpipeline.apply.completed", ... + "Applied the selected image match."); end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m index 4721bc6c2..d184f87f5 100644 --- a/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/reset.m @@ -8,5 +8,6 @@ image_match.matchPipeline.invalidateResults(applicationState); applicationState.session.cache = image_match.matchPipeline.refreshPreview( ... applicationState.session.cache, steps); -callbackContext.appendStatus("Reset match history."); +callbackContext.log("info", "image_match.matchpipeline.reset.completed", ... + "Reset match history."); end diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m index d6a1f6aff..693ae6893 100644 --- a/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/settingsChanged.m @@ -7,7 +7,8 @@ if ~isscalar(method) || ... ~any(method == string(image_match.matchPipeline.methods())) method = "Balanced"; - callbackContext.appendStatus( ... + callbackContext.log("warning", ... + "image_match.matchpipeline.settingschanged.repaired", ... "Reset an unsupported match method to Balanced."); end applicationState.project.parameters.matchMethod = method; diff --git a/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m b/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m index 30e0644ba..a2152c77c 100644 --- a/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m +++ b/apps/image_measurement/image_match/+image_match/+matchPipeline/undo.m @@ -15,6 +15,6 @@ image_match.matchPipeline.invalidateResults(applicationState); applicationState.session.cache = image_match.matchPipeline.refreshPreview( ... applicationState.session.cache, steps); -callbackContext.appendStatus( ... - "Undid match step: " + string(removed.label)); +callbackContext.log("info", "image_match.matchpipeline.undo.completed", ... + "Undid the latest match step."); end diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m b/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m index ef03cdca3..47f022c6c 100644 --- a/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/changeFormat.m @@ -4,7 +4,9 @@ %CHANGEFORMAT Select the matched-image batch format. format = upper(string(format)); if ~isscalar(format) || ~any(format == ["PNG" "TIFF" "JPEG"]) - callbackContext.appendStatus("Ignored an unsupported export format."); + callbackContext.log("warning", ... + "image_match.resultfiles.changeformat.ignored", ... + "Ignored an unsupported export format."); return; end applicationState.project.parameters.exportFormat = format; diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m b/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m index 1084f5b69..a2e0acc0c 100644 --- a/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/chooseOutputFolder.m @@ -5,7 +5,9 @@ choice = callbackContext.chooseOutputFolder( ... applicationState.project.parameters.outputFolder); if choice.Cancelled - callbackContext.appendStatus("Export folder selection cancelled."); + callbackContext.log("info", ... + "image_match.resultfiles.chooseoutputfolder.cancelled", ... + "Export folder selection cancelled."); return; end applicationState.project.parameters.outputFolder = string(choice.Value); diff --git a/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m b/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m index e7af0fd91..a8b694b77 100644 --- a/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m +++ b/apps/image_measurement/image_match/+image_match/+resultFiles/exportImages.m @@ -14,7 +14,9 @@ if strlength(folder) == 0 choice = callbackContext.chooseOutputFolder(""); if choice.Cancelled - callbackContext.appendStatus("Image-match export cancelled."); + callbackContext.log("info", ... + "image_match.resultfiles.exportimages.cancelled", ... + "Image-match export cancelled."); return; end folder = string(choice.Value); @@ -35,7 +37,8 @@ if ~isempty(applicationState.project.results.lastExport) && ... applicationState.project.results.lastExportFingerprint == ... task.fingerprint - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "image_match.resultfiles.exportimages.skipped", ... "Matched export is already up to date."); return; end @@ -51,10 +54,12 @@ ManifestName="image_match.labkit.json"); written = callbackContext.writeResultPackage(folder, package); catch ME - callbackContext.reportError("Export matched images", ME); + callbackContext.log("error", "image_match.resultfiles.exportimages.exception", "Export matched images", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Export failed"); - callbackContext.appendStatus( ... - "Image-match export failed: " + string(ME.message)); + callbackContext.log("error", ... + "image_match.resultfiles.exportimages.failed", ... + "Image-match export failed."); return; end payload.sourceIds = string({sources.id}); @@ -63,8 +68,16 @@ applicationState.project.results.lastExport = payload; applicationState.project.results.lastExportFingerprint = task.fingerprint; applicationState.project.results.resultManifestPath = string(written.Value); -callbackContext.appendStatus( ... - "Exported matched images: " + payload.manifestPath); +statuses = string({payload.results.status}); +failedCount = sum(statuses == "failed"); +severity = "info"; +if failedCount > 0 + severity = "warning"; +end +callbackContext.log(severity, ... + "image_match.resultfiles.exportimages.completed", ... + sprintf("Exported %d matched image(s); %d failed.", ... + sum(statuses == "saved"), failedCount)); end function outputs = packageOutputs(payload) diff --git a/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m b/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m index 2acf63009..9e8206e4c 100644 --- a/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m +++ b/apps/image_measurement/image_match/+image_match/+sourceFiles/referenceSelected.m @@ -18,6 +18,8 @@ applicationState = ... image_match.matchPipeline.rebuildPreview(applicationState); if isempty(listSelection.Indices) && isempty(reference) - callbackContext.appendStatus("Reference image cleared."); + callbackContext.log("info", ... + "image_match.sourcefiles.referenceselected.cleared", ... + "Reference image cleared."); end end diff --git a/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m b/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m index 7ec427fbf..a934c35b0 100644 --- a/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m +++ b/apps/image_measurement/image_match/+image_match/+sourceFiles/selectPreview.m @@ -25,7 +25,8 @@ paths = callbackContext.resolveSourcePaths(sources(index)); items = image_match.sourceFiles.readImages(paths); catch ME - callbackContext.reportError("Load image-match preview", ME); + callbackContext.log("error", "image_match.sourcefiles.selectpreview.exception", "Load image-match preview", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Could not load source image"); return; end diff --git a/apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m b/apps/image_measurement/image_match/+image_match/+syntheticInputs/writeSamplePack.m similarity index 90% rename from apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m rename to apps/image_measurement/image_match/+image_match/+syntheticInputs/writeSamplePack.m index 7dd9d993d..06d8a3fe9 100644 --- a/apps/image_measurement/image_match/+image_match/+debug/writeSamplePack.m +++ b/apps/image_measurement/image_match/+image_match/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: image_match.definition during debug launch and unit tests. Input +% Expected caller: image_match.definition during synthetic-input generation and unit tests. Input % is a LabKit debug context. Output is a deterministic synthetic reference % matching sample pack. Side effects: writes anonymous debug images and % records a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Image Match debug image files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end referencePath = sampleContext.samplePath("image_match/reference.png"); @@ -29,7 +29,7 @@ "image1", "source-image", sourceWarmPath, true), ... sampleContext.sourceRecord( ... "image2", "source-image", sourceDimPath, true)]; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-image-match", InitialProject=project, ... Artifacts={ ... sampleContext.artifact( ... @@ -71,8 +71,8 @@ function writeTextFile(filepath, text) fid = fopen(char(filepath), "w", "n", "UTF-8"); if fid < 0 - error("image_match:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("image_match:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", char(text)); diff --git a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m index 167ae4b52..957e5e15e 100644 --- a/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m +++ b/apps/image_measurement/image_match/+image_match/+workbench/buildLayout.m @@ -90,9 +90,6 @@ "currentImageSection", "Current Image", { ... labkit.app.layout.dataTable("metricsTable", ... Title="Current Image", Columns=["Metric" "Value"])})}); -logTab = labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("logPanel", Title="Log")})}); preview = labkit.app.layout.plotArea( ... "preview", @image_match.imagePreview.draw, ... Title="Preview", Layout="single", ... @@ -101,7 +98,7 @@ OnValueChanged=@image_match.imagePreview.changeMode); workspace = labkit.app.layout.workspace(preview, Title="Preview"); layout = labkit.app.layout.workbench( ... - {libraryTab, matchTab, logTab}, Workspace=workspace); + {libraryTab, matchTab}, Workspace=workspace); end function node = strength(id, label) diff --git a/apps/image_measurement/image_match/+image_match/definition.m b/apps/image_measurement/image_match/+image_match/definition.m index ad5762c93..fa62cd7dc 100644 --- a/apps/image_measurement/image_match/+image_match/definition.m +++ b/apps/image_measurement/image_match/+image_match/definition.m @@ -5,10 +5,10 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ImageMatch_app", AppId="image_match", ... Title="Paper Image Match", DisplayName="Image Match", ... - Family="Image Measurement", AppVersion="1.7.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "image", ">=2.0 <3"), ... + Family="Image Measurement", AppVersion="1.8.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "image", ">=2.0 <3"), ... ProjectSchema=image_match.projectSpec(), CreateSession=@image_match.createSession, ... Workbench=image_match.workbench.buildLayout(), ... PresentWorkbench=@image_match.workbench.present, ... - BuildDebugSample=@image_match.debug.writeSamplePack); + BuildSyntheticSample=@image_match.syntheticInputs.writeSamplePack); end diff --git a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m index 8e6ce958c..5bf6c158d 100644 --- a/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m +++ b/apps/image_measurement/video_marker/+video_marker/+frameNavigation/changeFrame.m @@ -31,7 +31,8 @@ state.session.cache.currentImage, ... numel(state.project.annotations.skeleton.pointIds)); catch cause - context.reportError("Could not read video frame", cause); + context.log("error", "video_marker.framenavigation.changeframe.exception", "Could not read video frame", ... + Category="failure", Audience="developer", Exception=cause); context.alert(cause.message, "Could not read frame"); state.session.selection.currentFrame = startFrame; return @@ -50,11 +51,13 @@ state.session.view.scaleBar = []; state = video_marker.resultFiles.clearExportState(state); if report.predictedFrames > 0 - context.appendStatus("Predicted " + string(report.predictedFrames) + ... + context.log("info", "video_marker.framenavigation.changeframe.predicted", ... + "Predicted " + string(report.predictedFrames) + ... " frame(s) through frame " + string(target) + "; " + ... string(report.fallbackPoints) + ... " point(s) used motion fallback."); else - context.appendStatus("Moved to frame " + string(target) + "."); + context.log("info", "video_marker.framenavigation.changeframe.moved", ... + "Moved to frame " + string(target) + "."); end end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m index 4329dad8d..0043f2fc9 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/changePoints.m @@ -18,6 +18,7 @@ points = points(1:min(size(points, 1), total), :); frame = state.session.cache.frameIndex; state = video_marker.markerEditing.setPoints(state, points); -context.appendStatus("Frame " + string(frame) + " points: " + ... +context.log("info", "video_marker.markerediting.changepoints.status", ... + "Frame " + string(frame) + " points: " + ... string(size(points, 1)) + " / " + string(total) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m index b4079f3bf..35c1c8617 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/clear.m @@ -6,5 +6,6 @@ end frame = state.session.cache.frameIndex; state = video_marker.markerEditing.setPoints(state, zeros(0, 2)); -context.appendStatus("Cleared frame " + string(frame) + " points."); +context.log("info", "video_marker.markerediting.clear.status", ... + "Cleared frame " + string(frame) + " points."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m index 3dbef4ba6..8db588dc8 100644 --- a/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m +++ b/apps/image_measurement/video_marker/+video_marker/+markerEditing/undo.m @@ -8,5 +8,6 @@ points(end, :) = []; frame = state.session.cache.frameIndex; state = video_marker.markerEditing.setPoints(state, points); -context.appendStatus("Undid last point on frame " + string(frame) + "."); +context.log("info", "video_marker.markerediting.undo.status", ... + "Undid the last point on frame " + string(frame) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m index c0e2ae090..e6cce3c47 100644 --- a/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportCoordinates.m @@ -11,7 +11,9 @@ choice = context.chooseOutputFile( ... ["*.csv", "Coordinate CSV files"], startPath); if choice.Cancelled - context.appendStatus("Coordinate export cancelled."); + context.log("info", ... + "video_marker.resultfiles.exportcoordinates.cancelled", ... + "Coordinate export cancelled."); return end filepath = string(choice.Value); @@ -32,12 +34,15 @@ written = writeManifest(state, context, filepath, ... "coordinateCsv", "video_marker_coordinates.labkit.json"); catch cause - context.reportError("Could not export coordinate CSV", cause); + context.log("error", "video_marker.resultfiles.exportcoordinates.exception", "Could not export coordinate CSV", ... + Category="failure", Audience="developer", Exception=cause); context.alert(cause.message, "Could not export coordinate CSV"); return end state.project.results.coordinateManifestPath = string(written.Value); -context.appendStatus("Exported coordinate CSV: " + filepath); +context.log("info", ... + "video_marker.resultfiles.exportcoordinates.completed", ... + "Exported the coordinate CSV."); end function written = writeManifest(state, context, filepath, id, manifestName) diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m index 2c6e140b6..71f2b9008 100644 --- a/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/exportMarkers.m @@ -11,7 +11,9 @@ choice = context.chooseOutputFile( ... ["*.csv", "Marker CSV files"], startPath); if choice.Cancelled - context.appendStatus("Marker export cancelled."); + context.log("info", ... + "video_marker.resultfiles.exportmarkers.cancelled", ... + "Marker export cancelled."); return end filepath = string(choice.Value); @@ -33,10 +35,12 @@ ManifestName="video_marker_markers.labkit.json"); written = context.writeResultPackage(folder, package); catch cause - context.reportError("Could not export marker CSV", cause); + context.log("error", "video_marker.resultfiles.exportmarkers.exception", "Could not export marker CSV", ... + Category="failure", Audience="developer", Exception=cause); context.alert(cause.message, "Could not export marker CSV"); return end state.project.results.markerManifestPath = string(written.Value); -context.appendStatus("Exported marker CSV: " + filepath); +context.log("info", "video_marker.resultfiles.exportmarkers.completed", ... + "Exported the marker CSV."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m index 856569c47..f165ff1ad 100644 --- a/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m +++ b/apps/image_measurement/video_marker/+video_marker/+resultFiles/importMarkers.m @@ -4,14 +4,17 @@ choice = context.chooseInputFile( ... ["*.csv", "Marker CSV files"], pwd); if choice.Cancelled - context.appendStatus("Marker import cancelled."); + context.log("info", ... + "video_marker.resultfiles.importmarkers.cancelled", ... + "Marker import cancelled."); return end filepath = string(choice.Value); try payload = video_marker.markerCsv.readFile(filepath); catch cause - context.reportError("Could not import marker CSV", cause); + context.log("error", "video_marker.resultfiles.importmarkers.exception", "Could not import marker CSV", ... + Category="failure", Audience="developer", Exception=cause); context.alert(cause.message, "Could not import marker CSV"); return end @@ -44,5 +47,6 @@ state.project.parameters.coordinateEndFrame = ... max(1, payload.videoInfo.frameCount); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Imported marker CSV: " + filepath); +context.log("info", "video_marker.resultfiles.importmarkers.completed", ... + "Imported the marker CSV."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m index 3e5848ebd..2b82b4aec 100644 --- a/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m +++ b/apps/image_measurement/video_marker/+video_marker/+scaleCalibration/placeBar.m @@ -16,7 +16,8 @@ parameters.scaleBarColor); state.session.workflow.scaleReferenceEditing = false; catch cause - context.reportError("Could not place scale bar", cause); + context.log("error", "video_marker.scalecalibration.placebar.exception", "Could not place scale bar", ... + Category="failure", Audience="developer", Exception=cause); context.alert(cause.message, "Could not place scale bar"); end end diff --git a/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m b/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m index aa79e37ee..5eaeaba51 100644 --- a/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m +++ b/apps/image_measurement/video_marker/+video_marker/+sessionControl/newSetup.m @@ -9,30 +9,37 @@ DefaultChoice="Save and start new", CancelChoice="Cancel"); answer = string(choice.Value); if choice.Cancelled || answer == "Cancel" - callbackContext.appendStatus("New setup cancelled."); + callbackContext.log("info", ... + "video_marker.sessioncontrol.newsetup.cancelled", ... + "New setup cancelled."); return end if answer == "Save and start new" destination = callbackContext.chooseOutputFile( ... ["*.mat", "LabKit project files"], "video-marker-project.mat"); if destination.Cancelled - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "video_marker.sessioncontrol.newsetup.save_cancelled", ... "New setup cancelled because project save was cancelled."); return end saved = callbackContext.saveProjectDocument( ... applicationState, destination.Value); if saved.Cancelled - callbackContext.appendStatus( ... + callbackContext.log("info", ... + "video_marker.sessioncontrol.newsetup.save_cancelled", ... "New setup cancelled because project save was cancelled."); return end elseif answer ~= "Discard and start new" - callbackContext.appendStatus("New setup cancelled."); + callbackContext.log("info", ... + "video_marker.sessioncontrol.newsetup.cancelled", ... + "New setup cancelled."); return end callbackContext.clearResourceScope("document"); applicationState = callbackContext.newProjectDocument(); -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "video_marker.sessioncontrol.newsetup.completed", ... "Started a new skeleton setup and cleared the annotation session."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m b/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m index 609c28607..086a46bef 100644 --- a/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m +++ b/apps/image_measurement/video_marker/+video_marker/+sessionControl/openProject.m @@ -4,7 +4,9 @@ choice = callbackContext.chooseInputFile( ... ["*.mat", "LabKit project files"], ""); if choice.Cancelled - callbackContext.appendStatus("Project open cancelled."); + callbackContext.log("info", ... + "video_marker.sessioncontrol.openproject.cancelled", ... + "Project open cancelled."); return end filepath = string(choice.Value); @@ -12,9 +14,12 @@ applicationState = ... callbackContext.restoreProjectDocument(filepath); catch cause - callbackContext.reportError("Could not open Video Marker project", cause); + callbackContext.log("error", "video_marker.sessioncontrol.openproject.exception", "Could not open Video Marker project", ... + Category="failure", Audience="developer", Exception=cause); callbackContext.alert(cause.message, "Could not open project"); return end -callbackContext.appendStatus("Opened project: " + filepath); +callbackContext.log("info", ... + "video_marker.sessioncontrol.openproject.completed", ... + "Opened a Video Marker project."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m b/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m index 0b5c549d8..7d1bec0e1 100644 --- a/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m +++ b/apps/image_measurement/video_marker/+video_marker/+sessionControl/saveAutosave.m @@ -3,7 +3,8 @@ %SAVEAUTOSAVE Write the deterministic source-adjacent autosave document. videoPath = applicationState.session.cache.videoPath; if strlength(videoPath) == 0 - callbackContext.appendStatus( ... + callbackContext.log("warning", ... + "video_marker.sessioncontrol.saveautosave.unavailable", ... "Autosave unavailable until a video is open."); return end @@ -18,11 +19,17 @@ end callbackContext.saveRecoveryDocument(applicationState, filepath); catch cause - callbackContext.reportError( ... - "Could not save Video Marker autosave", cause); + callbackContext.log("error", ... + "video_marker.sessioncontrol.saveautosave.exception", ... + "Could not save Video Marker autosave", ... + Category="failure", Audience="developer", Exception=cause); callbackContext.alert(cause.message, "Could not save autosave"); - callbackContext.appendStatus("Autosave failed: " + cause.message); + callbackContext.log("error", ... + "video_marker.sessioncontrol.saveautosave.failed", ... + "Autosave failed."); return end -callbackContext.appendStatus("Autosave updated: " + filepath); +callbackContext.log("info", ... + "video_marker.sessioncontrol.saveautosave.completed", ... + "Autosave updated."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m index ff78536f2..5a3b84328 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addConnection.m @@ -17,5 +17,6 @@ state.session.selection.selectedEdgeIndex = ... size(state.project.annotations.skeleton.edges, 1); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Connected " + names(a) + " to " + names(b) + "."); +context.log("info", "video_marker.skeletonsetup.addconnection.status", ... + "Added a keypoint connection."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m index 19e27e33f..243d198f5 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/addKeypoint.m @@ -10,6 +10,6 @@ state.session.selection.selectedPointIndex = index; state = video_marker.skeletonSetup.normalizeSelection(state); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Added keypoint " + ... - state.project.annotations.skeleton.pointNames(index) + "."); +context.log("info", "video_marker.skeletonsetup.addkeypoint.status", ... + "Added keypoint " + string(index) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m index 85272bdd3..5a73767f8 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/connectInOrder.m @@ -10,5 +10,6 @@ state.project.annotations.skeleton); state.session.selection.selectedEdgeIndex = 0; state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Connected adjacent keypoints in order."); +context.log("info", "video_marker.skeletonsetup.connectinorder.status", ... + "Connected adjacent keypoints in order."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m index 416e895b4..544bb3ab0 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveDown.m @@ -11,6 +11,7 @@ state.session.selection.selectedPointIndex, 1); state = video_marker.skeletonSetup.normalizeSelection(state); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Moved keypoint to position " + ... +context.log("info", "video_marker.skeletonsetup.movedown.status", ... + "Moved keypoint to position " + ... string(state.session.selection.selectedPointIndex) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m index 1234cbab5..2e302daf0 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/moveUp.m @@ -11,6 +11,7 @@ state.session.selection.selectedPointIndex, -1); state = video_marker.skeletonSetup.normalizeSelection(state); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Moved keypoint to position " + ... +context.log("info", "video_marker.skeletonsetup.moveup.status", ... + "Moved keypoint to position " + ... string(state.session.selection.selectedPointIndex) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m index acabf7f7f..2ece4546b 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeConnection.m @@ -12,5 +12,6 @@ state.session.selection.selectedEdgeIndex = min(index, ... size(state.project.annotations.skeleton.edges, 1)); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Removed connection " + string(index) + "."); +context.log("info", "video_marker.skeletonsetup.removeconnection.status", ... + "Removed connection " + string(index) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m index 7fed975da..777717f57 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/removeKeypoint.m @@ -15,5 +15,6 @@ state.session.selection.selectedEdgeIndex = 0; state = video_marker.skeletonSetup.normalizeSelection(state); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Removed keypoint " + string(index) + "."); +context.log("info", "video_marker.skeletonsetup.removekeypoint.status", ... + "Removed keypoint " + string(index) + "."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m index dff76a61a..dbca16d7d 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/renameKeypoint.m @@ -7,9 +7,11 @@ state.project.annotations.skeleton,edit.RowIndex,edit.NewValue); state=video_marker.skeletonSetup.normalizeSelection(state); state=video_marker.resultFiles.clearExportState(state); - context.appendStatus("Renamed keypoint " + string(edit.RowIndex) + "."); + context.log("info", "video_marker.skeletonsetup.renamekeypoint.status", ... + "Renamed keypoint " + string(edit.RowIndex) + "."); catch ME - context.reportError("Invalid keypoint name", ME); + context.log("error", "video_marker.skeletonsetup.renamekeypoint.exception", "Invalid keypoint name", ... + Category="failure", Audience="developer", Exception=ME); context.alert(ME.message, "Invalid keypoint name"); end end diff --git a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m index f4539fd4c..02f4362b4 100644 --- a/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m +++ b/apps/image_measurement/video_marker/+video_marker/+skeletonSetup/usePreset.m @@ -15,6 +15,6 @@ presets(match).pointNames, presets(match).edges); state = video_marker.skeletonSetup.normalizeSelection(state, true); state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Applied skeleton preset: " + ... - string(presets(match).label) + "."); +context.log("info", "video_marker.skeletonsetup.usepreset.status", ... + "Applied the selected skeleton preset."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m b/apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m similarity index 91% rename from apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m rename to apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m index 618f59baa..7d657a246 100644 --- a/apps/image_measurement/video_marker/+video_marker/+debug/writeSamplePack.m +++ b/apps/image_measurement/video_marker/+video_marker/+syntheticInputs/writeSamplePack.m @@ -1,10 +1,10 @@ -% App-owned implementation for video_marker.debug.writeSamplePack within the video_marker product workflow. +% App-owned implementation for video_marker.syntheticInputs.writeSamplePack within the video_marker product workflow. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Create a typed synthetic Video Marker reproduction. % Expected caller: diagnostic startup and focused sample-pack tests. The % generated video and project contain no user paths, identifiers, or lab data. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end videoPath = sampleContext.samplePath( ... @@ -31,7 +31,7 @@ "video_marker/video_marker_markers.csv"); coordinateOutput = sampleContext.outputPath( ... "video_marker/video_marker_coordinates.csv"); -pack = labkit.app.diagnostic.SamplePack( ... +pack = labkit.app.synthetic.Pack( ... Scenario="representative-video-marking", ... InitialProject=project, ... Artifacts={ ... diff --git a/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m b/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m index 16eaeba37..79d475978 100644 --- a/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m +++ b/apps/image_measurement/video_marker/+video_marker/+videoSource/selectionChanged.m @@ -19,7 +19,8 @@ state.project.parameters.coordinateStartFrame = 1; state.project.parameters.coordinateEndFrame = 1; state = video_marker.resultFiles.clearExportState(state); - context.appendStatus("No video loaded."); + context.log("info", "video_marker.videosource.selectionchanged.cleared", ... + "No video loaded."); return end frames = state.project.annotations.frames; @@ -36,6 +37,7 @@ state.project.parameters.coordinateStartFrame = 1; state.project.parameters.coordinateEndFrame = info.frameCount; state = video_marker.resultFiles.clearExportState(state); -context.appendStatus("Opened video with " + string(info.frameCount) + ... +context.log("info", "video_marker.videosource.selectionchanged.opened", ... + "Opened a video with " + string(info.frameCount) + ... " frame(s)."); end diff --git a/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m b/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m index 78dcded47..7bc4c6976 100644 --- a/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m +++ b/apps/image_measurement/video_marker/+video_marker/+workbench/buildLayout.m @@ -149,10 +149,7 @@ @video_marker.videoPreview.draw, Title="Video Preview", ... AxisIds="video", AxisTitles="Frame + Skeleton", ... Interactions={markers, scale}), Title="Video Preview"); -controls = {setup, video, exports, ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; +controls = {setup, video, exports}; usage = [ ... "1. Use an editable preset or add and name ordered keypoints, then add connections.", ... "2. Open a video, click points in table order, and drag existing points to refine.", ... diff --git a/apps/image_measurement/video_marker/+video_marker/definition.m b/apps/image_measurement/video_marker/+video_marker/definition.m index 67b85e722..b6c704381 100644 --- a/apps/image_measurement/video_marker/+video_marker/definition.m +++ b/apps/image_measurement/video_marker/+video_marker/definition.m @@ -5,12 +5,12 @@ app = labkit.app.Definition( ... Entrypoint="labkit_VideoMarker_app", AppId="video_marker", ... Title="Video Marker", DisplayName="Video Marker", ... - Family="Image Measurement", AppVersion="1.6.1", ... - Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Family="Image Measurement", AppVersion="1.7.0", ... + Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=video_marker.projectSpec(), ... CreateSession=@video_marker.createSession, ... Workbench=video_marker.workbench.buildLayout(), ... PresentWorkbench=@video_marker.workbench.present, ... - BuildDebugSample=@video_marker.debug.writeSamplePack); + BuildSyntheticSample=@video_marker.syntheticInputs.writeSamplePack); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m index 8569c9ff3..789f8e8b8 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/chooseOutputFolder.m @@ -16,6 +16,6 @@ state.project.parameters.outputFolder = string(chosen.Value); state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; -callbackContext.appendStatus( ... - "Output folder: " + state.project.parameters.outputFolder); +callbackContext.log("info", "figure_studio.resultfiles.chooseoutputfolder.status", ... + "Selected the Figure Studio output folder."); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m index 3e19c0e1a..f0b8e3d4f 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportCurrent.m @@ -37,7 +37,8 @@ "kind", "package", "path", folder, ... "manifestPath", string(written.Value), "outputs", payload); state.project.results.resultManifestPath = string(written.Value); -callbackContext.appendStatus("Exported package: " + folder); +callbackContext.log("info", "figure_studio.resultfiles.exportcurrent.status", ... + "Exported the Figure Studio package."); end function outputs = packageOutputs(payload) diff --git a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m index 817b42510..a2cd21b0d 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+resultFiles/exportGraphic.m @@ -50,8 +50,8 @@ "kind", format, "path", filepath, ... "manifestPath", string(written.Value)); state.project.results.resultManifestPath = string(written.Value); -callbackContext.appendStatus( ... - "Exported " + upper(format) + ": " + filepath); +callbackContext.log("info", "figure_studio.resultfiles.exportgraphic.status", ... + "Exported the current graphic."); end function startPath = quickExportStartPath(state) diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/limitChanged.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/limitChanged.m index 07a6f0473..523d612c7 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/limitChanged.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/limitChanged.m @@ -32,7 +32,7 @@ end state.session.cache.limitState = controls; state.session.workflow.status = "Keep each axis minimum below its maximum within the displayed data envelope."; - callbackContext.appendStatus(state.session.workflow.status); + callbackContext.log("info", "figure_studio.sourceaxes.limitchanged.status", state.session.workflow.status); return; end state.session.cache.plotData.axes.(char(axisName)) = pair; @@ -44,5 +44,5 @@ state.session.workflow.status = "Applied editable X/Y limits."; state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; -callbackContext.appendStatus(state.session.workflow.status); +callbackContext.log("info", "figure_studio.sourceaxes.limitchanged.status", state.session.workflow.status); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m index b19fc8bde..32fd12f55 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/panelChanged.m @@ -27,7 +27,8 @@ state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; state.session.workflow.status = "Editing " + panelLabel + "."; -callbackContext.appendStatus("Selected FIG " + panelLabel + "."); +callbackContext.log("info", "figure_studio.sourceaxes.panelchanged.status", ... + "Selected a figure panel."); end function limits = emptyLimitOverrides() diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/recalculateLimits.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/recalculateLimits.m index c22a57313..ed8d79107 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/recalculateLimits.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/recalculateLimits.m @@ -30,7 +30,7 @@ state.session.workflow.status = "Recalculated X/Y limits from visible graphics."; state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; -callbackContext.appendStatus(state.session.workflow.status); +callbackContext.log("info", "figure_studio.sourceaxes.recalculatelimits.status", state.session.workflow.status); end function deleteIfValid(fig) diff --git a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m index ede249915..8fa17db0c 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+sourceAxes/selectionChanged.m @@ -54,7 +54,9 @@ string(extension) + " — " + panelLabel + "."; state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; -callbackContext.appendStatus("Opened FIG: " + sourcePath); +callbackContext.log("info", ... + "figure_studio.sourceaxes.selectionchanged.status", ... + "Opened the selected FIG source."); end function state = adoptSourceStyle(state, sourceStyle) diff --git a/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m index 0414c73c0..064a4c0a1 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+styleLibrary/applyPreset.m @@ -29,7 +29,7 @@ state.session.workflow.status = "Styled with " + preset + "."; state.project.results.lastExport = []; state.project.results.resultManifestPath = ""; -callbackContext.appendStatus("Selected style mode: " + preset); +callbackContext.log("info", "figure_studio.stylelibrary.applypreset.status", "Selected style mode: " + preset); end function value = onOff(tf) diff --git a/apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m b/apps/labkit_core/figure_studio/+figure_studio/+syntheticInputs/writeSamplePack.m similarity index 89% rename from apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m rename to apps/labkit_core/figure_studio/+figure_studio/+syntheticInputs/writeSamplePack.m index 087c115a9..6e95261b6 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+debug/writeSamplePack.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: Figure Studio direct callbacks during debug launch and +% Expected caller: Figure Studio direct callbacks during synthetic-input generation and % unit guardrails. Input is a LabKit debug context. Output is a deterministic % synthetic FIG sample pack. Side effects: writes anonymous debug FIG files % and records a session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Figure Studio debug FIG files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end figPath = sampleContext.samplePath("figure_studio/source.fig"); @@ -14,7 +14,7 @@ project = figure_studio.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "figure1", "figure", figPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-figure", InitialProject=project, ... Artifacts={sampleContext.artifact( ... "sourceFigure", "figure", figPath)}); diff --git a/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m b/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m index b9a86d341..c381a7b6a 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m +++ b/apps/labkit_core/figure_studio/+figure_studio/+workbench/buildLayout.m @@ -7,10 +7,7 @@ figure_studio.styleLibrary.lineLayoutSection(), ... figure_studio.styleLibrary.legendLayoutSection()}), ... labkit.app.layout.tab("export", "Export", { ... - figure_studio.resultFiles.layoutSection()}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; + figure_studio.resultFiles.layoutSection()})}; workspace = labkit.app.layout.workspace( ... labkit.app.layout.plotArea("preview", ... @figure_studio.sourceAxes.drawPreview, ... diff --git a/apps/labkit_core/figure_studio/+figure_studio/definition.m b/apps/labkit_core/figure_studio/+figure_studio/definition.m index 43b18c69f..5684b6b70 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/definition.m +++ b/apps/labkit_core/figure_studio/+figure_studio/definition.m @@ -5,12 +5,12 @@ app = labkit.app.Definition( ... Entrypoint="labkit_FigureStudio_app", AppId="figure_studio", ... Title="Figure Studio", Family="LabKit Core", ... - AppVersion="0.6.5", Updated="2026-07-22", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + AppVersion="0.7.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=figure_studio.projectSpec(), ... CreateSession=@figure_studio.createSession, ... OnStart=@figure_studio.initializeWorkbench, ... Workbench=figure_studio.workbench.buildLayout(), ... PresentWorkbench=@figure_studio.workbench.present, ... - BuildDebugSample=@figure_studio.debug.writeSamplePack); + BuildSyntheticSample=@figure_studio.syntheticInputs.writeSamplePack); end diff --git a/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m b/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m index f77d60e65..825f8a79a 100644 --- a/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m +++ b/apps/labkit_core/figure_studio/+figure_studio/initializeWorkbench.m @@ -20,6 +20,6 @@ state.project.annotations, "transientSourceAxes"); end if ~isempty(state.session.cache.plotData) - callbackContext.appendStatus("Restored Figure Studio source."); + callbackContext.log("info", "figure_studio.initializeworkbench.status", "Restored Figure Studio source."); end end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m index 8fe2c5944..0131850a0 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+analysisRun/runSession.m @@ -15,7 +15,8 @@ state.session.cache.analysis = nerve_response_analysis.analysisRun.analyzeSession( ... state.session.cache.filterRecord, state.session.cache.protocol, options); catch ME - context.reportError("Analyze nerve response", ME); + context.log("error", "nerve_response_analysis.analysisrun.runsession.exception", "Analyze nerve response", ... + Category="failure", Audience="developer", Exception=ME); state.session.cache.analysis = []; state.session.workflow.statusMessage = string(ME.message); state.session.workflow.lastAction = "Analysis failed"; @@ -25,5 +26,6 @@ state.session.workflow.statusMessage = sprintf("Analyzed %d recording(s).", ... state.session.cache.analysis.analyzedCount); state.session.workflow.lastAction = "Analyzed filter record"; -context.appendStatus(state.session.workflow.statusMessage); +context.log("info", "nerve_response_analysis.analysisrun.runsession.completed", ... + state.session.workflow.statusMessage); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m index 19a441f56..ddd889276 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/chooseOutputFolder.m @@ -13,6 +13,6 @@ end state.session.workflow.outputFolder = string(choice.Value); state.session.workflow.lastAction = "Selected output folder"; -context.appendStatus( ... - "Selected output folder: " + string(choice.Value)); +context.log("info", "nerve_response_analysis.resultfiles.chooseoutputfolder.status", ... + "Selected an output folder."); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m index 5c5fb31dc..9c033f4c0 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/clearOutputFolder.m @@ -3,5 +3,6 @@ %CLEAROUTPUTFOLDER Clear the current analysis destination. state.session.workflow.outputFolder = ""; state.session.workflow.lastAction = "Cleared output folder"; -context.appendStatus("Cleared output folder."); +context.log("info", "nerve_response_analysis.resultfiles.clearoutputfolder.status", ... + "Cleared the output folder."); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m index 6af12a4e6..f1873f667 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+resultFiles/exportAnalysis.m @@ -33,7 +33,8 @@ state.session.workflow.statusMessage = ... "Exported nerve-response analysis."; state.session.workflow.lastAction = "Exported analysis"; -context.appendStatus("Exported analysis: " + string(path)); +context.log("info", "nerve_response_analysis.resultfiles.exportanalysis.completed", ... + "Exported the analysis results."); end function summary = analysisSummary(analysis) diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m index 15afef136..ea777f6eb 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/filterChanged.m @@ -12,6 +12,6 @@ end state.session.workflow.statusMessage = "Filter record selected."; state.session.workflow.lastAction = "Selected filter record"; -context.appendStatus( ... - "Selected filter record: " + state.session.cache.filterPath); +context.log("info", "nerve_response_analysis.sourcefiles.filterchanged.status", ... + "Selected a filter record."); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m index 4d4c65bd6..d4fdbf4ec 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+sourceFiles/protocolChanged.m @@ -11,6 +11,6 @@ return; end state.session.workflow.lastAction = "Selected protocol"; -context.appendStatus( ... - "Selected protocol: " + state.session.cache.protocolPath); +context.log("info", "nerve_response_analysis.sourcefiles.protocolchanged.status", ... + "Selected a protocol."); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+syntheticInputs/writeSamplePack.m similarity index 93% rename from apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m rename to apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+syntheticInputs/writeSamplePack.m index f8b926cc8..29130d042 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+debug/writeSamplePack.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+syntheticInputs/writeSamplePack.m @@ -5,7 +5,7 @@ function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Nerve Response Analysis debug files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end rhsA = sampleContext.samplePath( ... @@ -36,7 +36,7 @@ "filterRecord", "filterRecord", filterRecordPath, true), ... sampleContext.sourceRecord( ... "protocol", "protocol", protocolPath, false)]; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-nerve-response", ... InitialProject=project, Artifacts={ ... sampleContext.artifact( ... @@ -89,8 +89,8 @@ function writeProtocol(filepath) function writeSyntheticRhs(filepath, amplifierNames, stimPulseSamples, nBlocks) fid = fopen(char(filepath), "w", "ieee-le"); if fid < 0 - error("nerve_response_analysis:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("nerve_response_analysis:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); @@ -182,8 +182,8 @@ function writeQString(fid, value) function writeJson(filepath, payload) fid = fopen(char(filepath), "w"); if fid < 0 - error("nerve_response_analysis:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("nerve_response_analysis:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", jsonencode(payload)); @@ -192,8 +192,8 @@ function writeJson(filepath, payload) function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 - error("nerve_response_analysis:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("nerve_response_analysis:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m index 2a9450d36..fffaa120c 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/buildLayout.m @@ -15,9 +15,7 @@ labkit.app.layout.statusPanel("details", Title="Details")}), ... labkit.app.layout.tab("export", "Export", { ... nerve_response_analysis.resultFiles.outputSection(), ... - nerve_response_analysis.resultFiles.actionSection()}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.logPanel("logPanel")})}; + nerve_response_analysis.resultFiles.actionSection()})}; preview = labkit.app.layout.plotArea("preview", ... @nerve_response_analysis.analysisRun.drawPreview, ... Title="Preview", AxisTitles="Analysis Counts", ... diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m index b21ba0bc2..12ac47282 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/+workbench/resetWorkflow.m @@ -5,5 +5,6 @@ state.project = schema.Create(); state.session = nerve_response_analysis.createSession( ... state.project, context); -context.appendStatus("Reset Nerve Response Analysis state."); +context.log("info", "nerve_response_analysis.workbench.resetworkflow.completed", ... + "Reset Nerve Response Analysis state."); end diff --git a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m index b3b693b06..042a9fcb5 100644 --- a/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m +++ b/apps/neurophysiology/nerve_response_analysis/+nerve_response_analysis/definition.m @@ -7,11 +7,11 @@ AppId="nerve_response_analysis", ... Title="Nerve Response Analysis", ... DisplayName="Nerve Response Analysis", Family="Neurophysiology", ... - AppVersion="1.5.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "rhs", ">=1.0 <2"), ... + AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "rhs", ">=1.0 <2"), ... ProjectSchema=nerve_response_analysis.projectSpec(), ... CreateSession=@nerve_response_analysis.createSession, ... Workbench=nerve_response_analysis.workbench.buildLayout(), ... PresentWorkbench=@nerve_response_analysis.workbench.present, ... - BuildDebugSample=@nerve_response_analysis.debug.writeSamplePack); + BuildSyntheticSample=@nerve_response_analysis.syntheticInputs.writeSamplePack); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m index c17c6111e..727787967 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+analysisRun/refreshMetrics.m @@ -15,7 +15,8 @@ [metrics, summary, aligned] = response_review_stats.analysisRun.loadMetrics( ... paths(1), state.project.parameters); catch ME - context.reportError("Metric load", ME); + context.log("error", "response_review_stats.analysisrun.refreshmetrics.exception", "Metric load", ... + Category="failure", Audience="developer", Exception=ME); state.session.cache.metrics = table(); state.session.cache.summary = table(); state.session.cache.aligned = []; @@ -30,5 +31,6 @@ state.session.cache.aligned = aligned; state.session.workflow.statusMessage = sprintf("Loaded %d metric row(s).", height(metrics)); state.session.workflow.lastAction = "Loaded metrics"; -context.appendStatus(state.session.workflow.statusMessage); +context.log("info", "response_review_stats.analysisrun.refreshmetrics.completed", ... + state.session.workflow.statusMessage); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m index 6e88d5b35..3ed2929c2 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/chooseOutputFolder.m @@ -12,6 +12,6 @@ end state.session.workflow.outputFolder = string(choice.Value); state.session.workflow.lastAction = "Selected output folder"; -context.appendStatus( ... - "Selected output folder: " + string(choice.Value)); +context.log("info", "response_review_stats.resultfiles.chooseoutputfolder.status", ... + "Selected an output folder."); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m index f6fd42024..bcf98f67d 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/clearOutputFolder.m @@ -3,5 +3,6 @@ %CLEAROUTPUTFOLDER Clear the current metrics destination. state.session.workflow.outputFolder = ""; state.session.workflow.lastAction = "Cleared output folder"; -context.appendStatus("Cleared output folder."); +context.log("info", "response_review_stats.resultfiles.clearoutputfolder.status", ... + "Cleared the output folder."); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m index b4421b70b..4917a2c40 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+resultFiles/exportMetrics.m @@ -34,5 +34,6 @@ "manifestPath", string(written.Value)); state.session.workflow.statusMessage = "Exported response-review metrics."; state.session.workflow.lastAction = "Exported metrics"; -context.appendStatus("Exported metrics: " + string(path)); +context.log("info", "response_review_stats.resultfiles.exportmetrics.completed", ... + "Exported the response-review metrics."); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+syntheticInputs/writeSamplePack.m similarity index 90% rename from apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m rename to apps/neurophysiology/response_review_stats/+response_review_stats/+syntheticInputs/writeSamplePack.m index de2e4f734..0e9c6e960 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+debug/writeSamplePack.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+syntheticInputs/writeSamplePack.m @@ -5,7 +5,7 @@ function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write Response Review Stats debug files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end segmentPath = sampleContext.samplePath( ... @@ -28,7 +28,7 @@ project = response_review_stats.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "reviewInput", "reviewInput", segmentPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-response-review", ... InitialProject=project, Artifacts={ ... sampleContext.artifact( ... @@ -75,8 +75,8 @@ function writeAnalysisJson(filepath) "version", 1, "metrics", metrics); fid = fopen(char(filepath), "w"); if fid < 0 - error("response_review_stats:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("response_review_stats:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", jsonencode(payload)); @@ -85,8 +85,8 @@ function writeAnalysisJson(filepath) function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 - error("response_review_stats:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("response_review_stats:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m index 0da024ed3..343918f2d 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/buildLayout.m @@ -14,9 +14,7 @@ labkit.app.layout.statusPanel("details", Title="Details")}), ... labkit.app.layout.tab("export", "Export", { ... response_review_stats.resultFiles.outputSection(), ... - response_review_stats.resultFiles.layoutSection()}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.logPanel("logPanel")})}; + response_review_stats.resultFiles.layoutSection()})}; workspace = labkit.app.layout.workspace( ... labkit.app.layout.plotArea("preview", ... @response_review_stats.analysisRun.drawPreview, ... diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m index ec75a81c9..e71ce922b 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/+workbench/resetWorkflow.m @@ -4,5 +4,6 @@ schema = response_review_stats.projectSpec(); state.project = schema.Create(); state.session = response_review_stats.createSession(state.project, context); -context.appendStatus("Reset Response Review Stats state."); +context.log("info", "response_review_stats.workbench.resetworkflow.completed", ... + "Reset Response Review Stats state."); end diff --git a/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m b/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m index 4d6180f41..7104d3607 100644 --- a/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m +++ b/apps/neurophysiology/response_review_stats/+response_review_stats/definition.m @@ -8,12 +8,12 @@ Title="Response Review Stats", ... DisplayName="Response Review Stats", ... Family="Neurophysiology", ... - AppVersion="1.5.1", ... - Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + AppVersion="1.6.0", ... + Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=response_review_stats.projectSpec(), ... CreateSession=@response_review_stats.createSession, ... Workbench=response_review_stats.workbench.buildLayout(), ... PresentWorkbench=@response_review_stats.workbench.present, ... - BuildDebugSample=@response_review_stats.debug.writeSamplePack); + BuildSyntheticSample=@response_review_stats.syntheticInputs.writeSamplePack); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m index 4dacefbeb..01e8e6f8c 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeFamily.m @@ -19,13 +19,12 @@ applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... applicationState.session, applicationState.project.parameters); end -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, applicationState.project.parameters, ... "Updated preview window", false); applicationState.session.workflow.lastAction = ... "Updated preview settings"; -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.changefamily.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m index 61062b852..35f0daf18 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeMaxPreviewChannels.m @@ -18,13 +18,12 @@ applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... applicationState.session, applicationState.project.parameters); end -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, applicationState.project.parameters, ... "Updated preview window", false); applicationState.session.workflow.lastAction = ... "Updated preview settings"; -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.changemaxpreviewchannels.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m index 2c4755f2a..e7486d563 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/changeWindowStart.m @@ -14,13 +14,12 @@ applicationState.session = rhs_preview.analysisRun.applyPreviewContext( ... applicationState.session, context); applicationState.session.view.autoWindow = false; -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, applicationState.project.parameters, ... "Panned preview window", false); applicationState.session.workflow.lastAction = ... "Updated preview settings"; -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.changewindowstart.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m index 91ce0e17e..3a01b3680 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/editPreviewChannels.m @@ -15,13 +15,12 @@ applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... applicationState.session, applicationState.project.parameters); end -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, applicationState.project.parameters, ... "Updated preview channel window", false); applicationState.session.workflow.lastAction = ... "Updated preview channels"; -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.editpreviewchannels.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/logPreviewRead.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/logPreviewRead.m new file mode 100644 index 000000000..16cb48808 --- /dev/null +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/logPreviewRead.m @@ -0,0 +1,14 @@ +function logPreviewRead(callbackContext, ok, message, eventName) +%LOGPREVIEWREAD Record a privacy-safe outcome for preview-refresh callbacks. +% Called by RHS Preview controls after readCurrentPreview. The original +% message may contain decoder or file details and remains in transient App +% state; this helper emits only the operation outcome to the session journal. +if strlength(string(message)) == 0 + return; +end +if ok + callbackContext.log("info", eventName, "Updated the RHS preview."); +else + callbackContext.log("error", eventName, "RHS preview read failed."); +end +end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m index 3e31f0bcb..4a544cb2c 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshFilterFiles.m @@ -12,7 +12,8 @@ rows = rhs_preview.analysisRun.discoverFilterRows( ... string(rows.filePath), rows); catch ME - callbackContext.reportError("Folder scan failed", ME); + callbackContext.log("error", "rhs_preview.analysisrun.refreshfilterfiles.exception", "Folder scan failed", ... + Category="failure", Audience="developer", Exception=ME); applicationState.session.workflow.statusMessage = string(ME.message); return; end @@ -26,6 +27,7 @@ applicationState.session.workflow.statusMessage = string(sprintf( ... "Discovered %d RHS file(s).", height(rows))); applicationState.session.workflow.lastAction = "Discovered RHS files"; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "rhs_preview.analysisrun.refreshfilterfiles.completed", ... applicationState.session.workflow.statusMessage); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m index a72d7c1c7..e4850036f 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/refreshPreview.m @@ -2,11 +2,10 @@ function applicationState = refreshPreview( ... applicationState, callbackContext) %REFRESHPREVIEW Reread the current bounded preview window. -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, applicationState.project.parameters, ... "Refresh preview window", false); -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.refreshpreview.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m index 92b649b45..d3178c031 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/scrollWindow.m @@ -32,12 +32,11 @@ applicationState.session = rhs_preview.analysisRun.applyPreviewContext( ... applicationState.session, context); applicationState.session.view.autoWindow = false; -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, parameters, ... "Zoom preview window", false); applicationState.session.workflow.statusMessage = "Preview zoom updated."; -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.scrollwindow.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m index 7c1291948..0cf23da94 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+analysisRun/zoomToRoi.m @@ -21,11 +21,10 @@ applicationState.session = rhs_preview.analysisRun.applyPreviewContext( ... applicationState.session, context); applicationState.session.view.autoWindow = false; -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, parameters, ... "Zoom to ROI window", true); -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.analysisrun.zoomtoroi.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m index 25aa6846a..e74870e33 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveFilterRecord.m @@ -37,8 +37,9 @@ applicationState.session.workflow.statusMessage = ... "Saved filter record."; applicationState.session.workflow.lastAction = "Saved filter record"; -callbackContext.appendStatus( ... - "Saved filter record JSON: " + string(outputPath)); +callbackContext.log("info", ... + "rhs_preview.resultfiles.savefilterrecord.completed", ... + "Saved the filter record JSON."); end function [folder, name, extension] = outputParts(path) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m index bd1881606..bca33856f 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+resultFiles/saveProtocol.m @@ -39,8 +39,8 @@ applicationState.session.workflow.statusMessage = ... "Saved protocol draft."; applicationState.session.workflow.lastAction = "Saved protocol"; -callbackContext.appendStatus( ... - "Saved protocol JSON: " + string(outputPath)); +callbackContext.log("info", "rhs_preview.resultfiles.saveprotocol.completed", ... + "Saved the protocol JSON."); end function [folder, name, extension] = outputParts(path) diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m index b26d8e635..8cf7f761c 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/filterChanged.m @@ -17,7 +17,9 @@ applicationState.session.workflow.lastAction = ... "Cleared RHS filter files"; if isempty(selection.Indices) - callbackContext.appendStatus("Cleared RHS filter files."); + callbackContext.log("info", ... + "rhs_preview.sourcefiles.filterchanged.cleared", ... + "Cleared RHS filter files."); end return; end @@ -30,6 +32,7 @@ applicationState.session.workflow.statusMessage = string(sprintf( ... "Discovered %d RHS file(s).", height(rows))); applicationState.session.workflow.lastAction = "Discovered RHS files"; -callbackContext.appendStatus( ... +callbackContext.log("info", ... + "rhs_preview.sourcefiles.filterchanged.completed", ... applicationState.session.workflow.statusMessage); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m index 67274b0e3..dd28630bc 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/protocolChanged.m @@ -24,12 +24,11 @@ applicationState.session = rhs_preview.analysisRun.applyAdaptiveWindow( ... applicationState.session, applicationState.project.parameters); end -[applicationState.session, ~, message] = ... +[applicationState.session, ok, message] = ... rhs_preview.analysisRun.readCurrentPreview( ... applicationState.session, applicationState.project.parameters, ... "Selected protocol", false); applicationState.session.workflow.lastAction = "Selected protocol"; -if strlength(message) > 0 - callbackContext.appendStatus(message); -end +rhs_preview.analysisRun.logPreviewRead(callbackContext, ok, message, ... + "rhs_preview.sourcefiles.protocolchanged.preview"); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m index 40b2dc286..ab3bc2668 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+sourceFiles/recordingChanged.m @@ -20,7 +20,7 @@ applicationState.project.annotations.previewChannelRows); applicationState.project.annotations.previewChannelRows = ... applicationState.session.cache.previewChannelRows; -callbackContext.appendStatus( ... - "Indexed RHS file: " + rhs_preview.sourceFiles.displayFile( ... - applicationState.session.cache.rhsPath)); +callbackContext.log("info", ... + "rhs_preview.sourcefiles.recordingchanged.completed", ... + "Indexed the selected RHS file."); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+syntheticInputs/writeSamplePack.m similarity index 91% rename from apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m rename to apps/neurophysiology/rhs_preview/+rhs_preview/+syntheticInputs/writeSamplePack.m index 30ff7de37..72cb8c651 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+debug/writeSamplePack.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: RHS Preview direct callbacks during debug launch and unit tests. Input +% Expected caller: RHS Preview direct callbacks during synthetic-input generation and unit tests. Input % is a bounded diagnostic SampleContext. Output is a deterministic synthetic % RHS sample pack. Side effects: writes anonymous debug RHS/protocol files % beneath the diagnostic root. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write RHS Preview debug acquisition files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end primaryPath = sampleContext.samplePath( ... @@ -34,7 +34,7 @@ "recording", "recording", primaryPath, true), ... sampleContext.sourceRecord( ... "protocol", "protocol", protocolPath, false)]; - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-rhs-preview", InitialProject=project, ... Artifacts={ ... sampleContext.artifact( ... @@ -70,8 +70,8 @@ function writeSyntheticRhs(filepath, opts) fid = fopen(char(filepath), "w", "ieee-le"); if fid < 0 - error("rhs_preview:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("rhs_preview:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); @@ -157,8 +157,8 @@ function writeQString(fid, value) function writeJson(filepath, payload) fid = fopen(char(filepath), "w"); if fid < 0 - error("rhs_preview:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("rhs_preview:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s", jsonencode(payload)); @@ -167,8 +167,8 @@ function writeJson(filepath, payload) function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 - error("rhs_preview:debug:SampleWriteFailed", ... - "Could not write debug sample file: %s.", filepath); + error("rhs_preview:syntheticInputs:SampleWriteFailed", ... + "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m index a4f2259c3..bc89e6148 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/buildLayout.m @@ -19,10 +19,7 @@ labkit.app.layout.dataTable("summaryTable", ... Title="RHS Summary", Columns=["Field", "Value"])}), ... labkit.app.layout.section("detailsSection", "Details", { ... - labkit.app.layout.statusPanel("details", Title="Details")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("logPanel", Title="Log")})})}; + labkit.app.layout.statusPanel("details", Title="Details")})})}; interaction = labkit.app.interaction.interval( ... "previewRange", @rhs_preview.analysisRun.editRange, ... OnScrolled=@rhs_preview.analysisRun.scrollWindow, ... diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m index 92cc868e3..9ac6bb0f7 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/+workbench/resetWorkflow.m @@ -6,5 +6,6 @@ applicationState.project = schema.Create(); applicationState.session = rhs_preview.createSession( ... applicationState.project, callbackContext); -callbackContext.appendStatus("Reset RHS Preview state."); +callbackContext.log("info", "rhs_preview.workbench.resetworkflow.completed", ... + "Reset RHS Preview state."); end diff --git a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m index 6ea435830..016a4b935 100644 --- a/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m +++ b/apps/neurophysiology/rhs_preview/+rhs_preview/definition.m @@ -4,9 +4,9 @@ function app = definition() app = labkit.app.Definition(Entrypoint="labkit_RHSPreview_app", ... AppId="rhs_preview", Title="RHS Preview", DisplayName="RHS Preview", ... - Family="Neurophysiology", AppVersion="1.5.1", Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1 <2", "rhs", ">=1.0 <2"), ... + Family="Neurophysiology", AppVersion="1.6.0", Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3", "rhs", ">=1.0 <2"), ... ProjectSchema=rhs_preview.projectSpec(), CreateSession=@rhs_preview.createSession, ... Workbench=rhs_preview.workbench.buildLayout(), PresentWorkbench=@rhs_preview.workbench.present, ... - BuildDebugSample=@rhs_preview.debug.writeSamplePack); + BuildSyntheticSample=@rhs_preview.syntheticInputs.writeSamplePack); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m index d233a8e53..c88707ab9 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/addSelectedSourceValues.m @@ -43,7 +43,6 @@ state.project.inputs.groups = groups; state.project.parameters.captureTarget = "(new group)"; state.project.results.lastDataExport = ""; -context.appendStatus(sprintf( ... - 'Added %d value(s) to %s.', ... - selected.acceptedCount, groups(groupIndex).label)); +context.log("info", "ttest_wizard.groupdata.addselectedsourcevalues.status", sprintf( ... + 'Added %d value(s) to the selected group.', selected.acceptedCount)); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m index a8a2fc8c3..79aa81f9e 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/assignSelectedRows.m @@ -19,8 +19,8 @@ state.project.inputs.groups, selectedRows, target); state.session.selection.analysisCells = zeros(0, 2); state.project.results.lastDataExport = ""; -context.appendStatus(sprintf( ... - 'Changed %d selected row(s) to %s.', numel(selectedRows), target)); +context.log("info", "ttest_wizard.groupdata.assignselectedrows.status", sprintf( ... + 'Changed the group for %d selected row(s).', numel(selectedRows))); end function groups = reassignRows(groups, selectedRows, target) diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m index 063edfba8..ade3d862d 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/clearAll.m @@ -15,5 +15,5 @@ state.session.selection.analysisCells = zeros(0, 2); state.session.selection.batchGroupTarget = "(select group)"; state.project.results.lastDataExport = ""; -context.appendStatus("Cleared all analysis data."); +context.log("info", "ttest_wizard.groupdata.clearall.status", "Cleared all analysis data."); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m index a43eaeb03..6e398b477 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/deleteSelectedRows.m @@ -25,7 +25,7 @@ state.session.selection.batchGroupTarget = "(select group)"; end state.project.results.lastDataExport = ""; -context.appendStatus(sprintf( ... +context.log("info", "ttest_wizard.groupdata.deleteselectedrows.status", sprintf( ... 'Deleted %d selected row(s).', numel(selectedRows))); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m index 5fdab9595..44d7b5261 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+groupData/replaceFromTableEdit.m @@ -30,7 +30,7 @@ state.project.parameters.captureTarget = "(new group)"; end state.project.results.lastDataExport = ""; -context.appendStatus(sprintf( ... +context.log("info", "ttest_wizard.groupdata.replacefromtableedit.status", sprintf( ... 'Updated analysis data: %d group(s), %d value(s).', ... numel(groups), sum(arrayfun( ... @(group) numel(group.values), groups)))); diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m index 53dc85f6d..e91f583e5 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportComparisons.m @@ -25,12 +25,14 @@ try ttest_wizard.resultFiles.writeResultCsv(filepath, results); catch ME - context.reportError("Export t-test results", ME); + context.log("error", "ttest_wizard.resultfiles.exportcomparisons.exception", "Export t-test results", ... + Category="failure", Audience="developer", Exception=ME); context.alert(ME.message, "Export results"); return; end state.project.results.lastResultExport = filepath; -context.appendStatus("Exported t-test results: " + filepath); +context.log("info", "ttest_wizard.resultfiles.exportcomparisons.status", ... + "Exported t-test comparison results."); end function filepath = ensureCsvExtension(filepath) diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m index 80074e85f..e3a813f5e 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+resultFiles/exportGroupData.m @@ -24,12 +24,14 @@ try ttest_wizard.sourceTable.writeGroupCsv(filepath, groups); catch ME - context.reportError("Export group data", ME); + context.log("error", "ttest_wizard.resultfiles.exportgroupdata.exception", "Export group data", ... + Category="failure", Audience="developer", Exception=ME); context.alert(ME.message, "Export data"); return; end state.project.results.lastDataExport = filepath; -context.appendStatus("Exported group data: " + filepath); +context.log("info", "ttest_wizard.resultfiles.exportgroupdata.status", ... + "Exported group data."); end function filepath = ensureCsvExtension(filepath) diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m index 5e5cd2e91..493e89da2 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+sourceTable/changeWorksheet.m @@ -24,7 +24,8 @@ try source = ttest_wizard.sourceTable.readSourceTable(paths(1), requested); catch ME - context.reportError("Change worksheet", ME); + context.log("error", "ttest_wizard.sourcetable.changeworksheet.exception", "Change worksheet", ... + Category="failure", Audience="developer", Exception=ME); context.alert(ME.message, "Worksheet"); return; end @@ -33,5 +34,6 @@ state.session.selection.sourceCells = zeros(0, 2); state.session.selection.selectionMessage = ... "Select numeric cells in the new worksheet."; -context.appendStatus("Worksheet: " + source.sheet); +context.log("info", "ttest_wizard.sourcetable.changeworksheet.status", ... + "Selected a source worksheet."); end diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m b/apps/statistics/ttest_wizard/+ttest_wizard/+syntheticInputs/writeSamplePack.m similarity index 79% rename from apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m rename to apps/statistics/ttest_wizard/+ttest_wizard/+syntheticInputs/writeSamplePack.m index 70c1547ae..0ef9819e2 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+debug/writeSamplePack.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+syntheticInputs/writeSamplePack.m @@ -1,14 +1,14 @@ % App debug-sample writer; returns synthetic paths and writes only temporary artifacts. function pack = writeSamplePack(sampleContext) -%WRITESAMPLEPACK Write an anonymous four-group CSV for debug launch. +%WRITESAMPLEPACK Write an anonymous four-group CSV for synthetic-input generation. % -% Expected caller: Runtime DebugSample lifecycle and App isolation tests. +% Expected caller: Runtime SyntheticInputs lifecycle and App isolation tests. % debugLog is the injected debug context when available. The output identifies % one representative synthetic CSV and an output folder. Side effects are % limited to ignored temporary debug artifacts. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end filepath = sampleContext.samplePath("ttest_wizard/group_table.csv"); writecell({ ... @@ -20,7 +20,7 @@ project = ttest_wizard.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "table1", "table", filepath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-groups", InitialProject=project, ... Artifacts={sampleContext.artifact( ... "groupTable", "table", filepath)}); diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m index 04931775a..7728c7aa0 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+testRun/runComparisons.m @@ -26,9 +26,8 @@ state.project.results.current = results; state.project.results.lastResultExport = ""; okCount = sum([results.ok]); -context.appendStatus(sprintf( ... - 'Completed %d of %d comparison(s) against %s.', ... - okCount, numel(results), groups(1).label)); +context.log("info", "ttest_wizard.testrun.runcomparisons.status", sprintf( ... + 'Completed %d of %d comparison(s).', okCount, numel(results))); if okCount < numel(results) failed = results(~[results.ok]); context.alert(strjoin(unique([failed.message]), newline), ... diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m b/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m index a529e54ac..eee0743c3 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/+workbench/buildLayout.m @@ -16,10 +16,7 @@ controls = { ... labkit.app.layout.tab("data", "1 Data", dataControls), ... labkit.app.layout.tab("analysis", "2 Test & Plot", testControls), ... - labkit.app.layout.tab("export", "3 Export", exportControls), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog")})})}; + labkit.app.layout.tab("export", "3 Export", exportControls)}; workspace = labkit.app.layout.workspace(Title="Data and Plot"); workspace = workspace.page("dataPage", "Data", { ... diff --git a/apps/statistics/ttest_wizard/+ttest_wizard/definition.m b/apps/statistics/ttest_wizard/+ttest_wizard/definition.m index 38286a53b..4c0e7b898 100644 --- a/apps/statistics/ttest_wizard/+ttest_wizard/definition.m +++ b/apps/statistics/ttest_wizard/+ttest_wizard/definition.m @@ -4,7 +4,7 @@ % % Expected caller: labkit_TTestWizard_app. The output declares the product % version, project/session factories, semantic layout, handlers, view builder, -% plot renderer, and synthetic debug sample. Side effects are none. +% plot renderer, and synthetic-input generator. Side effects are none. app = labkit.app.Definition( ... Entrypoint="labkit_TTestWizard_app", ... @@ -12,12 +12,12 @@ Title="T-Test Wizard", ... DisplayName="T-Test Wizard", ... Family="Statistics", ... - AppVersion="1.2.0", ... - Updated="2026-07-20", ... - Requirements=labkit.contract.requirements("app", ">=1.2 <2"), ... + AppVersion="1.3.0", ... + Updated="2026-07-26", ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=ttest_wizard.projectSpec(), ... CreateSession=@ttest_wizard.createSession, ... Workbench=ttest_wizard.workbench.buildLayout(), ... PresentWorkbench=@ttest_wizard.workbench.present, ... - BuildDebugSample=@ttest_wizard.debug.writeSamplePack); + BuildSyntheticSample=@ttest_wizard.syntheticInputs.writeSamplePack); end diff --git a/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m b/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m index cbd8b9b07..abf30a593 100644 --- a/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m +++ b/apps/statistics/ttest_wizard/labkit_TTestWizard_app.m @@ -5,7 +5,7 @@ % labkit_TTestWizard_app % fig = labkit_TTestWizard_app % info = labkit_TTestWizard_app("version") -% [fig, debug] = labkit_TTestWizard_app("debug") +% requirements = labkit_TTestWizard_app("requirements") % % Description: % Opens tabular CSV or workbook sources, captures two or more visible numeric @@ -16,8 +16,8 @@ % varargin - Launch requests accepted by labkit.app.Definition.launch. % % Outputs: -% varargout - Launch, version, requirements, or debug outputs returned by the -% LabKit runtime. +% varargout - Optional figure, version metadata, or requirements returned by +% the LabKit runtime. % % Typical Call: % labkit_TTestWizard_app diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m index b302eedd2..a0c5f5a68 100644 --- a/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/analyze.m @@ -17,9 +17,11 @@ applicationState.session.cache, ... applicationState.project.parameters); catch ME - callbackContext.reportError("ECG analysis", ME); + callbackContext.log("error", "ecg_print.analysisrun.analyze.exception", "ECG analysis", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Analysis failed"); - callbackContext.appendStatus("Analysis failed: " + ME.message); + callbackContext.log("error", "ecg_print.analysisrun.analyze.failed", ... + "ECG analysis failed."); return; end cache = applicationState.session.cache; @@ -31,8 +33,7 @@ "perSegment", cache.measurements.perSegment); applicationState.project.results.lastSegmentExport = []; applicationState.project.results.lastWaveformExport = []; -callbackContext.appendStatus(sprintf( ... - "Analyzed ROI with %s: %d peaks, %d valid segments.", ... - applicationState.project.parameters.peakMethod, ... +callbackContext.log("info", "ecg_print.analysisrun.analyze.completed", sprintf( ... + "Analyzed the ROI: %d peaks, %d valid segments.", ... numel(cache.events.index), size(cache.segments.values, 2))); end diff --git a/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m b/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m index 6a943ad53..d8d9b3ad8 100644 --- a/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m +++ b/apps/wearable/ecg_print/+ecg_print/+analysisRun/changeChannel.m @@ -11,7 +11,8 @@ signal = labkit.biosignal.getChannel( ... applicationState.session.cache.recording, channel); catch ME - callbackContext.reportError("Channel selection", ME); + callbackContext.log("error", "ecg_print.analysisrun.changechannel.exception", "Channel selection", ... + Category="failure", Audience="developer", Exception=ME); callbackContext.alert(ME.message, "Channel selection failed"); return; end @@ -28,5 +29,6 @@ applicationState.project.results.lastAnalysis = struct(); applicationState.project.results.lastSegmentExport = []; applicationState.project.results.lastWaveformExport = []; -callbackContext.appendStatus("Selected channel: " + channel); +callbackContext.log("info", "ecg_print.analysisrun.changechannel.status", ... + "Selected a recording channel."); end diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m index 78108b1a8..83f7f9410 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportSegments.m @@ -12,7 +12,8 @@ chosen = callbackContext.chooseOutputFile( ... ["*.csv", "CSV files (*.csv)"], "ecg_segment_snr.csv"); if chosen.Cancelled - callbackContext.appendStatus("Segment SNR export cancelled."); + callbackContext.log("info", "ecg_print.resultfiles.exportsegments.cancelled", ... + "Segment SNR export cancelled."); return; end filepath = string(chosen.Value); @@ -34,8 +35,8 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.lastSegmentExport = struct( ... "csvPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus( ... - "Exported segment SNR CSV: " + filepath); +callbackContext.log("info", "ecg_print.resultfiles.exportsegments.completed", ... + "Exported the segment SNR CSV."); end function folder = outputFolder(folder) diff --git a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m index 61a032086..b5f3769a2 100644 --- a/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m +++ b/apps/wearable/ecg_print/+ecg_print/+resultFiles/exportWaveform.m @@ -14,7 +14,8 @@ chosen = callbackContext.chooseOutputFile( ... ["*.png", "PNG files (*.png)"], "ecg_waveform.png"); if chosen.Cancelled - callbackContext.appendStatus("Waveform export cancelled."); + callbackContext.log("info", "ecg_print.resultfiles.exportwaveform.cancelled", ... + "Waveform export cancelled."); return; end filepath = string(chosen.Value); @@ -33,7 +34,8 @@ written = callbackContext.writeResultPackage(folder, package); applicationState.project.results.lastWaveformExport = struct( ... "pngPath", filepath, "manifestPath", string(written.Value)); -callbackContext.appendStatus("Exported waveform PNG: " + filepath); +callbackContext.log("info", "ecg_print.resultfiles.exportwaveform.completed", ... + "Exported the waveform PNG."); end function folder = outputFolder(folder) diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m index 47300535a..721fc5b2a 100644 --- a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/previewHeader.m @@ -10,5 +10,6 @@ end applicationState.session.cache.filePreview = ... ecg_print.sourceFiles.previewFileHeader(paths(1), 18); -callbackContext.appendStatus("Previewed file header: " + paths(1)); +callbackContext.log("info", "ecg_print.sourcefiles.previewheader.completed", ... + "Previewed the recording header."); end diff --git a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m index bae745a20..7f2c2f0ef 100644 --- a/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m +++ b/apps/wearable/ecg_print/+ecg_print/+sourceFiles/refreshImport.m @@ -15,7 +15,8 @@ filepath, applicationState.project.parameters, ... applicationState.project.parameters.channel); catch cause - callbackContext.reportError("Recording parse failed", cause); + callbackContext.log("error", "ecg_print.sourcefiles.refreshimport.exception", "Recording parse failed", ... + Category="failure", Audience="developer", Exception=cause); cache = ecg_print.sourceFiles.emptyCache(); cache.filepath = filepath; cache.filePreview = preview; @@ -27,8 +28,8 @@ applicationState.project.results.lastAnalysis = struct(); applicationState.project.results.lastSegmentExport = []; applicationState.project.results.lastWaveformExport = []; - callbackContext.appendStatus( ... - "Recording parse failed: " + cause.message); + callbackContext.log("error", "ecg_print.sourcefiles.refreshimport.failed", ... + "Recording import failed."); callbackContext.alert(cause.message, "Could not parse recording"); return; end @@ -42,7 +43,6 @@ applicationState.project.results.lastAnalysis = struct(); applicationState.project.results.lastSegmentExport = []; applicationState.project.results.lastWaveformExport = []; -callbackContext.appendStatus(sprintf( ... - "Parsed %d channel(s) from %s", ... - numel(cache.channelItems), filepath)); +callbackContext.log("info", "ecg_print.sourcefiles.refreshimport.completed", sprintf( ... + "Imported %d recording channel(s).", numel(cache.channelItems))); end diff --git a/apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m b/apps/wearable/ecg_print/+ecg_print/+syntheticInputs/writeSamplePack.m similarity index 89% rename from apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m rename to apps/wearable/ecg_print/+ecg_print/+syntheticInputs/writeSamplePack.m index fee5a60a3..25fef1138 100644 --- a/apps/wearable/ecg_print/+ecg_print/+debug/writeSamplePack.m +++ b/apps/wearable/ecg_print/+ecg_print/+syntheticInputs/writeSamplePack.m @@ -1,11 +1,11 @@ -% Expected caller: ecg_print.run during debug launch and unit tests. Input is +% Expected caller: ecg_print.run during synthetic-input generation and unit tests. Input is % a LabKit debug context. Output is a deterministic synthetic ECG recording % sample pack. Side effects: writes anonymous debug files and records a % session manifest when available. function pack = writeSamplePack(sampleContext) %WRITESAMPLEPACK Write ECG Print debug recording files. arguments - sampleContext (1, 1) labkit.app.diagnostic.SampleContext + sampleContext (1, 1) labkit.app.synthetic.Context end csvPath = sampleContext.samplePath("ecg_print/recording.csv"); @@ -29,7 +29,7 @@ project = ecg_print.projectSpec().Create(); project.inputs.sources = sampleContext.sourceRecord( ... "recording1", "recording", csvPath, true); - pack = labkit.app.diagnostic.SamplePack( ... + pack = labkit.app.synthetic.Pack( ... Scenario="representative-ecg", InitialProject=project, ... Artifacts={ ... sampleContext.artifact("recording", "recording", csvPath), ... @@ -61,7 +61,7 @@ function writeTextFile(filepath, lines) fid = fopen(char(filepath), "w"); if fid < 0 - error("ecg_print:debug:SampleWriteFailed", "Could not write debug sample file: %s.", filepath); + error("ecg_print:syntheticInputs:SampleWriteFailed", "Could not write synthetic input file: %s.", filepath); end cleaner = onCleanup(@() fclose(fid)); fprintf(fid, "%s\n", lines); diff --git a/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m b/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m index 4b576f25b..c707baa6e 100644 --- a/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m +++ b/apps/wearable/ecg_print/+ecg_print/+workbench/buildLayout.m @@ -13,10 +13,7 @@ Title="Summary", Columns=["Metric", "Value"]), ... labkit.app.layout.statusPanel("filePreview", ... Title="File Header Preview", Text= ... - "Open a CSV/text file, then use Preview file header.")})}), ... - labkit.app.layout.tab("log", "Log", { ... - labkit.app.layout.section("logSection", "Log", { ... - labkit.app.layout.logPanel("appLog", Title="Log")})})}; + "Open a CSV/text file, then use Preview file header.")})})}; preview = labkit.app.layout.plotArea("previewAxes", ... @ecg_print.analysisRun.drawPreview, ... Title="ECG Preview", Layout="stack", ... diff --git a/apps/wearable/ecg_print/+ecg_print/definition.m b/apps/wearable/ecg_print/+ecg_print/definition.m index 5a7b7daea..0dbd58c5e 100644 --- a/apps/wearable/ecg_print/+ecg_print/definition.m +++ b/apps/wearable/ecg_print/+ecg_print/definition.m @@ -5,12 +5,12 @@ app = labkit.app.Definition( ... Entrypoint="labkit_ECGPrint_app", AppId="ecg_print", ... Title="ECG Signal Print + SNR Explorer", DisplayName="ECG Print", ... - Family="Wearable", AppVersion="1.5.1", Updated="2026-07-20", ... + Family="Wearable", AppVersion="1.6.0", Updated="2026-07-26", ... Requirements=labkit.contract.requirements( ... - "app", ">=1 <2", "biosignal", ">=1.0 <2"), ... + "app", ">=2 <3", "biosignal", ">=1.0 <2"), ... ProjectSchema=ecg_print.projectSpec(), ... CreateSession=@ecg_print.createSession, ... Workbench=ecg_print.workbench.buildLayout(), ... PresentWorkbench=@ecg_print.workbench.present, ... - BuildDebugSample=@ecg_print.debug.writeSamplePack); + BuildSyntheticSample=@ecg_print.syntheticInputs.writeSamplePack); end diff --git a/docs/apps/README.md b/docs/apps/README.md index 0c50137b0..77f32ad9c 100644 --- a/docs/apps/README.md +++ b/docs/apps/README.md @@ -94,6 +94,13 @@ screenshot actions, plot tools, and managed interactions. Apps own scientific choices, workflow-specific defaults, result schemas, and exports. See the [App Framework](../framework/README.md) for behavior shared across apps. +Every App opens as a clean project. Use **Tools > Diagnostics** to inspect the +current session history, enable future trace capture, or export a diagnostic +bundle after a problem. Apps with declared sample generation expose **Tools > +Developer Tools > Generate Synthetic Inputs...**; generation writes anonymous +inputs without loading them or changing the open project. The +[runtime guide](../framework/guides/runtime.md) defines these shared contracts. + Action and input-selection buttons provide concise hover help. The shared **Tools** menu contains plot, screenshot, and project-state actions when the corresponding capability is available. diff --git a/docs/apps/electrochemistry/README.md b/docs/apps/electrochemistry/README.md index c6b05f22c..a088d752b 100644 --- a/docs/apps/electrochemistry/README.md +++ b/docs/apps/electrochemistry/README.md @@ -25,7 +25,8 @@ one failed item does not silently replace another result. The DTA library returns structured items, curve tables, headers, units, metadata, parser messages, and status. Apps use exact required columns for scientific calculations and do not infer missing physical quantities from a -plot label. +plot label. The Apps require the version 3 unit-explicit DTA contract rather +than branching between duplicate legacy and canonical item fields. ## Units And Traceability diff --git a/docs/apps/electrochemistry/eis/README.md b/docs/apps/electrochemistry/eis/README.md index e281c4695..7e45c6f02 100644 --- a/docs/apps/electrochemistry/eis/README.md +++ b/docs/apps/electrochemistry/eis/README.md @@ -21,18 +21,20 @@ through portable references. 1. Add the EIS DTA files. 2. Choose X and Y quantities. -3. Enable logarithmic X or Y scaling only for strictly positive plotted data. -4. Use **Fit X/Y limits** to re-estimate independent limits from the current +3. Choose **mΩ**, **Ω**, **kΩ**, or **MΩ** for impedance axes. New projects + default to **kΩ**. +4. Enable logarithmic X or Y scaling only for strictly positive plotted data. +5. Use **Fit X/Y limits** to re-estimate independent limits from the current data, or **Use equal X/Y scale** when equal data units are wanted. -5. Adjust marker, line, grid, and legend presentation. -6. Export the current plot data CSV. +6. Adjust marker, line, grid, and legend presentation. +7. Export the current plot data CSV. ## Axis Quantities The available quantities are frequency, log10 frequency, time, point number, real impedance, imaginary impedance, negative imaginary impedance, impedance magnitude, phase, DC current, and DC voltage. Default axes are -`Zreal (ohm)` and `-Zimag (ohm)`. +`Zreal` and `-Zimag`, displayed in kΩ for a new project. Use `Zreal` versus `-Zimag` for the conventional Nyquist orientation. Use frequency versus magnitude or phase for Bode-style views. The log-axis @@ -44,6 +46,7 @@ intended. | Parameter | Default | | --- | ---: | +| Impedance unit | kΩ | | Line width | 1.4 | | Marker size | 6 | | Show markers | on | @@ -64,17 +67,19 @@ that viewport. **Export current plot CSV** writes the selected X/Y values for each valid file on a shared row index. Each file retains its own X and Y pair, so unequal curve -lengths do not imply interpolation. A result manifest records the selected -axes, plot parameters, source references, and output role. +lengths do not imply interpolation. Impedance columns use the selected display +unit and include an ASCII unit suffix such as `kohm` in the column name. A +result manifest records the selected axes, impedance unit, plot parameters, +source references, and output role. ## Use Without The GUI ```matlab [item, status] = labkit.dta.loadFile("spectrum.DTA", "eis"); assert(status.ok, status.message); -curve = labkit.dta.getZCurve(item); -x = eis.analysisRun.valuesForAxis(curve, "Zreal (ohm)"); -y = eis.analysisRun.valuesForAxis(curve, "-Zimag (ohm)"); +units = eis.impedanceDisplay.catalog(); +x = eis.analysisRun.valuesForAxis(item, "Zreal", units.choices(3)); +y = eis.analysisRun.valuesForAxis(item, "-Zimag", units.choices(3)); plot(x, y, "o-"); axis equal ``` @@ -87,6 +92,8 @@ catalog. The DTA loader and `getZCurve` are supported reusable APIs. - Log axes omit or reject nonpositive coordinates according to MATLAB axes behavior; inspect the data rather than treating missing points as zero. - Overlaying files does not normalize electrode area or fixture geometry. +- Changing the impedance display unit rescales impedance axes and exported + impedance columns; it does not alter the DTA values stored in base ohms. - Axis labels describe parsed DTA columns; they do not validate the experiment configuration recorded by the instrument. diff --git a/docs/apps/image-measurement/batch-crop/README.md b/docs/apps/image-measurement/batch-crop/README.md index 509b4fa10..3d957facf 100644 --- a/docs/apps/image-measurement/batch-crop/README.md +++ b/docs/apps/image-measurement/batch-crop/README.md @@ -22,15 +22,20 @@ loading duplicate files. 1. Load images and select a task. 2. Set crop width and height or switch to physical mode. -3. Drag the highlighted crop center/ROI in the preview or enter center X/Y. +3. Click anywhere in the preview to place the crop center. Drag the center + marker or anywhere inside the highlighted ROI to move it, or enter center + X/Y. 4. Set rotation and padding. 5. Calibrate physical scale when required. 6. Duplicate tasks for additional ROIs. 7. Choose format and output folder, then export. -The preview rectangle remains draggable and resizable. Editing the ROI updates -the selected task immediately; switching tasks restores that task's geometry. -Marker and ROI refreshes preserve axes zoom. +The preview rectangle remains draggable by either its center marker or its +interior; a click without dragging sets its center at that preview location, +including inside the rectangle. Change crop width and height with their +controls. Editing the ROI updates the selected task immediately; +switching tasks restores that task's geometry. ROI refreshes preserve axes +zoom. ## Pixel Mode diff --git a/docs/apps/image-measurement/curvature/README.md b/docs/apps/image-measurement/curvature/README.md index 3f69feb64..9f20304ab 100644 --- a/docs/apps/image-measurement/curvature/README.md +++ b/docs/apps/image-measurement/curvature/README.md @@ -33,7 +33,8 @@ axes zoom. The **Summary + Results** tab reports curve length, radius, curvature, RMSE, fit center, and pixels per selected unit. **Details** explains the next valid step before a result exists and reports the current measurement afterward. -The **Log** tab records file, edit, fit, calibration, and export actions. +**Tools > Diagnostics > Open Session Log...** records file, edit, fit, +calibration, export, and runtime actions without consuming a workflow tab. The chosen image is saved as a portable project source. Older projects are upgraded on load without changing the curve, calibration, or result meaning. diff --git a/docs/apps/image-measurement/video-marker/README.md b/docs/apps/image-measurement/video-marker/README.md index 12f3681ce..ec504f577 100644 --- a/docs/apps/image-measurement/video-marker/README.md +++ b/docs/apps/image-measurement/video-marker/README.md @@ -105,14 +105,14 @@ the upgraded project before moving it into another workflow. CSV dialogs start in a source-adjacent `video_marker` output folder. Result manifests are written beside the chosen CSV. -## Debug Diagnostics - -Launch with `Diagnostics=labkit.app.diagnostic.Options(Level="verbose")` to -record the richer sanitized callback, checkpoint, count, project, dialog, -resource, and failure trace. Add `Sample="synthetic"` and an artifact folder -to run the App's `BuildDebugSample` contract, which creates a synthetic video, -a valid initial marking project, and declared marker/coordinate output targets -without including user filenames or laboratory data. +## Diagnostics And Synthetic Inputs + +Use **Tools > Diagnostics** to inspect the live session log, enable trace +capture, or export a diagnostic bundle after a problem occurs. Use **Tools > +Developer Tools > Generate Synthetic Inputs...** to create a synthetic video, +a valid marking project, and declared marker/coordinate output targets without +including user filenames or laboratory data. Generation does not load those +inputs into the running App. ## Use Without The GUI diff --git a/docs/apps/labkit-core/launcher/README.md b/docs/apps/labkit-core/launcher/README.md index e13f7d8f8..c8f2d8ed2 100644 --- a/docs/apps/labkit-core/launcher/README.md +++ b/docs/apps/labkit-core/launcher/README.md @@ -21,7 +21,6 @@ tool availability, or the active maintenance operation. | Group | Action | Behavior | | --- | --- | --- | | Run Apps | **Open Selected App** | Checks the selected app requirements, adds the app root, and calls its App SDK entrypoint without retired runtime launch arguments. | -| Run Apps | **Open Debug** | Starts the same App through its typed SDK diagnostics contract, records verbose structured events under `artifacts/diagnostics/launcher/`, and loads the App-owned anonymous synthetic sample. | | Run Apps | **Refresh App List** | Repeats public and configured private-app discovery without restarting the launcher. | | Run Apps | **Documentation and History** | Opens the generated manual for the selected app. | | Versions and Install | **Latest** | Installs the current `main` branch archive. | @@ -38,11 +37,12 @@ Double-clicking an app row is equivalent to selecting it and opening it normally. The checkbox column controls package membership; ordinary launch selection does not change the checked set. -Debug sessions use a new isolated artifact folder on every launch. The folder -contains the runtime event stream, session manifest, synthetic sample manifest, -and any anonymous fixture files declared by the selected App. Normal launches -keep the SDK's bounded standard diagnostics in memory and do not create this -verbose artifact set. +Every launch uses the same clean App path. Use the App's **Tools > +Diagnostics** menu to inspect its live session log, enable trace capture, or +export a diagnostic ZIP after a problem occurs. Apps that declare a synthetic +input pack expose **Tools > Developer Tools > Generate Synthetic Inputs...**. +Generation writes anonymous fixture files and a manifest into a new folder but +does not load them or mutate the running project. ## Programmatic Calls diff --git a/docs/apps/wearable/ecg-print/README.md b/docs/apps/wearable/ecg-print/README.md index 89fd65ff0..7c5aa2d11 100644 --- a/docs/apps/wearable/ecg-print/README.md +++ b/docs/apps/wearable/ecg-print/README.md @@ -67,9 +67,10 @@ The **Files + Analysis** tab keeps the workflow in five ordered sections: **Recording**, **Import Parsing**, **Channel + ROI**, **Signal Processing + SNR**, and **Exports**. Bounded numeric settings use paired spinner-and-slider controls. **Summary + Results** contains the -analysis summary and file-header preview, while **Log** records the current -session workflow. The **ECG Preview** workspace keeps four vertically stacked -time-series axes available on every tab. +analysis summary and file-header preview. **Tools > Diagnostics > Open Session +Log...** records the current workflow and earlier runtime context. The **ECG +Preview** workspace keeps four vertically stacked time-series axes available +on every tab. ## Analysis Parameters diff --git a/docs/development/build-apps/app-development.md b/docs/development/build-apps/app-development.md index 87ab25cb2..2774fc8df 100644 --- a/docs/development/build-apps/app-development.md +++ b/docs/development/build-apps/app-development.md @@ -24,7 +24,11 @@ directly from the layout. Add `+workbench/present.m` for derived visible state, `createSession.m` for transient decoded/cache state, and `projectSpec.m` only when the App owns durable data. That single project file contains local create, validate, and migrate functions. Its migrate callback exists only after a -saved project schema has actually changed; Runtime owns the version loop. +saved project schema has actually changed and only for the older payload +versions the App explicitly supports; Runtime owns the version loop. Current +saves always write the current payload version. Use an exact declared +`LegacyImports` variable adapter only for real pre-envelope user files, never +as a shape-guessing fallback. Runtime and App architecture names remain versionless. Put facade/App compatibility in the existing version and requirement metadata, and put saved diff --git a/docs/development/build-apps/architecture.md b/docs/development/build-apps/architecture.md index 100eb1c43..dcb6ac540 100644 --- a/docs/development/build-apps/architecture.md +++ b/docs/development/build-apps/architecture.md @@ -161,7 +161,7 @@ Add workflow packages only when the App has that user-facing capability: +resultFiles/ choosing output folders, writing files, and summarizing exports +cropGeometry/ app-owned crop geometry operations +thermalFrames/ app-owned thermal frame queues and display choices -+debugArtifacts/ app-owned clean-room sample and debug artifact generation ++syntheticInputs/ app-owned clean-room synthetic input generation ``` Create only the packages the app needs. Names should describe a workflow or diff --git a/docs/development/build-apps/complete-app.md b/docs/development/build-apps/complete-app.md index 2db5534bf..3f6bc7322 100644 --- a/docs/development/build-apps/complete-app.md +++ b/docs/development/build-apps/complete-app.md @@ -53,7 +53,7 @@ app = labkit.app.Definition( ... Family="Examples", ... AppVersion="1.0.0", ... Updated="2026-07-19", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=trace_viewer.projectSpec(), ... CreateSession=@trace_viewer.createSession, ... Workbench=trace_viewer.workbench.buildLayout(), ... diff --git a/docs/framework/README.md b/docs/framework/README.md index 168fcf5e2..ae45d4b7e 100644 --- a/docs/framework/README.md +++ b/docs/framework/README.md @@ -64,7 +64,7 @@ function app = definition() Entrypoint="labkit_Example_app", AppId="example", ... Title="Example", Family="Examples", ... AppVersion="1.0.0", Updated="2026-07-19", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... Workbench=workbench); end ``` @@ -114,6 +114,9 @@ and renderer signatures, and builds one private native platform plan. remain scrollable. - Declare editable overlays with `labkit.app.interaction.*` on the plot area; supply their current values with same-named Snapshot methods. +- For a managed rectangle with `OnBackgroundPressed`, an un-dragged click + anywhere on its plot—including inside the rectangle—uses that point + callback; dragging the rectangle still uses its change callback. - Use `labkit.app.plot.clearAxes`, `showMessage`, and `fitAxesToGraphics` for renderer mechanics; `EqualDataUnits=true` makes a one-time fitted equal-scale view from the settled native axes allocation without dispatching @@ -127,8 +130,9 @@ and renderer signatures, and builds one private native platform plan. Runtime validates candidate state and the complete view snapshot before publishing either. The private MATLAB adapter maps semantic IDs to native -components, preserves plot viewports, normalizes native event differences, and -never exposes component registries to Apps. +components, skips unchanged native property writes, reuses direct associations +between controls and their labels, preserves plot viewports, normalizes native +event differences, and never exposes component registries to Apps. Normal App launches show the completed native window. Official GUI validation uses the same launch path with a framework-owned visibility policy: `hidden` diff --git a/docs/framework/app-sdk-api.md b/docs/framework/app-sdk-api.md index bddba53ef..2766557fa 100644 --- a/docs/framework/app-sdk-api.md +++ b/docs/framework/app-sdk-api.md @@ -25,7 +25,8 @@ page provides syntax, inputs, outputs, options, errors, and related APIs. | Results | [App SDK result API](../reference/api/labkit/app/result/File.html) | | Managed interactions | [App SDK interaction API](../reference/api/labkit/app/interaction/anchorPath.html) | | Plot mechanics | [App SDK plot API](../reference/api/labkit/app/plot/clearAxes.html) | -| Diagnostics and dialogs | [App SDK diagnostic API](../reference/api/labkit/app/diagnostic/Artifact.html) and [dialog API](../reference/api/labkit/app/dialog/Choice.html) | +| Runtime diagnostics | [Runtime diagnostics and session logging](guides/runtime.md#diagnostics-and-session-logging) | +| Dialog results | [App SDK dialog API](../reference/api/labkit/app/dialog/Choice.html) | ## Related Topics diff --git a/docs/framework/guides/runtime.md b/docs/framework/guides/runtime.md index 00d988abe..48565d749 100644 --- a/docs/framework/guides/runtime.md +++ b/docs/framework/guides/runtime.md @@ -24,7 +24,7 @@ app = labkit.app.Definition( ... Family="Examples", ... AppVersion="1.0.0", ... Updated="2026-07-19", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... Workbench=example.workbench.buildLayout(), ... ProjectSchema=example.projectSpec(), ... CreateSession=@example.createSession, ... @@ -40,7 +40,7 @@ Required Definition arguments are product metadata, requirements, and one | `CreateSession` | `session = callback(project,callbackContext)` | Rebuild transient App data from durable project state. | | `PresentWorkbench` | `view = callback(applicationState)` | Return the App-owned fragment of the complete visible snapshot. | | `OnStart` | `applicationState = callback(applicationState,callbackContext)` | Perform a real post-first-commit request or resource initialization. | -| `BuildDebugSample` | `sample = callback(callbackContext)` | Build clean-room debug input when the App supports it. | +| `BuildSyntheticSample` | `sample = callback(callbackContext)` | Build clean-room debug input when the App supports it. | Ordinary default state needs no startup callback. Exact syntax and errors are in the generated [public API reference](../../reference/README.md). @@ -263,12 +263,67 @@ presentation, rollback, document metadata, and title publication. project/session state and publishes a new unsaved document identity only when that callback transaction commits. +## Diagnostics And Session Logging + +Every ordinary App launch starts one sanitized session event stream and durable +journal. Launch arguments do not select a debug mode, change startup behavior, +or generate sample data. Runtime automatically records lifecycle, callback, +transaction, dialog, project, source, result, and failure boundaries with +correlated operation IDs. + +App callbacks add domain events through +`callbackContext.log(severity,eventName,message,Name=Value)`. Use `info` for +useful progress and completed user actions, `warning` for recoverable +conditions, and `error` or `critical` for failures. Stable event names and +structured allowlisted attributes support diagnosis; messages remain concise +and safe for display. Pass caught exceptions through the dedicated +`Exception` option instead of copying stack, path, identifier, or scientific +data into free text. + +The App's **Tools > Diagnostics** menu opens the live session viewer, enables +more detailed trace capture for future activity, and exports a diagnostic +bundle from the same session history. Enabling trace does not restart the App +or reconstruct earlier detail. Journal degradation is itself exposed in the +surviving in-memory stream; logging failures never alter callback transaction +semantics or scientific results. + ## Persistence, Results, And Cleanup +## Synthetic Inputs + +An App that declares `BuildSyntheticSample` exposes **Tools > Developer +Tools > Generate Synthetic Inputs...**. The action writes an anonymous, +validated `labkit.app.synthetic.Pack` and `synthetic-input-pack.json` into a +new folder beneath the selected destination. Generation does not load the +pack, mutate the open project, or suppress `OnStart`; every App launch follows +the same clean startup path. Users deliberately import the generated files +through the App's ordinary controls. + `labkit.app.project.Schema` owns current project creation, validation, and ordered version migration. Runtime owns the project envelope, atomic save, restore, recovery, and relinking loop. +### Saved-project compatibility boundary + +Every save writes exactly one `labkitProject` variable using the current App +payload version. A restore accepts an older payload only when the App's current +schema declares one `Migrate(project, fromVersion)` callback; Runtime invokes +it once for each missing version in order and then validates the current +payload. A payload newer than the running App is rejected rather than guessed +at. + +An App may declare an exact legacy MAT variable name in `LegacyImports` when +real user files still require a one-way import. That callback converts the +legacy value directly to the current project and optional resume state. It is +read-only: current saves never write the legacy variable, and Runtime contains +no App ID, field-shape, or filename heuristics. + +These readers are supported data contracts while their Apps declare and test +them. They are not an excuse for duplicate live state fields or old runtime +APIs. Removing a supported payload migration or importer is an explicit +breaking saved-data decision; adding one requires App-owned persistence +evidence plus a runtime restore test for the framework mechanism. + `labkit.app.result.File` and `labkit.app.result.Package` describe App-owned outputs. `CallbackContext.writeResultPackage` writes through the runtime so source and project provenance remain consistent. diff --git a/docs/history/records/2026/07/LK-20260713-dic-rigid-point-editor.md b/docs/history/records/2026/07/LK-20260713-dic-rigid-point-editor.md index 81fe6a6ab..fa0b3d8f2 100644 --- a/docs/history/records/2026/07/LK-20260713-dic-rigid-point-editor.md +++ b/docs/history/records/2026/07/LK-20260713-dic-rigid-point-editor.md @@ -15,7 +15,7 @@ scope: Single-click DIC rigid point matching DIC manual rigid matching had draggable points but maintained a separate pointer implementation and required a less consistent placement workflow than -the ROI-center anchors used by Imager Reconstruction. +the ROI-center anchors used by related reconstruction workflows. ## Decision and rationale diff --git a/docs/history/records/2026/07/LK-20260726-runtime-diagnostics-and-workflow-repair.md b/docs/history/records/2026/07/LK-20260726-runtime-diagnostics-and-workflow-repair.md new file mode 100644 index 000000000..97e6b36cb --- /dev/null +++ b/docs/history/records/2026/07/LK-20260726-runtime-diagnostics-and-workflow-repair.md @@ -0,0 +1,139 @@ +# Runtime diagnostics and workflow repair converge on one product path + +```labkit-change +id: LK-20260726-runtime-diagnostics-and-workflow-repair +date: 2026-07-26 +sequence: 159 +type: feat +compatibility: breaking +component: `labkit_launcher` | `1.6.0 -> 1.7.0` +component: `labkit.app` | `1.2.4 -> 2.0.0` +component: `labkit.dta` | `2.0.3 -> 3.0.0` +component: `labkit.image` | `2.0.2 -> 2.0.3` +component: `labkit_DICPostprocess_app` | `1.5.1 -> 1.6.0` +component: `labkit_DICPreprocess_app` | `1.6.1 -> 1.7.0` +component: `labkit_ChronoOverlay_app` | `1.5.1 -> 1.6.0` +component: `labkit_CIC_app` | `1.5.1 -> 1.6.0` +component: `labkit_CSC_app` | `1.5.1 -> 1.6.0` +component: `labkit_EIS_app` | `1.5.2 -> 1.6.0` +component: `labkit_VTResistance_app` | `1.5.1 -> 1.6.0` +component: `labkit_GaitAnalysis_app` | `2.1.1 -> 2.2.0` +component: `labkit_BatchImageCrop_app` | `1.8.1 -> 1.9.0` +component: `labkit_CurvatureMeasurement_app` | `1.5.1 -> 1.6.0` +component: `labkit_FLIRThermal_app` | `1.5.1 -> 1.6.0` +component: `labkit_FocusStack_app` | `1.6.1 -> 1.7.0` +component: `labkit_ImageEnhance_app` | `1.7.1 -> 1.8.0` +component: `labkit_ImageMatch_app` | `1.7.1 -> 1.8.0` +component: `labkit_VideoMarker_app` | `1.6.1 -> 1.7.0` +component: `labkit_FigureStudio_app` | `0.6.5 -> 0.7.0` +component: `labkit_NerveResponseAnalysis_app` | `1.5.1 -> 1.6.0` +component: `labkit_ResponseReviewStats_app` | `1.5.1 -> 1.6.0` +component: `labkit_RHSPreview_app` | `1.5.1 -> 1.6.0` +component: `labkit_TTestWizard_app` | `1.2.0 -> 1.3.0` +component: `labkit_ECGPrint_app` | `1.5.1 -> 1.6.0` +scope: Always-on session diagnostics and semantic App events +scope: Clean startup and explicit synthetic-input generation +scope: Launcher repair boundary and preserved product interaction +scope: Batch Crop native ROI interaction and preview resolution +scope: Explicit DTA units and EIS impedance display units +``` + +## Context + +Debug launching had combined synthetic data, diagnostic persistence, and App +startup into one mode, while ordinary incidents often left too little useful +history. App-owned Log tabs and status/error adapters competed with a partial +framework recorder. Batch Crop also exposed the practical consequence of +testing construction without native interaction: its ROI could be visible yet +fail to respond, and ordinary images were unnecessarily downsampled. + +The root Launcher had simultaneously accumulated installed-product behavior, +repair implementation, and maintenance-tool logic in one rescue file. DTA +inputs also carried ambiguous unit inference into downstream electrochemistry +workflows. + +## Decision and rationale + +Every normal App launch now owns one clean Runtime session with an always-on, +privacy-bounded event journal. Runtime instruments framework operations; +Apps add stable semantic events through `CallbackContext.log`. Trace capture, +session viewing, and bundle export are runtime tools, while synthetic input +generation is a separate explicit developer action that never loads its output +or changes the open project. + +The public App SDK advances to 2.0 because launch diagnostic options and the +old status, error, checkpoint, count, recorder, and debug-sample contracts were +removed rather than kept as misleading compatibility aliases. Each migrated +App requires `labkit.app >=2 <3`. + +The root Launcher remains a small self-contained repair entry. The installed +launcher keeps the established catalog, status, App information, double-click +launch, version browsing, and visual identity, while independent maintenance +operations live under `tools/`. + +## Changes + +- Added canonical session events, durable bounded journals, active-session + leases, degradation reporting, operation/result versus rollback semantics, + a live session viewer, trace control, and diagnostic bundle export. +- Migrated all public Apps to semantic user/developer events, removed duplicate + Log tabs, and exposed explicit anonymous synthetic-input generation from the + ordinary Tools menu. +- Kept default launches data-free and action-free; hidden GUI tests now own + their visibility fixture instead of relying on a test tag. +- Restored Batch Crop ROI body drag, center drag, and click-to-place behavior + through managed native interaction, while preserving the viewport and using + full-resolution previews unless an explicit high-resolution budget applies. +- Made DTA item units explicit, rejected unknown pulse modes, and added EIS + impedance display choices from milliohms through megohms with kilohms as the + default. +- Split Launcher rescue, installed composition, version management, cleanup, + and documentation responsibilities without removing product capabilities. + +## User and data impact + +Users can diagnose a problem after it occurs in an ordinary session, export a +sanitized history, and deliberately generate anonymous reproduction inputs +without restarting in a special mode. App status remains concise, while +developer detail is available through Diagnostics. Existing scientific source +files are not rewritten. + +Batch Crop keeps source resolution for ordinary images and provides responsive +native ROI editing. EIS changes only display and export scaling selected by +the user; calculations retain canonical ohms. Launcher repair remains explicit +and never downloads or replaces files merely because it was opened. + +## Compatibility and migration + +`labkit.app` 2.0 intentionally removes the former launch/debug and diagnostic +adapter APIs. App source using those APIs must migrate to `BuildSyntheticSample` +and semantic `CallbackContext.log` events. Saved App project schemas and their +declared migrations are unchanged. + +`labkit.dta` 3.0 replaces ambiguous numeric unit inference with explicit unit +metadata. Consumers must use declared canonical values. `labkit.image` 2.0.3 +removes library-owned default megapixel policy; Apps choose preview budgets +according to their own workflow. + +## Validation + +Focused framework suites cover event schema, RNG preservation, operation +outcomes, multi-process leases, privacy allowlists, projection degradation, +viewer behavior, bundle export, Runtime instrumentation, and repository +architecture. All 63 public hidden native App smoke cases pass. Every migrated +App family has focused workflow coverage, including Batch Crop native ROI and +preview-resolution contracts, EIS units, DTA parity, Launcher dispatch/repair, +and synthetic-input state neutrality. + +## Evidence + +The `debug-repair` branch contains purpose-based implementation and focused-test +commits. Final evidence is the branch-review validation result, pull-request +CI, merged-main CI, and developer-led manual checks recorded in the pull +request. + +## Known limitations and follow-up + +Hidden GUI tests do not establish pointer feel, dialog usability, visual +quality, or the usefulness and privacy of exported incident bundles. Those +remain explicit manual acceptance gates before merge. diff --git a/docs/libraries/dta/README.md b/docs/libraries/dta/README.md index c8734ab15..b2fdcfc7f 100644 --- a/docs/libraries/dta/README.md +++ b/docs/libraries/dta/README.md @@ -116,12 +116,20 @@ Other modes are `"Metadata only"` and `"Auto from Im only"`. Programmatic aliases `"metadata_first"`, `"metadata_only"`, and `"current_only"` are also accepted. Current-based detection uses 25 percent of `max(abs(Im))` as its threshold, with a `1e-12 A` floor, then selects the longest cathodic segment -and the longest later anodic segment. - -On success, `pulse.cath`, `pulse.gap`, and `pulse.anod` provide times in -seconds and currents in amperes. `pulse.ok` is false and numeric fields are -`NaN` when a valid cathodic/anodic pair cannot be found. Always check -`pulse.ok` before using the windows in a calculation. +and the longest later anodic segment. An unknown mode returns `pulse.ok=false` +with an explanatory message; it is never silently treated as the default. + +On success, `pulse.pre`, `pulse.cath`, `pulse.gap`, `pulse.anod`, and +`pulse.post` provide unit-explicit `start_s` and `end_s` windows. +`pulse.cath.current_A` and `pulse.anod.current_A` contain nominal or measured +phase currents, and `pulse.gap.center_s` contains the alignment center. +`pulse.ok` is false and numeric fields are `NaN` when a valid +cathodic/anodic pair cannot be found. Always check `pulse.ok` before using the +windows in a calculation. + +Version 3 returns only this unit-explicit item and pulse model. Chrono items +use `t_s`, `Vf_V`, and `Im_A`; EIS items use the fields listed below. Earlier +unit-ambiguous aliases are not emitted. ## EIS Data diff --git a/docs/libraries/image/README.md b/docs/libraries/image/README.md index e926c7619..40a25de1c 100644 --- a/docs/libraries/image/README.md +++ b/docs/libraries/image/README.md @@ -21,7 +21,7 @@ preview = labkit.image.ensureRgb(source); preview = min(max(preview, 0), 1); luma = labkit.image.rgb2gray(preview); [preview, scale] = labkit.image.resizeToFit(preview, "MaxHeight", 1500); -[preview, budget] = labkit.image.previewBudget(preview, "MaxPixels", 1.2e6); +[preview, budget] = labkit.image.previewBudget(preview, "MaxPixels", appPreviewBudget); blurred = labkit.image.meanFilter2(preview(:, :, 1), 7); enhanced = labkit.image.adjustBrightnessContrast(preview, 10, 20); @@ -45,6 +45,10 @@ to three channels or drops channels after RGB without changing class or sample values. Callers that need display-ready RGB data explicitly compose `im2double`, `ensureRgb`, and `[0, 1]` clamping as shown above. +`labkit.image.previewBudget` preserves native pixels by default. An App passes +an explicit finite `"MaxPixels"` value only when its own workflow permits +sampling; the resulting integer coordinate scale is returned with the preview. + ## Provided Operations The module provides: @@ -54,8 +58,8 @@ The module provides: - `imread`/`imwrite` wrappers that normalize app-facing edge behavior - MATLAB-compatible image conversion, explicit RGB shaping, preview-size fitting, and edge-normalized mean filtering -- display-pixel budget helpers for responsive previews while preserving a - documented integer coordinate scale +- caller-owned display-pixel budget helpers for responsive previews while + preserving a documented integer coordinate scale - generic image enhancement primitives such as brightness/contrast, HSV hue/saturation, gray-world white balance, local contrast, and sharpening diff --git a/labkit_launcher.m b/labkit_launcher.m index 2cfccceb0..1d89010d9 100644 --- a/labkit_launcher.m +++ b/labkit_launcher.m @@ -1,2548 +1,597 @@ function varargout = labkit_launcher(varargin) -%LABKIT_LAUNCHER Self-contained GUI selector and repair updater for LabKit. +%LABKIT_LAUNCHER Repair a LabKit installation or delegate to its installed launcher. % -% Usage: -% labkit_launcher -% apps = labkit_launcher("list") -% info = labkit_launcher("version") -% page = labkit_launcher("documentation", appCommand) -% -% This launcher intentionally avoids dependencies on other LabKit .m files so -% it can still restore a damaged zip install when packages, apps, or scripts -% have been deleted. App launch still adds the restored app folders to the -% MATLAB path before calling the selected app entry point. -% -% Maintenance map: -% Section: Public entrypoint and version -% Section: Path setup -% Section: Main launcher window -% Section: App documentation -% Section: Version manager window -% Section: Version manager support -% Section: Table selection and display helpers -% Section: Launcher status messages -% Section: App discovery and catalog metadata -% Section: Clean Artifacts action -% Section: Documentation site action -% Section: Code Analyzer action -% Section: Performance profile action -% Section: App package action -% Section: Update entrypoints and install transaction -% Section: GitHub update source discovery -% Section: Update validation and launcher window helpers -% Section: Update install file operations -% Section: Shared filesystem and path helpers - - [mode, modeArgs] = parseMode(varargin); - if mode == "version" - varargout = {launcherVersion()}; - return; - end - - if mode == "list" - root = fileparts(mfilename('fullpath')); - apps = discoverApps(root); - varargout = {appCatalogTable(apps)}; - return; - end - if mode == "documentation" - root = fileparts(mfilename('fullpath')); - varargout = {launcherAppDocumentation(root, modeArgs(1))}; - return; - end - if nargout > 1 - error('labkit_launcher:TooManyOutputs', ... - 'labkit_launcher returns at most the launcher figure handle.'); - end - - root = fileparts(mfilename('fullpath')); - initializeLauncherPath(root); - fig = runLauncher(root); - if nargout == 1 - varargout = {fig}; - end -end - -%% Section: Public entrypoint and version - -function [mode, modeArgs] = parseMode(args) - mode = "gui"; - modeArgs = strings(1, 0); - if isempty(args) - return; - end - first = string(args{1}); - if numel(args) == 2 && isscalar(first) && strcmpi(first, "documentation") - command = string(args{2}); - if ~isscalar(command) || strlength(strtrim(command)) == 0 - error('labkit_launcher:InvalidInput', ... - 'Documentation mode requires one nonempty app command.'); - end - mode = "documentation"; - modeArgs = command; - return; - end - if numel(args) ~= 1 - error('labkit_launcher:InvalidInput', ... - ['labkit_launcher accepts no inputs, "list", "version", or ' ... - '"documentation" plus one app command.']); - end - value = string(args{1}); - if ~isscalar(value) - error('labkit_launcher:InvalidInput', ... - 'labkit_launcher mode must be a string scalar.'); - end - if strlength(strtrim(value)) == 0 - error('labkit_launcher:InvalidInput', ... - 'Unsupported labkit_launcher mode: empty string.'); - end - if strcmpi(value, "list") - mode = "list"; - return; - end - if strcmpi(value, "version") - mode = "version"; - return; - end - error('labkit_launcher:InvalidInput', ... - 'Unsupported labkit_launcher mode: %s', value); -end - -function info = launcherVersion() - info = struct( ... - "name", "labkit_launcher", ... - "displayName", "LabKit App Launcher", ... - "version", "1.6.0", ... - "updated", "2026-07-20"); -end - -function titleText = launcherVersionTitle() - info = launcherVersion(); - titleText = info.displayName + " v" + info.version + " (" + info.updated + ")"; -end - -%% Section: Path setup - -function initializeLauncherPath(root) - addPathIfMissing(root); - setappdata(groot, 'labkitFigureStudioLauncher', ... - @(ax) launchFigureStudioFromAxes(root, ax)); -end - -function addPathIfMissing(folder, varargin) - if exist(folder, 'dir') == 7 && ~pathContains(folder) - addpath(folder, varargin{:}); - end -end - -function launchFigureStudioFromAxes(root, ax) - addPathIfMissing(fullfile(root, 'apps', 'labkit_core', 'figure_studio')); - labkit_FigureStudio_app("axes", ax); -end - -%% Section: Main launcher window - -function fig = runLauncher(root) - panelFontSize = 15; - tableFontSize = 15; - - closeExistingLauncherFigures(); - figArgs = {'Name', 'LabKit App Launcher', ... - 'Tag', launcherFigureTag(), ... - 'Position', [150 130 1260 620], 'Color', [0.97 0.98 0.99]}; - if launcherGuiTestMode() == "hidden" - figArgs = [figArgs, {'Visible', 'off'}]; - end - fig = uifigure(figArgs{:}); - fig.Name = char(launcherVersionTitle()); - applyLauncherGuiTestMode(fig); - paintVisibleLauncherFigure(); - main = uigridlayout(fig, [1 3]); - main.ColumnWidth = {420, 5, '1x'}; - main.RowHeight = {'1x'}; - main.Padding = [6 6 6 6]; - main.ColumnSpacing = 0; - - leftPanel = uipanel(main, 'Title', 'Launcher', 'FontSize', panelFontSize); - leftPanel.Layout.Row = 1; - leftPanel.Layout.Column = 1; - divider = uipanel(main, 'BorderType', 'none', ... - 'BackgroundColor', [0.78 0.80 0.82]); - divider.Layout.Row = 1; - divider.Layout.Column = 2; - rightPanel = uipanel(main, 'Title', 'Applications', 'FontSize', panelFontSize); - rightPanel.Layout.Row = 1; - rightPanel.Layout.Column = 3; - - controlsGrid = uigridlayout(leftPanel, [5 1]); - controlsGrid.RowHeight = {108, 72, 108, 72, '1x'}; - controlsGrid.Padding = [6 6 6 6]; - controlsGrid.RowSpacing = 6; - - runPanel = uipanel(controlsGrid, 'Title', 'Run Apps'); - runPanel.Layout.Row = 1; - runGrid = uigridlayout(runPanel, [2 2]); - runGrid.RowHeight = {'1x', '1x'}; - runGrid.ColumnWidth = {'1x', '1x'}; - runGrid.Padding = [5 5 5 5]; - runGrid.RowSpacing = 5; - runGrid.ColumnSpacing = 6; - btnOpen = uibutton(runGrid, 'Text', 'Open Selected App', ... - 'ButtonPushedFcn', @onLaunchSelected); - btnOpen.Layout.Row = 1; - btnOpen.Layout.Column = 1; - btnDebug = uibutton(runGrid, 'Text', 'Open Debug', ... - 'ButtonPushedFcn', @onLaunchSelectedDebug); - btnDebug.Layout.Row = 1; - btnDebug.Layout.Column = 2; - btnRefresh = uibutton(runGrid, 'Text', 'Refresh App List', ... - 'ButtonPushedFcn', @onRefreshApps); - btnRefresh.Layout.Row = 2; - btnRefresh.Layout.Column = 1; - btnAppDocs = uibutton(runGrid, 'Text', 'Documentation and History', ... - 'ButtonPushedFcn', @onOpenSelectedDocumentation); - btnAppDocs.Layout.Row = 2; - btnAppDocs.Layout.Column = 2; - if isprop(btnAppDocs, 'Tooltip') - btnAppDocs.Tooltip = 'Open the generated documentation page for the selected app.'; - end - - updatePanel = uipanel(controlsGrid, 'Title', 'Versions and Install'); - updatePanel.Layout.Row = 2; - updateGrid = uigridlayout(updatePanel, [1 3]); - updateGrid.ColumnWidth = {'1x', '1x', '1x'}; - updateGrid.RowHeight = {'1x'}; - updateGrid.Padding = [5 5 5 5]; - updateGrid.ColumnSpacing = 6; - btnUpdate = uibutton(updateGrid, 'Text', 'Latest', ... - 'ButtonPushedFcn', @onUpdateFromMain); - btnUpdate.Layout.Row = 1; - btnUpdate.Layout.Column = 1; - btnRelease = uibutton(updateGrid, 'Text', 'Release', ... - 'ButtonPushedFcn', @onUpdateFromStable); - btnRelease.Layout.Row = 1; - btnRelease.Layout.Column = 2; - btnVersions = uibutton(updateGrid, 'Text', 'Versions', ... - 'ButtonPushedFcn', @onOpenVersionManager); - btnVersions.Layout.Row = 1; - btnVersions.Layout.Column = 3; - if isprop(btnUpdate, 'Tooltip') - btnUpdate.Tooltip = 'Download and apply the latest main branch zip.'; - btnRelease.Tooltip = 'Download and apply the latest GitHub release or tag zip.'; - btnVersions.Tooltip = 'Choose a recent release, tag, or main-branch commit.'; - end - maintenancePanel = uipanel(controlsGrid, ... - 'Title', 'Development and Maintenance'); - maintenancePanel.Layout.Row = 3; - maintenanceGrid = uigridlayout(maintenancePanel, [2 2]); - maintenanceGrid.ColumnWidth = {'1x', '1x'}; - maintenanceGrid.RowHeight = {'1x', '1x'}; - maintenanceGrid.Padding = [5 5 5 5]; - maintenanceGrid.RowSpacing = 5; - maintenanceGrid.ColumnSpacing = 6; - btnDocs = uibutton(maintenanceGrid, 'Text', 'Update Documentation', ... - 'ButtonPushedFcn', @onUpdateDocumentation); - btnDocs.Layout.Row = 1; - btnDocs.Layout.Column = 1; - btnCode = uibutton(maintenanceGrid, 'Text', 'Run Code Analyzer', ... - 'ButtonPushedFcn', @onRunCodeCheck); - btnCode.Layout.Row = 1; - btnCode.Layout.Column = 2; - btnProfile = uibutton(maintenanceGrid, 'Text', 'Profile Selected App', ... - 'ButtonPushedFcn', @onProfileSelectedApp); - btnProfile.Layout.Row = 2; - btnProfile.Layout.Column = 1; - btnClean = uibutton(maintenanceGrid, 'Text', 'Clean Artifacts', ... - 'ButtonPushedFcn', @onCleanArtifacts); - btnClean.Layout.Row = 2; - btnClean.Layout.Column = 2; - - packagePanel = uipanel(controlsGrid, 'Title', 'Package and Publish'); - packagePanel.Layout.Row = 4; - packageGrid = uigridlayout(packagePanel, [1 2]); - packageGrid.ColumnWidth = {'1x', '1x'}; - packageGrid.RowHeight = {'1x'}; - packageGrid.Padding = [5 5 5 5]; - packageGrid.ColumnSpacing = 6; - btnPackage = uibutton(packageGrid, 'Text', 'Package Checked', ... - 'ButtonPushedFcn', @(~, ~) onPackageCheckedApps(false)); - btnPackage.Layout.Row = 1; - btnPackage.Layout.Column = 1; - btnPackagePcode = uibutton(packageGrid, 'Text', 'Checked P-code', ... - 'ButtonPushedFcn', @(~, ~) onPackageCheckedApps(true)); - btnPackagePcode.Layout.Row = 1; - btnPackagePcode.Layout.Column = 2; - if isprop(btnProfile, 'Tooltip') - btnProfile.Tooltip = ['Launch and profile the selected app ' ... - 'until that app window closes. Saves the report without opening a browser.']; - end - if isprop(btnPackage, 'Tooltip') - btnPackage.Tooltip = ['Create one standalone zip containing every checked app, ' ... - 'their assets, the shared +labkit library, the launcher, and deployment tools.']; - btnPackagePcode.Tooltip = ['Create the same multi-app zip with MATLAB code ' ... - 'encoded as .p files instead of source .m files.']; - end - txtInfo = uitextarea(controlsGrid, 'Editable', 'off', 'Value', {'Ready.'}); - txtInfo.Layout.Row = 5; - - tableGrid = uigridlayout(rightPanel, [1 1]); - tableGrid.Padding = [4 4 4 4]; - appTable = uitable(tableGrid, ... - 'ColumnName', {'Package', 'Family', 'App', 'Visibility', 'Version', 'Updated', 'Command'}, ... - 'ColumnEditable', [true false false false false false false], 'RowName', {}, ... - 'FontSize', tableFontSize); - if isprop(appTable, 'ColumnFormat') - appTable.ColumnFormat = {'logical', 'char', 'char', 'char', 'char', 'char', 'char'}; - end - appTable.ColumnWidth = {72, 140, 190, 90, 90, 110, 'auto'}; - configureTable(appTable, @onSelectionChanged, @onTableDoubleClicked); - appTable.CellEditCallback = @onPackageCheckChanged; - - ui = struct(); - ui.figure = fig; - ui.controls = struct(); - ui.controls.selectedDetails = struct('textArea', txtInfo); - ui.controls.statusLine = struct('textArea', txtInfo); - ui.controls.appTable = struct('table', appTable); - setappdata(fig, 'labkitLauncherView', ui); - - state = struct('apps', emptyAppStruct(), 'visibleApps', emptyAppStruct(), ... - 'selectedRow', 1, 'status', "Loading app list...", ... - 'packageCommands', strings(0, 1), ... - 'actionBusy', false, ... - 'tools', launcherToolAvailability(root)); - applyToolButtonTooltips(); - appTable.Data = cell(0, 7); - setLaunchEnabled(false); - updateInfo("Loading app list..."); - drawnow limitrate; - - state.apps = discoverApps(root); - state.visibleApps = state.apps; - state.status = integrityStatus(root, state.apps); - refreshTable(); - - function onRefreshApps(varargin) - beginLauncherAction("Refreshing app list..."); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Refresh App List', ... - 'Message', 'Scanning app entry points...', ... - 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - selectedCommand = currentSelectedAppCommand(); - state.tools = launcherToolAvailability(root); - applyToolButtonTooltips(); - state.apps = discoverApps(root); - state.visibleApps = state.apps; - state.selectedRow = appRowByCommand(state.visibleApps, selectedCommand); - initializeLauncherPath(root); - refreshTable(); - catch err - setStatus(sprintf('Refresh app list failed: %s', err.message)); - end - clear dlgCleanup; - endLauncherAction(); - end - - function onSelectionChanged(~, event) - row = eventRow(event); - if ~isnan(row) - state.selectedRow = row; - refreshSelection(); - end - end - - function onPackageCheckChanged(~, event) - if isempty(event.Indices) || isempty(state.visibleApps) - return; - end - row = event.Indices(1, 1); - if row < 1 || row > numel(state.visibleApps) - return; - end - command = string(state.visibleApps(row).command); - state.packageCommands(state.packageCommands == command) = []; - if logical(event.NewData) - state.packageCommands(end+1, 1) = command; - end - refreshSelection(); - end - - function onTableDoubleClicked(~, event) - row = eventRow(event); - if ~isnan(row) - state.selectedRow = row; - refreshSelection(); - end - launchSelectedApp(false); - end - - function onLaunchSelected(varargin) - launchSelectedApp(false); - end - - function onLaunchSelectedDebug(varargin) - launchSelectedApp(true); - end - - function onOpenSelectedDocumentation(varargin) - if isempty(state.visibleApps) - setStatus('No app is available for documentation.'); - return; - end - row = min(max(state.selectedRow, 1), numel(state.visibleApps)); - app = state.visibleApps(row); - try - page = launcherAppDocumentation(root, app.command); - if launcherGuiTestMode() ~= "hidden" - web(page, '-browser'); - end - setStatus(sprintf('Opened documentation for %s: %s', ... - app.command, relativePath(root, page))); - catch err - setStatus(sprintf('Documentation unavailable for %s: %s', ... - app.command, err.message)); - end - end - - function onProfileSelectedApp(varargin) - if state.actionBusy - return; - end - if isempty(state.visibleApps) - setStatus('No app entry points found. Use GitHub Update to repair this install.'); - return; - end - row = min(max(state.selectedRow, 1), numel(state.visibleApps)); - app = state.visibleApps(row); - beginLauncherAction(profileStartStatus(app)); - profileSelectedApp(app); - endLauncherAction(); - end - - function onPackageCheckedApps(usePcode) - if state.actionBusy - return; - end - apps = checkedPackageApps(state.visibleApps, state.packageCommands); - if isempty(apps) - setStatus('Check one or more apps in the Package column first.'); - return; - end - summary = packageAppSummary(apps); - beginLauncherAction(sprintf('Packaging %s...', summary)); - setStatus(sprintf('Packaging %s...', summary)); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Package LabKit App', ... - 'Message', 'Preparing package...', 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = packageSelectedLabKitApps(root, apps, logical(usePcode), @onPackageProgress); - setStatus(packageSuccessStatus(apps, result)); - catch err - setStatus(sprintf('Package failed for %s: %s', summary, err.message)); - end - clear dlgCleanup; - endLauncherAction(); - - function onPackageProgress(message, value) - setStatus(message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function onCleanArtifacts(varargin) - if state.actionBusy - return; - end - if ~confirmCleanArtifacts(fig) - setStatus('Clean artifacts canceled.'); - return; - end - beginLauncherAction("Cleaning generated artifacts..."); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Clean Artifacts', ... - 'Message', 'Preparing cleanup...', 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = cleanGeneratedArtifacts(root, @onCleanProgress); - setStatus(cleanArtifactsStatus(result)); - catch err - setStatus(sprintf('Clean artifacts failed: %s', err.message)); - end - clear dlgCleanup; - endLauncherAction(); - - function onCleanProgress(message, value) - setStatus(message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function onUpdateDocumentation(varargin) - if state.actionBusy - return; - end - beginLauncherAction("Updating documentation site..."); - setStatus('Updating documentation site...'); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Update Documentation', ... - 'Message', 'Reading documentation sources...', ... - 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = generateLauncherDocumentation(root, @onDocsProgress); - setStatus(documentationSuccessStatus(result)); - catch err - setStatus(sprintf('Documentation update failed: %s', err.message)); - end - clear dlgCleanup; - endLauncherAction(); - - function onDocsProgress(message, value) - setStatus(message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function onRunCodeCheck(varargin) - if state.actionBusy - return; - end - beginLauncherAction("Running MATLAB Code Analyzer..."); - setStatus('Running MATLAB Code Analyzer...'); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'MATLAB Code Analyzer', ... - 'Message', 'Preparing scan...', 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - report = runCodeAnalyzerReport(root, @onCodeCheckProgress); - setStatus(codeCheckStatus(report)); - catch err - setStatus(sprintf('Code Analyzer failed: %s', err.message)); - end - clear dlgCleanup; - endLauncherAction(); - - function onCodeCheckProgress(message, value) - setStatus(message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function onUpdateFromMain(varargin) - if state.actionBusy - return; - end - beginLauncherAction("Updating LabKit from GitHub main..."); - setStatus('Updating LabKit from GitHub main...'); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Update LabKit', ... - 'Message', 'Preparing update...', 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = launcherUpdateFromMainZip(root, @onUpdateProgress); - setStatus(result.message); - onRefreshApps(); - catch err - setStatus(sprintf('Update failed: %s', err.message)); - end - clear dlgCleanup; - endLauncherAction(); - - function onUpdateProgress(message, value) - setStatus(message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function onUpdateFromStable(varargin) - if state.actionBusy - return; - end - beginLauncherAction("Updating LabKit from latest release/tag..."); - setStatus('Updating LabKit from latest release/tag...'); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Update LabKit', ... - 'Message', 'Preparing release update...', 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = launcherUpdateFromStableZip(root, @onUpdateProgress); - setStatus(result.message); - onRefreshApps(); - catch err - setStatus(sprintf('Update failed: %s', err.message)); - end - clear dlgCleanup; - endLauncherAction(); - - function onUpdateProgress(message, value) - setStatus(message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function onOpenVersionManager(varargin) - if state.actionBusy - return; - end - openVersionManager(fig, root, @onVersionManagerUpdated, @setStatus); - end - - function onVersionManagerUpdated() - onRefreshApps(); - end - - function launchSelectedApp(debugMode) - if isempty(state.visibleApps) - setStatus('No app entry points found. Use GitHub Update to repair this install.'); - return; - end - row = min(max(state.selectedRow, 1), numel(state.visibleApps)); - app = state.visibleApps(row); - beginLauncherAction(launchStartStatus(app, debugMode)); - setStatus(launchStartStatus(app, debugMode)); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Open LabKit App', ... - 'Message', char(launchStartStatus(app, debugMode)), ... - 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - updateProgressDialog(dlg, sprintf('Adding %s to MATLAB path...', app.command), NaN); - drawnow limitrate; - initializeAppPath(app); - updateProgressDialog(dlg, sprintf('Opening %s...', app.command), NaN); - drawnow limitrate; - setStatus(launchHandOffStatus(app, debugMode)); - updateProgressDialog(dlg, sprintf('Initializing %s...', app.command), NaN); - drawnow limitrate; - diagnosticFolder = ""; - if debugMode - [diagnostics, diagnosticFolder] = launcherDiagnosticOptions(root, app); - feval(app.command, "Diagnostics", diagnostics); - else - feval(app.command); - end - setStatus(launchSuccessStatus(root, app, debugMode, diagnosticFolder)); - catch err - setStatus(sprintf(['Failed to launch %s: %s. If project files are missing ' ... - 'or damaged, use GitHub Update to repair this install.'], ... - app.command, err.message)); - end - clear dlgCleanup; - endLauncherAction(); - end - - function profileSelectedApp(app) - setStatus(profileStartStatus(app)); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(fig, 'Title', 'Profile LabKit App', ... - 'Message', char(profileStartStatus(app)), ... - 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = runLauncherAppProfile(root, app); - setStatus(profileSuccessStatus(app, result)); - catch err - setStatus(sprintf('Performance profile failed for %s: %s', ... - app.command, err.message)); - end - clear dlgCleanup; - end - - function refreshTable() - state.visibleApps = state.apps; - state.packageCommands = retainedPackageCommands( ... - state.visibleApps, state.packageCommands); - appTable.Data = appDisplayRows(state.visibleApps, state.packageCommands); - adjustLauncherWidthForAppTable(); - adjustAppTableColumns(); - state.selectedRow = min(max(state.selectedRow, 1), max(numel(state.visibleApps), 1)); - selectTableRow(appTable, state.selectedRow, state.visibleApps); - setLaunchEnabled(~isempty(state.visibleApps)); - refreshSelection(); - if isempty(state.visibleApps) - setStatus('No app entry points found. Use GitHub Update to repair this install.'); - else - setStatus(integrityStatus(root, state.apps)); - end - end - - function refreshSelection() - if isempty(state.visibleApps) - updateInfo(noMatchingAppDetails()); - return; - end - row = min(max(state.selectedRow, 1), numel(state.visibleApps)); - details = selectedAppDetails(state.visibleApps(row)); - details{end+1, 1} = sprintf('Checked for package: %d app(s)', ... - numel(state.packageCommands)); - updateInfo(details); - end - - function command = currentSelectedAppCommand() - command = ""; - if isempty(state.visibleApps) - return; - end - row = min(max(state.selectedRow, 1), numel(state.visibleApps)); - command = string(state.visibleApps(row).command); - end - - function setLaunchEnabled(enabled) - stateValue = matlab.lang.OnOffSwitchState(enabled); - btnOpen.Enable = stateValue; - btnDebug.Enable = stateValue; - btnAppDocs.Enable = stateValue; - btnProfile.Enable = matlab.lang.OnOffSwitchState(enabled && state.tools.profile); - btnPackage.Enable = matlab.lang.OnOffSwitchState(enabled && state.tools.package); - btnPackagePcode.Enable = matlab.lang.OnOffSwitchState(enabled && state.tools.package); - btnCode.Enable = matlab.lang.OnOffSwitchState(state.tools.codecheck); - btnDocs.Enable = matlab.lang.OnOffSwitchState(state.tools.docs); - end - - function beginLauncherAction(message) - state.actionBusy = true; - setLauncherControlsEnabled(false); - setStatus(message); - end - - function endLauncherAction() - state.actionBusy = false; - setLauncherControlsEnabled(true); - end - - function setLauncherControlsEnabled(enabled) - stateValue = matlab.lang.OnOffSwitchState(enabled); - btnUpdate.Enable = stateValue; - btnRelease.Enable = stateValue; - btnVersions.Enable = stateValue; - btnRefresh.Enable = stateValue; - btnClean.Enable = stateValue; - btnAppDocs.Enable = stateValue; - if enabled - setLaunchEnabled(~isempty(state.visibleApps)); - else - setLaunchEnabled(false); - btnCode.Enable = 'off'; - btnDocs.Enable = 'off'; - end - end - - function applyToolButtonTooltips() - if ~isprop(btnCode, 'Tooltip') - return; - end - if state.tools.codecheck - btnCode.Tooltip = ['Run MATLAB Code Analyzer, write the JSON report, ' ... - 'then open the HTML viewer from tools/codecheck.']; - else - btnCode.Tooltip = missingToolTooltip('tools/codecheck/runCodecheckReport.m or .p'); - end - if state.tools.docs - btnDocs.Tooltip = ['Rebuild the tracked site/ documentation from docs/ ' ... - 'and public MATLAB help contracts, then open its home page.']; - else - btnDocs.Tooltip = missingToolTooltip('tools/docs/renderLabKitDocs.m or .p'); - end - if state.tools.profile - btnProfile.Tooltip = ['Launch and profile the selected app ' ... - 'until that app window closes. Saves the report without opening a browser.']; - else - btnProfile.Tooltip = missingToolTooltip('tools/profiling/profileLabKitTarget.m or .p'); - end - if state.tools.package - btnPackage.Tooltip = ['Create one standalone zip containing every checked app, ' ... - 'their assets, the shared +labkit library, the launcher, and deployment tools.']; - btnPackagePcode.Tooltip = ['Create the same multi-app zip with MATLAB code ' ... - 'encoded as .p files instead of source .m files.']; - else - tooltip = missingToolTooltip('tools/deployment/packageLabKitApp.m or .p'); - btnPackage.Tooltip = tooltip; - btnPackagePcode.Tooltip = tooltip; - end - end - - function setStatus(message) - state.status = string(message); - refreshSelection(); - end - - function updateInfo(detailRows) - rows = reshape(cellstr(string(detailRows(:))), [], 1); - txtInfo.Value = [{['Status: ' char(state.status)]}; {''}; rows]; - end - - function adjustLauncherWidthForAppTable() - preferredTableWidth = launcherTablePreferredWidth(appTable.Data); - requiredWidth = 360 + 5 + preferredTableWidth + 34; - if fig.Position(3) >= requiredWidth - return; - end - fig.Position(3) = min(max(requiredWidth, 1260), 1700); - end - - function adjustAppTableColumns() - if ~isvalid(appTable) - return; - end - tableWidth = max(700, fig.Position(3) - 360 - 5 - 34); - appTable.ColumnWidth = launcherTableColumnWidths(appTable.Data, tableWidth); - end -end - -function initializeAppPath(app) - addPathIfMissing(app.folder, '-end'); -end - -%% Section: App documentation - -function page = launcherAppDocumentation(root, appCommand) - apps = discoverApps(root); - commands = string({apps.command}); - match = find(commands == string(appCommand), 1); - if isempty(match) || ~strcmp(apps(match).visibility, 'public') - error('labkit_launcher:AppDocumentationUnavailable', ... - 'No public documentation page is available for %s.', appCommand); - end - [~, appId] = fileparts(apps(match).folder); - appId = strrep(appId, '_', '-'); - manuals = dir(fullfile(root, 'docs', 'apps', '*', appId, 'README.md')); - if numel(manuals) ~= 1 - error('labkit_launcher:AppDocumentationUnavailable', ... - ['Expected one manual at docs/apps//%s/README.md; ' ... - 'found %d.'], appId, numel(manuals)); - end - familyFolder = fileparts(manuals(1).folder); - [~, familyId] = fileparts(familyFolder); - page = fullfile(root, 'site', 'apps', familyId, [appId '.html']); - if exist(page, 'file') ~= 2 - error('labkit_launcher:AppDocumentationNotBuilt', ... - ['The generated page is missing for %s. Use Update Documentation ' ... - 'and try again.'], appCommand); - end -end - -function position = centeredChildPosition(parentFig, childSize) - parentPosition = parentFig.Position; - position = [ ... - parentPosition(1) + max(20, (parentPosition(3) - childSize(1)) / 2), ... - parentPosition(2) + max(20, (parentPosition(4) - childSize(2)) / 2), ... - childSize]; -end - -%% Section: Version manager window - -function manager = openVersionManager(parentFig, root, refreshCallback, statusCallback) - if nargin < 3 - refreshCallback = []; - end - if nargin < 4 - statusCallback = []; - end - - managerArgs = {'Name', 'LabKit Version Manager', ... - 'Position', [210 170 900 520], 'Color', [0.97 0.98 0.99]}; - if launcherGuiTestMode() == "hidden" - managerArgs = [managerArgs, {'Visible', 'off'}]; - end - manager = uifigure(managerArgs{:}); - applyLauncherGuiTestMode(manager); - - layout = uigridlayout(manager, [4 1]); - layout.RowHeight = {86, '1x', 36, 76}; - layout.Padding = [8 8 8 8]; - layout.RowSpacing = 8; - - currentInfo = uitextarea(layout, 'Editable', 'off', ... - 'Value', cellstr(currentInstallVersionLines(root))); - currentInfo.Layout.Row = 1; - - sourceTable = uitable(layout, ... - 'ColumnName', {'Type', 'Version or commit', 'Date', 'Summary'}, ... - 'ColumnEditable', [false false false false], 'RowName', {}, ... - 'FontSize', 14); - sourceTable.ColumnWidth = {100, 170, 170, 'auto'}; - sourceTable.Layout.Row = 2; - - buttonGrid = uigridlayout(layout, [1 4]); - buttonGrid.Layout.Row = 3; - buttonGrid.ColumnWidth = {'1x', '1x', '1x', '1x'}; - buttonGrid.RowHeight = {'1x'}; - buttonGrid.Padding = [0 0 0 0]; - buttonGrid.ColumnSpacing = 8; - - btnRefresh = uibutton(buttonGrid, 'Text', 'Refresh', ... - 'ButtonPushedFcn', @onRefreshSources); - btnRefresh.Layout.Column = 1; - btnInstall = uibutton(buttonGrid, 'Text', 'Install Selected', ... - 'ButtonPushedFcn', @onInstallSelected); - btnInstall.Layout.Column = 2; - btnClose = uibutton(buttonGrid, 'Text', 'Close', ... - 'ButtonPushedFcn', @(~, ~) close(manager)); - btnClose.Layout.Column = 4; - if isprop(btnRefresh, 'Tooltip') - btnRefresh.Tooltip = 'Fetch recent GitHub releases, tags, and main commits.'; - btnInstall.Tooltip = 'Download and apply the selected LabKit version.'; - btnClose.Tooltip = 'Close version manager.'; - end - - statusText = uitextarea(layout, 'Editable', 'off', ... - 'Value', {'Choose a recent release, tag, or main-branch commit.'}); - statusText.Layout.Row = 4; - - sourceState = struct('sources', emptyVersionSources(), ... - 'selectedRow', 1, 'actionBusy', false); - configureTable(sourceTable, @onSourceSelection, @onSourceDoubleClicked); - onRefreshSources(); - - function onRefreshSources(varargin) - if sourceState.actionBusy - return; - end - beginVersionManagerAction('Fetching recent LabKit versions from GitHub...'); - setManagerStatus('Fetching recent LabKit versions from GitHub...'); - notifyStatus(statusCallback, 'Fetching recent LabKit versions from GitHub...'); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(manager, 'Title', 'Refresh LabKit Versions', ... - 'Message', 'Fetching recent releases, tags, and commits...', ... - 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - sourceState.sources = recentVersionSources(); - sourceState.selectedRow = 1; - sourceTable.Data = versionSourceRows(sourceState.sources); - btnInstall.Enable = matlab.lang.OnOffSwitchState(~isempty(sourceState.sources)); - if isempty(sourceState.sources) - setManagerStatus('No release, tag, or commit options were returned by GitHub.'); - notifyStatus(statusCallback, 'No release, tag, or commit options were returned by GitHub.'); - else - message = sprintf('Loaded %d version option(s). Select one to install or roll back.', ... - numel(sourceState.sources)); - setManagerStatus(message); - notifyStatus(statusCallback, message); - end - catch err - sourceState.sources = emptyVersionSources(); - sourceTable.Data = cell(0, 4); - btnInstall.Enable = 'off'; - message = sprintf('Version lookup failed: %s', err.message); - setManagerStatus(message); - notifyStatus(statusCallback, message); - end - clear dlgCleanup; - endVersionManagerAction(); - end - - function onSourceSelection(~, event) - row = eventRow(event); - if ~isnan(row) - sourceState.selectedRow = row; - end - end - - function onSourceDoubleClicked(varargin) - onInstallSelected(); - end - - function onInstallSelected(varargin) - if sourceState.actionBusy - return; - end - if isempty(sourceState.sources) - setManagerStatus('No version option is available to install.'); - return; - end - row = min(max(sourceState.selectedRow, 1), numel(sourceState.sources)); - source = sourceState.sources(row); - beginVersionManagerAction(sprintf('Preparing %s...', char(source.label))); - setManagerStatus(sprintf('Preparing %s...', char(source.label))); - notifyStatus(statusCallback, sprintf('Updating LabKit from %s...', char(source.label))); - drawnow; - dlg = []; - try - dlg = uiprogressdlg(manager, 'Title', 'Install LabKit Version', ... - 'Message', 'Preparing update...', 'Indeterminate', 'on'); - catch - end - if ~isempty(dlg) - dlgCleanup = onCleanup(@() close(dlg)); - end - try - result = launcherUpdateFromZipSource(root, source, @onVersionUpdateProgress); - setManagerStatus(result.message); - notifyStatus(statusCallback, result.message); - currentInfo.Value = cellstr(currentInstallVersionLines(root)); - if ~isempty(refreshCallback) - refreshCallback(); - end - catch err - message = sprintf('Version install failed: %s', err.message); - setManagerStatus(message); - notifyStatus(statusCallback, message); - end - clear dlgCleanup; - endVersionManagerAction(); - - function onVersionUpdateProgress(message, value) - setManagerStatus(message); - notifyStatus(statusCallback, message); - updateProgressDialog(dlg, message, value); - drawnow limitrate; - end - end - - function beginVersionManagerAction(message) - sourceState.actionBusy = true; - setVersionManagerControlsEnabled(false); - setManagerStatus(message); - end - - function endVersionManagerAction() - sourceState.actionBusy = false; - setVersionManagerControlsEnabled(true); - end - - function setVersionManagerControlsEnabled(enabled) - stateValue = matlab.lang.OnOffSwitchState(enabled); - btnRefresh.Enable = stateValue; - btnClose.Enable = stateValue; - if enabled - btnInstall.Enable = matlab.lang.OnOffSwitchState(~isempty(sourceState.sources)); - else - btnInstall.Enable = 'off'; - end - end - - function setManagerStatus(message) - statusText.Value = cellstr(wrapDescription(string(message))); - end -end - -%% Section: Version manager support - -function notifyStatus(statusCallback, message) - if isempty(statusCallback) - return; - end - try - statusCallback(string(message)); - catch - end -end - -function lines = currentInstallVersionLines(root) - info = launcherVersion(); - lines = [ - string(sprintf("Current launcher: %s v%s (%s)", ... - info.displayName, info.version, info.updated)) - "Install folder: " + string(root) - installFolderPolicyLine(root) - ]; -end - -function line = installFolderPolicyLine(root) - if exist(fullfile(root, ".git"), "dir") == 7 - line = "Git checkout detected: launcher zip updates are disabled; use git for this tree."; - return; - end - line = "Update policy: the current runtime folder is moved into a dated LabKit-previous-* snapshot before replacement."; -end - -function rows = versionSourceRows(sources) - rows = cell(numel(sources), 4); - for k = 1:numel(sources) - rows{k, 1} = char(sources(k).kind); - rows{k, 2} = char(sources(k).name); - rows{k, 3} = char(sources(k).date); - rows{k, 4} = char(sources(k).summary); - end -end - -%% Section: Table selection and display helpers - -function configureTable(tableHandle, selectionCallback, doubleClickCallback) - if isprop(tableHandle, 'SelectionChangedFcn') - tableHandle.SelectionChangedFcn = selectionCallback; - else - tableHandle.CellSelectionCallback = selectionCallback; - end - if isprop(tableHandle, 'SelectionType') - tableHandle.SelectionType = 'row'; - end - if isprop(tableHandle, 'DoubleClickedFcn') - tableHandle.DoubleClickedFcn = doubleClickCallback; - elseif isprop(tableHandle, 'CellDoubleClickedFcn') - tableHandle.CellDoubleClickedFcn = doubleClickCallback; - end -end - -function row = eventRow(event) - row = NaN; - if isprop(event, 'Indices') && ~isempty(event.Indices) - row = event.Indices(1, 1); - elseif isprop(event, 'Selection') && ~isempty(event.Selection) - row = event.Selection(1, 1); - elseif isstruct(event) && isfield(event, 'Indices') && ~isempty(event.Indices) - row = event.Indices(1, 1); - elseif isstruct(event) && isfield(event, 'Selection') && ~isempty(event.Selection) - row = event.Selection(1, 1); - end -end - -function row = appRowByCommand(apps, selectedCommand) - row = 1; - if isempty(apps) || strlength(string(selectedCommand)) == 0 - return; - end - commands = string({apps.command}); - match = find(commands == string(selectedCommand), 1, 'first'); - if ~isempty(match) - row = match; - end -end - -function selectTableRow(tableHandle, row, apps) - if isempty(apps) || ~isprop(tableHandle, 'Selection') - return; - end - try - tableHandle.Selection = row; - catch - try - tableHandle.Selection = [row 1]; - catch - end - end -end - -function rows = appDisplayRows(apps, packageCommands) - rows = cell(numel(apps), 7); - checked = ismember(string({apps.command}), string(packageCommands)); - for k = 1:numel(apps) - rows{k, 1} = checked(k); - rows{k, 2} = char(apps(k).family); - rows{k, 3} = char(apps(k).displayName); - rows{k, 4} = char(apps(k).visibility); - rows{k, 5} = char(apps(k).version); - rows{k, 6} = char(apps(k).updated); - rows{k, 7} = apps(k).command; - end -end - -function commands = retainedPackageCommands(apps, commands) - available = string({apps.command}); - commands = string(commands(:)); - commands = commands(ismember(commands, available)); -end - -function apps = checkedPackageApps(visibleApps, commands) - if isempty(visibleApps) - apps = visibleApps; - return; - end - selected = ismember(string({visibleApps.command}), string(commands)); - apps = visibleApps(selected); -end - -function summary = packageAppSummary(apps) - if numel(apps) == 1 - summary = string(apps.command); - else - summary = string(numel(apps)) + " checked apps"; - end -end - -function width = launcherTablePreferredWidth(rows) - columns = launcherTableColumnWidths(rows, inf); - width = sum(cell2mat(columns)) + 24; -end - -function widths = launcherTableColumnWidths(rows, tableWidth) - desired = [ ... - 72, ... - textColumnWidth(rows, 2, 130, 220), ... - textColumnWidth(rows, 3, 220, 380), ... - 96, ... - 96, ... - 122, ... - textColumnWidth(rows, 7, 320, 580)]; - - if isfinite(tableWidth) - spare = tableWidth - sum(desired); - if spare > 0 - desired(3) = desired(3) + round(spare * 0.35); - desired(7) = desired(7) + round(spare * 0.65); - elseif spare < 0 - deficit = abs(spare); - shrink = min(deficit, max(0, desired(7) - 320)); - desired(7) = desired(7) - shrink; - deficit = deficit - shrink; - shrink = min(deficit, max(0, desired(3) - 220)); - desired(3) = desired(3) - shrink; - end - end - - widths = num2cell(max(round(desired), [64 120 190 90 90 110 260])); -end - -function width = textColumnWidth(rows, columnIndex, minWidth, maxWidth) - values = strings(0, 1); - if ~isempty(rows) - values = string(rows(:, columnIndex)); - end - maxChars = max(strlength([""; values])); - width = min(maxWidth, max(minWidth, double(maxChars) * 10 + 34)); -end - -function rows = selectedAppDetails(app) - rows = [{char(app.displayName)}; {['Family: ' char(app.family)]}; ... - {['Visibility: ' char(app.visibility)]}; ... - {['Version: ' char(app.version)]}; {['Updated: ' char(app.updated)]}; ... - {['Command: ' app.command]}; {['Path: ' app.relativePath]}; ... - cellstr(wrapDescription(app.description))]; -end - -function rows = noMatchingAppDetails() - rows = {'No app entry points found.'; 'Use GitHub Update to repair this install.'}; -end - -%% Section: Launcher status messages - -function message = integrityStatus(root, apps) - missing = missingManagedProjectParts(root); - packageInfo = singleAppPackageInfo(root); - if packageInfo.isPackage && isempty(apps) - message = "App package is missing its app entry point."; - elseif packageInfo.isPackage && isempty(missing) - message = sprintf('App package ready: %d app(s) available.', numel(apps)); - elseif packageInfo.isPackage - message = sprintf(['App package has %d app(s), but package parts are ' ... - 'missing: %s. Recreate the package from a complete LabKit checkout.'], ... - numel(apps), strjoin(cellstr(missing), ', ')); - elseif isempty(apps) - message = "No app entry points found. Use GitHub Update to repair this install."; - elseif isempty(missing) - message = sprintf('%d app(s) available. Project structure looks complete.', numel(apps)); - else - message = sprintf(['%d app(s) available, but managed project parts are ' ... - 'missing: %s. Use GitHub Update to repair this install.'], ... - numel(apps), strjoin(cellstr(missing), ', ')); - end -end - -function missing = missingManagedProjectParts(root) - packageInfo = singleAppPackageInfo(root); - if packageInfo.isPackage - required = [packageInfo.launcherFile, "+labkit", packageInfo.appFolders, ... - "tools/deployment", "tools/profiling", "packaged_app_manifest.json"]; - else - required = ["+labkit", "apps", "docs", "tests", "buildfile.m", ... - "README.md", "AGENTS.md"]; - end - missing = strings(1, 0); - for k = 1:numel(required) - path = fullfile(root, char(required(k))); - if exist(path, "file") ~= 2 && exist(path, "dir") ~= 7 - missing(end+1) = required(k); - end - end -end - -function info = singleAppPackageInfo(root) - info = struct('isPackage', false, 'appCommand', "", ... - 'appFolder', "apps", 'appFolders', "apps", ... - 'codeFormat', "source", 'launcherFile', "labkit_launcher.m"); - manifestFile = fullfile(root, "packaged_app_manifest.json"); - if exist(manifestFile, "file") ~= 2 - return; - end - try - raw = jsondecode(fileread(manifestFile)); - catch - return; - end - if isfield(raw, "type") && any(string(raw.type) == ... - ["labkit.single_app_package", "labkit.multi_app_package"]) - info.isPackage = true; - if isfield(raw, "appCommand") - info.appCommand = string(raw.appCommand); - end - if isfield(raw, "appFolder") - info.appFolder = string(raw.appFolder); - info.appFolders = info.appFolder; - elseif isfield(raw, "appFolders") - info.appFolders = string(raw.appFolders(:)).'; - if ~isempty(info.appFolders) - info.appFolder = info.appFolders(1); - end - end - if isfield(raw, "codeFormat") - info.codeFormat = string(raw.codeFormat); - end - if info.codeFormat == "pcode" - info.launcherFile = "labkit_launcher.p"; - end - end -end - -function message = launchStartStatus(app, debugMode) - if debugMode - message = sprintf('Launching %s with verbose diagnostics and its synthetic sample...', ... - app.command); - else - message = sprintf('Launching %s...', app.command); - end -end - -function message = launchSuccessStatus(root, app, debugMode, diagnosticFolder) - if debugMode - message = sprintf('Launched %s in debug mode. Diagnostic session: %s', ... - app.command, relativePath(root, diagnosticFolder)); - else - message = sprintf('Launched %s.', app.command); - end -end - -function message = launchHandOffStatus(app, debugMode) - if debugMode - message = sprintf(['Opening %s with App SDK verbose diagnostics ' ... - 'and its synthetic sample...'], app.command); - else - message = sprintf('Opening %s with the App SDK runtime...', app.command); - end -end - -function message = profileStartStatus(app) - message = sprintf(['Profiling %s. Close the app window to finish ' ... - 'the report...'], app.command); -end - -function message = profileSuccessStatus(app, result) - message = sprintf('Profile complete for %s: %s', app.command, ... - char(result.relativeHtmlFile)); -end - -function [diagnostics, folder] = launcherDiagnosticOptions(root, app) - baseFolder = fullfile(root, 'artifacts', 'diagnostics', 'launcher', ... - safeFilename(app.command)); - ensureFolder(baseFolder); - folder = string(tempname(baseFolder)); - diagnostics = labkit.app.diagnostic.Options( ... - Level="verbose", ArtifactFolder=folder, Sample="synthetic"); -end +% labkit_launcher opens the installed launcher when its private entry is +% available. If that entry is missing or cannot load, it opens a minimal +% repair window instead. labkit_launcher("repair") always opens that repair +% window. Repair is explicit; startup never downloads or replaces files. -function message = cleanArtifactsStatus(result) - if isempty(result.errors) - message = sprintf('Cleaned %d generated artifact item(s).', result.removedCount); - else - message = sprintf('Cleaned %d item(s); %d item(s) failed.', ... - result.removedCount, numel(result.errors)); + if nargout > 1 + error("labkit_launcher:TooManyOutputs", "labkit_launcher returns at most one output."); end -end - -function message = codeCheckStatus(report) - message = sprintf(['codeIssues report complete: %d issue(s), ' ... - '%d suppressed issue(s), %d file(s): %s; HTML: %s'], ... - report.issueCount, report.suppressedIssueCount, ... - report.fileCount, char(report.relativeJsonFile), ... - char(report.relativeHtmlFile)); -end - -%% Section: App discovery and metadata - -function apps = discoverApps(root) - template = emptyAppStruct(); - appRoots = appDiscoveryRoots(root); - apps = template; - appCount = 0; - for rootIndex = 1:numel(appRoots) - appRoot = char(appRoots(rootIndex).appRoot); - if exist(appRoot, 'dir') ~= 7 - continue; - end - entries = appEntryFiles(appRoot); - for k = 1:numel(entries) - filepath = fullfile(entries(k).folder, entries(k).name); - rel = relativePath(appRoot, filepath); - if isHiddenImplementationPath(rel) - continue; - end - [folder, command] = fileparts(filepath); - appCount = appCount + 1; - apps(appCount).command = command; - apps(appCount).displayName = displayNameFromCommand(command); - apps(appCount).family = appFamily(appRoot, folder); - apps(appCount).visibility = char(appRoots(rootIndex).visibility); - apps(appCount).folder = folder; - apps(appCount).relativePath = relativePath(root, filepath); - apps(appCount).description = appDescription(filepath, command); - versionInfo = appVersionInfo(folder); - apps(appCount).version = versionInfo.version; - apps(appCount).updated = versionInfo.updated; + root = fileparts(mfilename("fullpath")); + if isRepairRequest(varargin) + if nargout > 0 + error("labkit_launcher:RepairHasNoOutput", "Repair mode returns no output."); end - end - if ~isempty(apps) - keys = [reshape(string({apps.visibility}) == "private", [], 1), ... - reshape(string({apps.family}), [], 1), ... - reshape(string({apps.displayName}), [], 1)]; - [~, order] = sortrows(keys); - apps = apps(order); - end -end - -function app = emptyAppStruct() - app = struct('command', {}, 'displayName', {}, 'family', {}, ... - 'visibility', {}, ... - 'folder', {}, 'relativePath', {}, 'description', {}, ... - 'version', {}, 'updated', {}); -end - -function entries = appEntryFiles(appRoot) - entries = [dir(fullfile(appRoot, '**', 'labkit_*_app.m')); ... - dir(fullfile(appRoot, '**', 'labkit_*_app.p'))]; - entries = entries(~[entries.isdir]); - if isempty(entries) + openRepairWindow(root, ""); return; end - paths = strings(numel(entries), 1); - commands = strings(numel(entries), 1); - isSource = false(numel(entries), 1); - for k = 1:numel(entries) - paths(k) = string(fullfile(entries(k).folder, entries(k).name)); - [~, commands(k), ext] = fileparts(paths(k)); - isSource(k) = string(ext) == ".m"; - end - [~, order] = sortrows([commands, string(~isSource), paths]); - entries = entries(order); - commands = commands(order); - [~, keep] = unique(commands, "stable"); - entries = entries(keep); -end - -function catalog = appCatalogTable(apps) - catalog = table(string({apps.command})', string({apps.displayName})', ... - string({apps.family})', string({apps.visibility})', string({apps.folder})', ... - string({apps.relativePath})', string({apps.description})', ... - string({apps.version})', string({apps.updated})', ... - 'VariableNames', {'Command', 'DisplayName', 'Family', 'Visibility', 'Folder', ... - 'RelativePath', 'Description', 'Version', 'Updated'}); -end - -function appRoots = appDiscoveryRoots(root) - roots = struct('appRoot', string(fullfile(root, 'apps')), ... - 'visibility', "public"); - privateRoots = privateAppRoots(root); - for k = 1:numel(privateRoots) - roots(end+1) = struct('appRoot', privateRoots(k), ... - 'visibility', "private"); - end - appRoots = roots; -end - -function roots = privateAppRoots(root) - roots = strings(1, 0); - localRoot = string(fullfile(root, 'private_apps', 'apps')); - if exist(localRoot, 'dir') == 7 - roots(end+1) = localRoot; - end - - envValue = string(getenv('LABKIT_PRIVATE_APP_ROOTS')); - if strlength(strtrim(envValue)) > 0 - parts = string(strsplit(char(envValue), pathsep)); - parts = strip(parts); - parts = parts(strlength(parts) > 0); - for k = 1:numel(parts) - candidate = privateAppRootAppsFolder(parts(k)); - if exist(candidate, 'dir') == 7 - roots(end+1) = candidate; - end - end - end - roots = unique(roots, 'stable'); -end - -function appRoot = privateAppRootAppsFolder(root) - root = string(root); - if endsWith(strrep(root, "\", "/"), "/apps") - appRoot = root; - else - appRoot = string(fullfile(root, 'apps')); - end -end - -function info = appVersionInfo(folder) - info = struct('version', "", 'updated', ""); - definitions = dir(fullfile(folder, '+*', 'definition.m')); - if ~isempty(definitions) - filepath = fullfile(definitions(1).folder, definitions(1).name); - try - text = fileread(filepath); - info.version = stringLiteralField(text, "AppVersion"); - info.updated = stringLiteralField(text, "Updated"); - catch - end - if strlength(info.version) > 0 && strlength(info.updated) > 0 - return; - end - end - - % Transitional fallback for Apps not yet moved to one definition. - entries = dir(fullfile(folder, '+*', 'version.m')); - if isempty(entries) + entry = installedDispatchFile(root); + if strlength(entry) == 0 + varargout = handleMissingInstalledEntry(root, varargin, nargout); return; end - filepath = fullfile(entries(1).folder, entries(1).name); try - text = fileread(filepath); - catch - return; - end - info.version = stringLiteralField(text, "version"); - info.updated = stringLiteralField(text, "updated"); -end - -function value = stringLiteralField(text, fieldName) - value = ""; - patterns = { ... - [char(fieldName) '\s*=\s*"([^"]+)"'], ... - ['"' char(fieldName) '"\s*,\s*"([^"]+)"']}; - for k = 1:numel(patterns) - tokens = regexp(char(text), patterns{k}, 'tokens', 'once'); - if ~isempty(tokens) - value = string(tokens{1}); + addpath(root, "-begin"); + rehash; + dispatcher = str2func("labkit.app.internal.launcher.dispatch"); + if ~resolvesInstalledDispatch(dispatcher, entry) + error("labkit_launcher:InstalledEntryMismatch", ... + "The installed launcher entry does not resolve from this LabKit installation."); + end + if nargout == 0 + dispatcher(root, varargin{:}); + else + [varargout{1:nargout}] = dispatcher(root, varargin{:}); + end + catch cause + if isempty(varargin) && isStructuralDelegateFailure(cause) + fig = openRepairWindow(root, repairMessage(cause)); + if nargout == 1, varargout = {fig}; end return; end + rethrow(cause); end end -function tf = isHiddenImplementationPath(rel) - parts = split(string(strrep(rel, filesep, '/')), '/'); - tf = any(startsWith(parts, '+')) || any(parts == "private"); +function tf = isRepairRequest(args) +tf = isscalar(args) && isTextScalar(args{1}) && strcmpi(string(args{1}), "repair"); end -function name = displayNameFromCommand(command) - name = regexprep(command, '^labkit_', ''); - name = regexprep(name, '_app$', ''); - name = regexprep(name, '_', ' '); - words = split(string(name)); - for k = 1:numel(words) - words(k) = upper(extractBefore(words(k), 2)) + extractAfter(words(k), 1); - end - name = strjoin(cellstr(words), ' '); +function outputs = handleMissingInstalledEntry(root, args, outputCount) +outputs = cell(1, 0); +message = "The installed launcher entry is unavailable. This installation may be incomplete. " + ... + "Use Repair / Reinstall to download the latest stable LabKit release."; +if ~isempty(args) + error("labkit_launcher:InstalledEntryUnavailable", "%s", message); end - -function family = appFamily(appRoot, folder) - rel = relativePath(appRoot, folder); - parts = split(string(strrep(rel, filesep, '/')), '/'); - if isempty(parts) || strlength(parts(1)) == 0 - family = "General"; - else - family = displayToken(parts(1)); - end +fig = openRepairWindow(root, message); +if outputCount == 1, outputs = {fig}; end end -function value = displayToken(token) - words = split(strrep(string(token), '_', ' ')); - for k = 1:numel(words) - words(k) = displayWord(words(k)); +function entry = installedDispatchFile(root) +base = fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch"); +entry = ""; +for extension = [".m", ".p"] + candidate = base + extension; + if exist(candidate, "file") == 2 + entry = candidate; + return; end - value = strjoin(cellstr(words), ' '); end - -function word = displayWord(word) - word = string(word); - switch lower(word) - case "labkit" - word = "LabKit"; - otherwise - word = upper(extractBefore(word, 2)) + extractAfter(word, 1); - end end -function description = appDescription(filepath, command) - description = ""; - try - text = fileread(filepath); - catch - return; - end - lines = splitlines(string(text)); - for k = 1:min(numel(lines), 20) - line = strtrim(lines(k)); - prefix = "%" + upper(command); - if startsWith(line, prefix, 'IgnoreCase', true) - description = strtrim(erase(extractAfter(line, strlength(prefix)), "-")); - return; - elseif startsWith(line, "%") - cleaned = strtrim(extractAfter(line, 1)); - if strlength(cleaned) > 0 && ~startsWith(cleaned, "Usage") - description = cleaned; - return; - end - end - end +function tf = isStructuralDelegateFailure(cause) +identifier = string(cause.identifier); +tf = startsWith(identifier, "MATLAB:UndefinedFunction") || ... + startsWith(identifier, "MATLAB:undefinedVarOrClass") || ... + startsWith(identifier, "MATLAB:parse") || startsWith(identifier, "MATLAB:dispatcher"); end -function lines = wrapDescription(description) - text = string(description); - if strlength(text) == 0 - lines = "No description found in the app header."; - return; +function tf = resolvesInstalledDispatch(dispatcher, entry) +tf = false; +try + details = functions(dispatcher); + resolvedFile = ""; + if isfield(details, "file") + resolvedFile = string(details.file); end - parts = regexp(char(text), '(?<=[.!?])\s+', 'split'); - lines = strings(0, 1); - for k = 1:numel(parts) - value = strtrim(string(parts{k})); - if strlength(value) > 0 - lines(end+1, 1) = value; - end + if strlength(resolvedFile) == 0 && isfield(details, "function") + resolvedFile = string(which(char(details.function))); end - lines = lines(1:min(numel(lines), 3)); + tf = strlength(resolvedFile) > 0 && sameNormalizedPath(resolvedFile, entry); +catch end - -%% Section: Clean Artifacts action - -function result = cleanGeneratedArtifacts(root, progressFcn) - if nargin < 2 - progressFcn = []; - end - notifyProgress(progressFcn, "Checking cleanup root...", 0.05); - try - root = validateCleanArtifactsRoot(root); - catch err - result = struct('removedCount', 0, 'errors', string(err.message)); - return; - end - - targets = {'artifacts'}; - notifyProgress(progressFcn, "Finding generated artifact targets...", 0.20); - removedCount = 0; - errors = strings(0, 1); - for k = 1:numel(targets) - relativeTarget = targets{k}; - target = fullfile(root, relativeTarget); - try - notifyProgress(progressFcn, ... - sprintf("Checking %s...", relativeTarget), 0.25); - validateCleanArtifactsTarget(root, target, relativeTarget); - if exist(target, 'dir') == 7 - notifyProgress(progressFcn, ... - sprintf("Removing %s...", relativeTarget), 0.55); - rmdir(target, 's'); - removedCount = removedCount + 1; - elseif exist(target, 'file') == 2 - notifyProgress(progressFcn, ... - sprintf("Removing %s...", relativeTarget), 0.55); - delete(target); - removedCount = removedCount + 1; - else - notifyProgress(progressFcn, ... - sprintf("%s is already clean.", relativeTarget), 0.85); - end - catch err - errors(end+1) = string(err.message); - end - end - result = struct('removedCount', removedCount, 'errors', errors); - notifyProgress(progressFcn, "Clean Artifacts complete.", 1.00); end -function root = validateCleanArtifactsRoot(root) - root = canonicalPath(root); - if isSamePath(root, filesep) - error('labkit_launcher:UnsafeCleanRoot', ... - 'Clean Artifacts refused to use the filesystem root as the project root.'); - end - if exist(fullfile(root, 'labkit_launcher.m'), 'file') ~= 2 - error('labkit_launcher:UnsafeCleanRoot', ... - 'Clean Artifacts refused a folder that is not a LabKit launcher root: %s', root); - end +function tf = sameNormalizedPath(leftPath, rightPath) +leftPath = normalizedPath(leftPath); +rightPath = normalizedPath(rightPath); +tf = strcmp(leftPath, rightPath); end -function validateCleanArtifactsTarget(root, target, relativeTarget) - if exist(target, 'dir') ~= 7 && exist(target, 'file') ~= 2 - return; - end - - canonicalRoot = canonicalPath(root); - canonicalTarget = canonicalPath(target); - expectedTarget = fullfile(canonicalRoot, relativeTarget); - if ~isSamePath(canonicalTarget, expectedTarget) || ... - isSamePath(canonicalTarget, canonicalRoot) || ... - ~startsWith(string(canonicalTarget), string([canonicalRoot filesep])) - error('labkit_launcher:UnsafeCleanTarget', ... - 'Clean Artifacts refused unsafe target: %s', target); - end +function value = normalizedPath(filepath) +pathValue = java.nio.file.Paths.get(char(filepath), javaArray("java.lang.String", 0)); +value = string(pathValue.toAbsolutePath().normalize().toString()); +if ispc + value = lower(value); end - -function resolvedPath = canonicalPath(filepath) - resolvedPath = char(java.io.File(char(filepath)).getCanonicalPath()); end -function tf = isSamePath(left, right) - if ispc - tf = strcmpi(char(left), char(right)); - else - tf = strcmp(char(left), char(right)); - end +function message = repairMessage(cause) +detail = string(cause.message); +if strlength(string(cause.identifier)) > 0 + detail = string(cause.identifier) + ": " + detail; end - -function tf = confirmCleanArtifacts(fig) - try - choice = uiconfirm(fig, ... - 'Remove generated LabKit artifacts?', ... - 'Clean Artifacts', 'Options', {'Clean', 'Cancel'}, ... - 'DefaultOption', 'Cancel', 'CancelOption', 'Cancel'); - tf = strcmp(choice, 'Clean'); - catch - tf = false; - end +message = "Installed launcher failed to load: " + detail + newline + ... + "The installation may be incomplete. Repair / Reinstall can restore the latest stable release."; end -%% Section: Documentation site action +function fig = openRepairWindow(root, initialMessage) +figArgs = {"Name", "LabKit Repair", "Tag", "labkitRepair", ... + "Position", [300 250 560 250], "Color", [0.97 0.98 0.99]}; +if repairGuiTestMode() == "hidden" + figArgs = [figArgs, {"Visible", "off"}]; +end +close(findall(groot, "Type", "figure", "Tag", "labkitRepair")); +fig = uifigure(figArgs{:}); +grid = uigridlayout(fig, [3 1]); +grid.RowHeight = {"1x", 34, 28}; +uitextarea(grid, "Editable", "off", "Value", cellstr(defaultRepairMessage(initialMessage))); +repair = uibutton(grid, "Text", "Repair / Reinstall Latest Stable Release"); +status = uilabel(grid, "Text", "Repair does not start until this button is pressed."); +repair.ButtonPushedFcn = @runRepair; -function result = generateLauncherDocumentation(root, progressFcn) - toolFile = documentationToolFile(root); - if strlength(string(toolFile)) == 0 - error('labkit_launcher:DocumentationToolUnavailable', ... - ['Documentation tools are missing. Restore tools/docs ' ... - 'or update the LabKit install.']); - end - toolFolder = fileparts(toolFile); - addedToolPath = ~pathContains(toolFolder); - addPathIfMissing(toolFolder); - if addedToolPath - toolPathCleanup = onCleanup(@() rmpath(toolFolder)); + function runRepair(~, ~) + repair.Enable = "off"; + status.Text = "Downloading the latest stable LabKit release..."; + drawnow; + try + result = repairFromStableZip(root, @setRepairProgress); + status.Text = char(result.message); + catch cause + status.Text = char("Repair failed: " + repairFailureMessage(cause)); + end + repair.Enable = "on"; end - reportLauncherProgress(progressFcn, ... - 'Reading Markdown and MATLAB API contracts...', 0.15); - result = renderLabKitDocs(fullfile(root, 'docs'), fullfile(root, 'site')); - result.indexFile = string(fullfile(result.outputRoot, 'index.html')); - result.relativeIndexFile = string(relativePath(root, result.indexFile)); - reportLauncherProgress(progressFcn, ... - sprintf('Generated %d pages and %d API references.', ... - result.pageCount, result.apiCount), 1); - if launcherGuiTestMode() ~= "hidden" - web(char(result.indexFile), '-browser'); + function setRepairProgress(text, ~) + status.Text = char(text); + drawnow limitrate; end - clear toolPathCleanup; end -function toolFile = documentationToolFile(root) - toolFile = matlabCodeFile(fullfile(root, 'tools', 'docs', ... - 'renderLabKitDocs')); +function message = defaultRepairMessage(initialMessage) +if strlength(initialMessage) == 0 + message = "LabKit repair is available if the installed launcher or its dependencies are missing."; +else + message = initialMessage; end - -function message = documentationSuccessStatus(result) - message = sprintf(['Documentation updated: %d narrative pages, %d API ' ... - 'references, %d generated files. Opened %s.'], ... - result.pageCount, result.apiCount, result.fileCount, ... - char(result.relativeIndexFile)); end -function reportLauncherProgress(progressFcn, message, value) - if nargin >= 1 && ~isempty(progressFcn) - progressFcn(char(message), value); - end +function message = repairFailureMessage(cause) +message = string(cause.message); +if strlength(string(cause.identifier)) > 0 + message = string(cause.identifier) + ": " + message; +end end -%% Section: Code Analyzer action - -function report = runCodeAnalyzerReport(root, progressFcn) - codecheckTool = codecheckToolFile(root); - if strlength(string(codecheckTool)) == 0 - error('labkit_launcher:CodecheckToolUnavailable', ... - ['Code Analyzer tools are missing. Restore tools/codecheck ' ... - 'or update the LabKit install.']); - end - codecheckFolder = fileparts(codecheckTool); - addedCodecheckPath = ~pathContains(codecheckFolder); - addPathIfMissing(codecheckFolder); - if addedCodecheckPath - codecheckPathCleanup = onCleanup(@() rmpath(codecheckFolder)); +function result = repairFromStableZip(root, progressFcn) +hook = repairTestHook(); +assertRepairRoot(root); +source = struct("label", "the supplied repair candidate"); +workspace = ""; +cleanup = onCleanup(@() removeFolderIfPresent(workspace)); +if strlength(hook.CandidateRoot) > 0 + candidate = hook.CandidateRoot; +else + source = resolveStableZipSource(); + notifyProgress(progressFcn, "Downloading " + source.label + "...", 0.15); + workspace = tempname; + mkdir(workspace); + zipPath = fullfile(workspace, "labkit.zip"); + websave(zipPath, source.url); + notifyProgress(progressFcn, "Extracting repair candidate...", 0.40); + extractRoot = fullfile(workspace, "candidate"); + unzip(zipPath, extractRoot); + candidate = findCandidateRoot(extractRoot); +end +assertCandidateRoot(candidate); +assertCandidateOutsideRepairRoot(candidate, root); +notifyProgress(progressFcn, "Replacing the incomplete installation...", 0.65); +replacement = replaceInstall(root, candidate, hook.FailAfterBackup); +notifyProgress(progressFcn, "Repair completed.", 1.00); +message = "Reinstalled " + source.label + ". Restart LabKit if it was open."; +if replacement.backupRetained + if replacement.preservedItemCount > 0 + message = message + " Migrated " + replacement.preservedItemCount + ... + " local data item(s). Recovery backup retained at " + ... + replacement.backupFolder + "."; + else + message = message + " Backup cleanup was incomplete; recovery files remain at " + ... + replacement.backupFolder + "."; end - - report = runCodecheckReport(root, ... - 'ProgressFcn', progressFcn, ... - 'OpenReport', launcherGuiTestMode() ~= "hidden"); - report.relativeJsonFile = string(relativePath(root, report.jsonFile)); - report.relativeHtmlFile = string(relativePath(root, report.htmlFile)); - clear codecheckPathCleanup; end - -function toolFile = codecheckToolFile(root) - toolFile = matlabCodeFile(fullfile(root, 'tools', 'codecheck', 'runCodecheckReport')); +result = summaryStruct(root, message); +delete(cleanup) end -%% Section: Performance profile action - -function result = runLauncherAppProfile(root, app) - profileTool = profileToolFile(root); - if strlength(string(profileTool)) == 0 - error('labkit_launcher:ProfilerUnavailable', ... - ['Performance profiler tools are missing. Restore tools/profiling ' ... - 'or update the LabKit install.']); - end - profileFolder = fileparts(profileTool); - addedProfilePath = ~pathContains(profileFolder); - addPathIfMissing(profileFolder); - if addedProfilePath - profilePathCleanup = onCleanup(@() rmpath(profileFolder)); +function hook = repairTestHook() +hook = struct("CandidateRoot", "", "FailAfterBackup", false); +key = "labkitLauncherRepairTestHook"; +if isappdata(groot, key) + value = getappdata(groot, key); + if isstruct(value) + if isfield(value, "CandidateRoot") && isTextScalar(value.CandidateRoot) + hook.CandidateRoot = string(value.CandidateRoot); + end + if isfield(value, "FailAfterBackup") && islogical(value.FailAfterBackup) && ... + isscalar(value.FailAfterBackup) + hook.FailAfterBackup = value.FailAfterBackup; + end end - initializeAppPath(app); - - outputRoot = fullfile(root, 'artifacts', 'profile', 'launcher-app-session'); - ensureFolder(outputRoot); - htmlFile = fullfile(outputRoot, sprintf('profile_%s_%s.html', ... - safeFilename(app.command), datestr(now, 'yyyymmdd_HHMMSS'))); - targetFile = fullfile(root, char(app.relativePath)); - target = @() launchProfileTarget(app); - [htmlFile, artifacts] = profileLabKitTarget(target, htmlFile, ... - 'OpenReport', launcherGuiTestMode() ~= "hidden", ... - 'WaitForGuiClose', true, ... - 'CloseFiguresAfterRun', false, ... - 'ProjectRoot', root, ... - 'TargetFile', targetFile, ... - 'PrintSummary', false, ... - 'RethrowError', true); - - result = struct(); - result.htmlFile = string(htmlFile); - result.jsonFile = string(artifacts.jsonFile); - result.relativeHtmlFile = string(relativePath(root, htmlFile)); - result.relativeJsonFile = string(relativePath(root, artifacts.jsonFile)); end - -function toolFile = profileToolFile(root) - toolFile = matlabCodeFile(fullfile(root, 'tools', 'profiling', 'profileLabKitTarget')); end -function launchProfileTarget(app) - feval(app.command); +function source = resolveStableZipSource() +release = latestStableRelease(); +if strlength(release.tag) > 0 + source = struct("label", "GitHub release " + release.tag, "url", release.zipUrl); + return; end - -function name = safeFilename(value) - name = regexprep(char(string(value)), '[^A-Za-z0-9_.-]+', '_'); - if isempty(name) - name = 'app'; - end +tag = latestGitHubTag(); +if strlength(tag) == 0 + error("labkit_launcher:StableSourceUnavailable", ... + "Could not find a stable GitHub release or tag to repair LabKit."); end - -function value = stringField(raw, name) - if isfield(raw, name) && ~isempty(raw.(name)) - value = string(raw.(name)); - else - value = ""; - end +source = struct("label", "GitHub tag " + tag, ... + "url", "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/refs/tags/" + tag + ".zip"); end -function value = nestedStringField(raw, names) - value = ""; - current = raw; - for k = 1:numel(names) - name = names(k); - if ~isstruct(current) || ~isfield(current, name) || isempty(current.(name)) - return; +function release = latestStableRelease() +release = struct("tag", "", "zipUrl", ""); +try + raw = webread("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/releases/latest"); + if isfield(raw, "tag_name") && isfield(raw, "zipball_url") + release.tag = string(raw.tag_name); + release.zipUrl = string(raw.zipball_url); + end +catch +end +end + +function tag = latestGitHubTag() +tag = ""; +try + stableTagPageSize = 20; + raw = webread("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/tags?per_page=" + stableTagPageSize); + if ~isempty(raw) && isfield(raw, "name") + for index = 1:numel(raw) + candidate = string(raw(index).name); + if isStableReleaseTag(candidate) + tag = candidate; + return; + end end - current = current.(name); - end - if ischar(current) || isstring(current) - value = string(current); end +catch end - -function line = firstTextLine(text) - parts = splitlines(string(text)); - parts = strip(parts); - parts = parts(strlength(parts) > 0); - if isempty(parts) - line = ""; - else - line = parts(1); - end end -%% Section: App package action +function tf = isStableReleaseTag(tag) +tf = isTextScalar(tag) && ~ismissing(string(tag)) && ... + ~isempty(regexp(char(string(tag)), '^v[0-9]+\.[0-9]+\.[0-9]+$', 'once')); +end -function result = packageSelectedLabKitApps(root, apps, usePcode, progressFcn) - packageTool = packageToolFile(root); - if strlength(string(packageTool)) == 0 - error('labkit_launcher:PackageToolUnavailable', ... - ['Deployment package tools are missing. Restore tools/deployment ' ... - 'or update the LabKit install.']); +function candidate = findCandidateRoot(extractRoot) +entries = dir(extractRoot); +for index = 1:numel(entries) + if ~entries(index).isdir || startsWith(entries(index).name, ".") + continue; end - packageFolder = fileparts(packageTool); - addedPackagePath = ~pathContains(packageFolder); - addPathIfMissing(packageFolder); - if addedPackagePath - packagePathCleanup = onCleanup(@() rmpath(packageFolder)); + folder = fullfile(entries(index).folder, entries(index).name); + if hasCandidateShape(folder) + candidate = folder; + return; end - - outputRoot = fullfile(root, 'artifacts', 'deployment'); - ensureFolder(outputRoot); - result = packageLabKitApp(apps, [], ... - 'Root', root, ... - 'OutputRoot', outputRoot, ... - 'CodeFormat', packageCodeFormat(usePcode), ... - 'ProgressFcn', progressFcn); - result.relativeZipFile = string(relativePath(root, result.zipFile)); - clear packagePathCleanup; -end - -function toolFile = packageToolFile(root) - toolFile = matlabCodeFile(fullfile(root, 'tools', 'deployment', 'packageLabKitApp')); end - -function file = matlabCodeFile(baseFile) - candidates = string(baseFile) + [".m", ".p"]; - file = ""; - for k = 1:numel(candidates) - if exist(candidates(k), 'file') == 2 - file = char(candidates(k)); - return; - end - end +error("labkit_launcher:InvalidCandidate", "Downloaded zip does not contain a LabKit root."); end -function codeFormat = packageCodeFormat(usePcode) - if usePcode - codeFormat = "pcode"; - else - codeFormat = "source"; - end +function assertCandidateRoot(candidate) +if ~hasCandidateShape(candidate) + error("labkit_launcher:InvalidCandidate", ... + "Repair candidate must contain labkit_launcher.m, +labkit, apps, and the installed launcher entry."); end - -function tools = launcherToolAvailability(root) - tools = struct(); - tools.docs = strlength(string(documentationToolFile(root))) > 0; - tools.codecheck = strlength(string(codecheckToolFile(root))) > 0; - tools.profile = strlength(string(profileToolFile(root))) > 0; - tools.package = strlength(string(packageToolFile(root))) > 0; end -function tooltip = missingToolTooltip(relativeToolPath) - tooltip = sprintf('Disabled because the optional tool is missing: %s', ... - char(relativeToolPath)); +function assertCandidateOutsideRepairRoot(candidate, root) +if sameNormalizedPath(candidate, root) || isDescendantPath(candidate, root) || ... + isDescendantPath(root, candidate) + error("labkit_launcher:InvalidCandidate", ... + "Repair candidate and installation root must be separate, non-nested directories."); end - -function message = packageSuccessStatus(apps, result) - if numel(apps) == 1 - message = sprintf('Packaged %s as %s. Run %s after unzipping.', ... - apps.command, char(result.relativeZipFile), char(result.entryFile)); - else - message = sprintf('Packaged %d apps as %s. Direct entries: %s.', ... - numel(apps), char(result.relativeZipFile), ... - strjoin(cellstr(result.entryFiles), ', ')); - end end -%% Section: Update entrypoints and install transaction - -function result = launcherUpdateFromMainZip(root, progressFcn) - source = struct( ... - "kind", "main", ... - "label", "GitHub main", ... - "zipUrl", "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/refs/heads/main.zip", ... - "zipName", "main.zip"); - result = launcherUpdateFromZipSource(root, source, progressFcn); +function tf = hasCandidateShape(candidate) +tf = exist(candidate, "dir") == 7 && ... + exist(fullfile(candidate, "labkit_launcher.m"), "file") == 2 && ... + exist(fullfile(candidate, "+labkit"), "dir") == 7 && ... + exist(fullfile(candidate, "apps"), "dir") == 7 && ... + strlength(installedDispatchFile(candidate)) > 0; end -function result = launcherUpdateFromStableZip(root, progressFcn) - notifyProgress(progressFcn, "Checking LabKit folder...", 0.05); - assertUpdateTargetRoot(root); - notifyProgress(progressFcn, "Checking update mode...", 0.10); - assertNotGitCheckout(root); - notifyProgress(progressFcn, "Resolving latest GitHub release or tag...", 0.12); - source = resolveStableZipSource(); - result = launcherUpdateFromZipSource(root, source, progressFcn, true); +function assertRepairRoot(root) +if isFilesystemRoot(root) + error("labkit_launcher:UnsafeRoot", ... + "Repair refuses filesystem roots to avoid overwriting a non-LabKit directory."); end - -function result = launcherUpdateFromZipSource(root, source, progressFcn, preflightDone) - if nargin < 4 - preflightDone = false; - end - tempRoot = tempname; - cleanup = onCleanup(@() removeFolderIfPresent(tempRoot)); - if ~preflightDone - notifyProgress(progressFcn, "Checking LabKit folder...", 0.05); - assertUpdateTargetRoot(root); - notifyProgress(progressFcn, "Checking update mode...", 0.10); - assertNotGitCheckout(root); - end - if ~confirmUpdate(root, source.label) - result = summaryStruct(root, 0, 0, "", "Update canceled."); - return; - end - notifyProgress(progressFcn, "Preparing update workspace...", 0.15); - ensureFolder(tempRoot); - zipPath = fullfile(tempRoot, char(source.zipName)); - extractRoot = fullfile(tempRoot, "extracted"); - notifyProgress(progressFcn, sprintf("Downloading %s zip...", char(source.label)), 0.25); - fetchZip(source.zipUrl, zipPath); - notifyProgress(progressFcn, "Extracting update zip...", 0.40); - unzip(char(zipPath), char(extractRoot)); - sourceRoot = findExtractedProjectRoot(extractRoot); - assertInstallRoot(sourceRoot); - removedApps = removedAppEntrypoints(root, sourceRoot); - if ~isempty(removedApps) && ~confirmDestructiveUpdate(source.label, removedApps) - result = summaryStruct(root, 0, 0, "", ... - "Update canceled because the candidate removes app entrypoints."); - return; - end - notifyProgress(progressFcn, "Moving current LabKit folder into a dated snapshot...", 0.55); - [snapshotFolder, movedCount] = moveCurrentInstallToSnapshot(root, progressFcn); - notifyProgress(progressFcn, "Copying replacement LabKit folder...", 0.75); - copiedCount = copyReplacementTree(sourceRoot, root, progressFcn); - notifyProgress(progressFcn, "Update complete.", 1.00); - result = summaryStruct(root, copiedCount, movedCount, snapshotFolder, ... - sprintf(['Updated from %s. Moved %d old top-level item(s) to %s and ' ... - 'copied %d replacement top-level item(s). Restart labkit_launcher.'], ... - char(source.label), movedCount, char(snapshotFolder), copiedCount)); - clear cleanup; - removeFolderIfPresent(tempRoot); +if exist(fullfile(root, ".git"), "file") == 2 || exist(fullfile(root, ".git"), "dir") == 7 + error("labkit_launcher:GitCheckout", "Repair from a Git checkout is disabled; use git to update it."); end - -%% Section: GitHub update source discovery - -function source = resolveStableZipSource() - release = latestStableRelease(); - if strlength(release.tagName) > 0 - source = stableSourceFromTag(release.tagName, ... - sprintf("GitHub release %s", release.tagName)); - return; - end - tagName = latestGitHubTag(); - if strlength(tagName) > 0 - source = stableSourceFromTag(tagName, sprintf("GitHub tag %s", tagName)); - return; - end - error("labkit_launcher:NoStableRelease", ... - "Could not find a GitHub release or tag to download."); +hasLauncher = exist(fullfile(root, "labkit_launcher.m"), "file") == 2; +hasFramework = exist(fullfile(root, "+labkit"), "dir") == 7; +hasApps = exist(fullfile(root, "apps"), "dir") == 7; +hasSupportingContent = exist(fullfile(root, "tools"), "dir") == 7 || ... + exist(fullfile(root, "docs"), "dir") == 7; +if ~hasLauncher || ~(hasFramework || (hasApps && hasSupportingContent)) + error("labkit_launcher:InvalidRepairRoot", ... + "Repair refuses this directory to avoid overwriting a non-LabKit installation."); end - -function sources = recentVersionSources() - sources = emptyVersionSources(); - sources = [sources, safeVersionSources(@() recentReleaseSources(5))]; - sources = [sources, safeVersionSources(@() recentTagSources(5))]; - sources = [sources, safeVersionSources(@() recentCommitSources(8))]; end -function sources = safeVersionSources(fetchFcn) - try - sources = fetchFcn(); - catch - sources = emptyVersionSources(); - end +function replacement = replaceInstall(root, candidate, failAfterBackup) +parent = fileparts(root); +[~, name] = fileparts(root); +backup = fullfile(parent, name + ".repair-backup-" + string(java.util.UUID.randomUUID())); +workingDirectory = pwd; +workingRelativePath = relativePathWithin(root, workingDirectory); +wasInsideRepairRoot = sameNormalizedPath(root, workingDirectory) || ... + isDescendantPath(workingDirectory, root); +pathState = captureRepairPathState(root); +if wasInsideRepairRoot + cd(parent); end - -function sources = recentReleaseSources(limit) - sources = emptyVersionSources(); - raw = githubApiRead("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/releases"); - if ~isstruct(raw) - return; - end - for k = 1:numel(raw) - item = raw(k); - if logicalField(item, "draft") || logicalField(item, "prerelease") - continue; - end - tag = stringField(item, "tag_name"); - if strlength(tag) == 0 - continue; - end - name = stringField(item, "name"); - if strlength(name) == 0 - name = tag; - end - date = stringField(item, "published_at"); - summary = "GitHub release " + tag; - source = sourceFromTag("Release", tag, "GitHub release " + tag, ... - tag, date, name + " (" + tag + ")"); - source.summary = summary; - sources(end+1) = source; - if numel(sources) >= limit - return; - end - end +removeRepairPathEntries(pathState); +cleanup = onCleanup(@() restoreWorkingDirectory( ... + workingDirectory, root, parent, workingRelativePath, wasInsideRepairRoot, pathState)); +[moved, moveMessage] = movefile(root, backup, "f"); +if ~moved + error("labkit_launcher:ReplaceFailed", "Could not preserve the current installation: %s", moveMessage); end - -function sources = recentTagSources(limit) - sources = emptyVersionSources(); - raw = githubApiRead("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/tags?per_page=" + string(limit)); - if ~isstruct(raw) - return; +try + if failAfterBackup + error("labkit_launcher:InjectedFailure", "Injected failure after preserving the current installation."); end - for k = 1:min(numel(raw), limit) - tag = stringField(raw(k), "name"); - if strlength(tag) == 0 - continue; - end - sources(end+1) = sourceFromTag("Tag", tag, "GitHub tag " + tag, ... - tag, "", "Tag " + tag); + [copied, copyMessage] = copyfile(candidate, root); + if ~copied + error("labkit_launcher:ReplaceFailed", "Could not install the repair candidate: %s", copyMessage); end + assertCandidateRoot(root); + preservation = copyPreservedLocalContent(backup, root); +catch cause + rollbackInstall(root, backup, cause); end - -function sources = recentCommitSources(limit) - sources = emptyVersionSources(); - raw = githubApiRead("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/commits?sha=main&per_page=" + string(limit)); - if ~isstruct(raw) - return; +replacement = struct("backupFolder", "", "backupRetained", false, ... + "preservedItemCount", preservation.itemCount, "backupFailureReason", ""); +if preservation.itemCount > 0 + replacement.backupFolder = string(backup); + replacement.backupRetained = true; +else + [removed, removeMessage] = rmdir(backup, "s"); + if ~removed + replacement.backupFolder = string(backup); + replacement.backupRetained = true; + replacement.backupFailureReason = string(removeMessage); end - for k = 1:min(numel(raw), limit) - sha = stringField(raw(k), "sha"); - if strlength(sha) < 7 - continue; - end - short = extractBefore(sha, 8); - message = firstTextLine(nestedStringField(raw(k), ["commit", "message"])); - date = nestedStringField(raw(k), ["commit", "author", "date"]); - label = "main commit " + short; - sources(end+1) = createVersionSource("Commit", label, ... - "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/" + sha + ".zip", ... - "commit-" + short + ".zip", short, date, message); - end -end - -function raw = githubApiRead(url) - options = weboptions("Timeout", 20, "UserAgent", "MATLAB LabKit Launcher"); - raw = webread(char(url), options); end - -function release = latestStableRelease() - release = struct("tagName", ""); - try - raw = githubApiRead("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/releases"); - catch - return; - end - if ~isstruct(raw) - return; - end - for k = 1:numel(raw) - item = raw(k); - if logicalField(item, "draft") || logicalField(item, "prerelease") - continue; - end - tag = stringField(item, "tag_name"); - if strlength(tag) > 0 - release.tagName = tag; - return; - end - end +delete(cleanup) end -function tagName = latestGitHubTag() - tagName = ""; - try - raw = githubApiRead("https://api.github.com/repos/Pluze/LabKit-MATLAB-Workbench/tags"); - catch - return; - end - if isstruct(raw) && ~isempty(raw) - tagName = stringField(raw(1), "name"); - end +function preservation = copyPreservedLocalContent(backup, root) +relativePaths = preservedLocalPaths(); +present = false(size(relativePaths)); +for index = 1:numel(relativePaths) + source = fullfile(backup, relativePaths(index)); + if ~filesystemEntryExists(source) + continue; + end + present(index) = true; + target = fullfile(root, relativePaths(index)); + if filesystemEntryExists(target) + error("labkit_launcher:LocalDataConflict", ... + "Repair candidate conflicts with preserved local data: %s.", relativePaths(index)); + end +end +for index = find(present)' + source = fullfile(backup, relativePaths(index)); + target = fullfile(root, relativePaths(index)); + targetParent = fileparts(target); + if exist(targetParent, "dir") ~= 7 + [created, createMessage] = mkdir(targetParent); + if ~created + error("labkit_launcher:LocalDataCopyFailed", ... + "Could not prepare preserved local data target %s: %s", ... + relativePaths(index), createMessage); + end + end + [copied, copyMessage] = copyfile(source, target); + if ~copied + error("labkit_launcher:LocalDataCopyFailed", ... + "Could not preserve local data %s: %s", relativePaths(index), copyMessage); + end +end +preservation = struct("itemCount", sum(present)); +end + +function paths = preservedLocalPaths() +paths = [ + "private_apps" + "artifacts" + string(fullfile("resources", "project")) + "photos" + "derived" + "profile_results" + "LabKit.prj" + ]; end -function source = stableSourceFromTag(tagName, label) - source = sourceFromTag("Stable", tagName, label, tagName, "", ... - "Stable tag " + string(tagName)); +function tf = filesystemEntryExists(filepath) +tf = exist(filepath, "file") == 2 || exist(filepath, "dir") == 7; end -function source = sourceFromTag(kind, tagName, label, name, date, summary) - safeTag = encodeUrlPathSegment(tagName); - source = createVersionSource(kind, label, ... - "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/refs/tags/" + string(safeTag) + ".zip", ... - "stable-" + sanitizeFilename(tagName) + ".zip", ... - name, date, summary); +function rollbackInstall(root, backup, cause) +[partialRemoved, removeMessage] = removePartialInstallRoot(root); +if ~partialRemoved + throwRollbackFailed(cause, backup, ... + "Partial replacement could not be removed: " + string(removeMessage)); end - -function source = createVersionSource(kind, label, zipUrl, zipName, name, date, summary) - source = struct( ... - "kind", string(kind), ... - "label", string(label), ... - "zipUrl", string(zipUrl), ... - "zipName", string(zipName), ... - "name", string(name), ... - "date", string(date), ... - "summary", string(summary)); +[restored, restoreMessage] = copyfile(backup, root); +if ~restored + throwRollbackFailed(cause, backup, ... + "Backup could not be restored: " + string(restoreMessage)); end - -function sources = emptyVersionSources() - sources = struct("kind", {}, "label", {}, "zipUrl", {}, "zipName", {}, ... - "name", {}, "date", {}, "summary", {}); +try + assertRepairRoot(root); +catch validationCause + throwRollbackFailed(cause, backup, ... + "Restored installation failed validation: " + string(validationCause.message)); end - -function encoded = encodeUrlPathSegment(value) - encoded = char(string(value)); - encoded = strrep(encoded, '%', '%25'); - encoded = strrep(encoded, ' ', '%20'); - encoded = strrep(encoded, '#', '%23'); - encoded = strrep(encoded, '?', '%3F'); - encoded = strrep(encoded, '/', '%2F'); +[removed, removeMessage] = rmdir(backup, "s"); +if ~removed + throwRollbackFailed(cause, backup, ... + "Restored installation is valid but backup cleanup failed: " + string(removeMessage)); end - -function value = logicalField(raw, name) - value = false; - if isfield(raw, name) && ~isempty(raw.(name)) - value = logical(raw.(name)); - end +rethrow(cause); end -%% Section: Update validation and launcher window helpers - -function assertUpdateTargetRoot(root) - hasLauncher = exist(fullfile(root, "labkit_launcher.m"), "file") == 2; - hasLabkit = exist(fullfile(root, "+labkit"), "dir") == 7; - if ~hasLauncher && ~hasLabkit - error("labkit_launcher:InvalidRoot", ... - "Run from a folder containing labkit_launcher.m or +labkit: %s", root); +function [removed, message] = removePartialInstallRoot(root) +removed = true; +message = ""; +if exist(root, "dir") == 7 + [removed, message] = rmdir(root, "s"); +elseif exist(root, "file") == 2 + try + delete(root); + catch cause + removed = false; + message = string(cause.message); end end - -function assertInstallRoot(root) - if exist(fullfile(root, "labkit_launcher.m"), "file") ~= 2 || ... - exist(fullfile(root, "+labkit"), "dir") ~= 7 || ... - exist(fullfile(root, "apps"), "dir") ~= 7 - error("labkit_launcher:InvalidRoot", ... - "Downloaded zip did not contain a complete LabKit install root."); - end end -function assertNotGitCheckout(root) - if exist(fullfile(root, ".git"), "dir") == 7 - error("labkit_launcher:GitCheckout", ... - "Update from GitHub zip is disabled for git checkouts. Use git to sync this working tree."); - end +function throwRollbackFailed(cause, backup, detail) +error("labkit_launcher:RollbackFailed", ... + "Repair failed (%s). %s Recovery backup remains at %s.", ... + repairFailureMessage(cause), detail, backup); end -function closeExistingLauncherFigures() - figures = findall(groot, 'Type', 'figure', 'Tag', launcherFigureTag()); - for k = 1:numel(figures) - if isvalid(figures(k)) - close(figures(k)); - end - end +function tf = isFilesystemRoot(folder) +folder = normalizedPath(folder); +tf = sameNormalizedPath(folder, fileparts(folder)); end -function tag = launcherFigureTag() - tag = 'labkit_launcher_main'; +function tf = isDescendantPath(candidate, root) +candidate = normalizedPath(candidate); +root = normalizedPath(root); +separator = string(filesep); +tf = startsWith(candidate, root + separator); end -function mode = launcherGuiTestMode() - mode = lower(strtrim(string(getenv('LABKIT_GUI_TEST_MODE')))); - if ~any(mode == ["hidden", "minimized"]) - mode = "visible"; - end +function relative = relativePathWithin(root, folder) +relative = ""; +if sameNormalizedPath(root, folder) + return; end - -function applyLauncherGuiTestMode(fig) - if launcherGuiTestMode() == "minimized" && isprop(fig, 'WindowState') - try - fig.WindowState = 'minimized'; - catch - end - end +if isDescendantPath(folder, root) + root = normalizedPath(root); + folder = normalizedPath(folder); + relative = extractAfter(folder, strlength(root) + 1); end - -function paintVisibleLauncherFigure() - if launcherGuiTestMode() ~= "visible" - return; - end - drawnow limitrate; end -function tf = confirmUpdate(root, sourceLabel) - message = sprintf(['Download %s zip and replace the LabKit runtime in:\n\n%s\n\n' ... - 'The LabKit folder should contain only LabKit runtime files. Before ' ... - 'installing, the launcher will move the current folder contents into a ' ... - 'dated LabKit-previous-* subfolder, then copy the selected zip into place. ' ... - 'Keep lab data and exports outside the LabKit folder.'], ... - char(sourceLabel), root); - try - choice = questdlg(message, "Update LabKit", "Update", "Cancel", "Cancel"); - tf = strcmp(choice, "Update"); - catch - tf = false; +function state = captureRepairPathState(root) +entries = string(strsplit(path, pathsep)); +isRepairEntry = false(size(entries)); +relativePaths = strings(size(entries)); +for index = 1:numel(entries) + if strlength(entries(index)) == 0 + continue; end -end - -function tf = confirmDestructiveUpdate(sourceLabel, removedApps) - appList = strjoin(cellstr(removedApps(:)), newline); - message = sprintf(['The %s update removes or merges these app entrypoints:\n\n%s\n\n' ... - 'This is a destructive LabKit update. Continue only if you do not need ' ... - 'those old entrypoints, or cancel and manually choose an older release tag.'], ... - char(sourceLabel), appList); - try - choice = questdlg(message, "Destructive LabKit Update", ... - "Continue update", "Cancel", "Cancel"); - tf = strcmp(choice, "Continue update"); - catch - tf = false; + if sameNormalizedPath(entries(index), root) + isRepairEntry(index) = true; + elseif isDescendantPath(entries(index), root) + isRepairEntry(index) = true; + relativePaths(index) = relativePathWithin(root, entries(index)); end end - -%% Section: Update install file operations - -function fetchZip(sourceUrl, zipPath) - websave(zipPath, sourceUrl); -end - -function sourceRoot = findExtractedProjectRoot(extractRoot) - entries = dir(extractRoot); - entries = entries([entries.isdir]); - entries = entries(~ismember(string({entries.name}), [".", ".."])); - for k = 1:numel(entries) - candidate = fullfile(entries(k).folder, entries(k).name); - if exist(fullfile(candidate, "labkit_launcher.m"), "file") == 2 - sourceRoot = candidate; - return; - end - end - error("labkit_launcher:InvalidZip", "Downloaded zip did not contain a LabKit project root."); +state = struct("entries", entries, "isRepairEntry", isRepairEntry, ... + "relativePaths", relativePaths); end -function removed = removedAppEntrypoints(currentRoot, sourceRoot) - currentApps = collectAppEntrypoints(currentRoot); - sourceApps = collectAppEntrypoints(sourceRoot); - removed = setdiff(currentApps, sourceApps); +function removeRepairPathEntries(state) +entries = state.entries(~state.isRepairEntry); +entries = entries(strlength(entries) > 0); +path(char(strjoin(entries, pathsep))); +rehash; end -function apps = collectAppEntrypoints(root) - entries = appEntryFiles(fullfile(root, "apps")); - apps = strings(1, 0); - for k = 1:numel(entries) - if entries(k).isdir - continue; - end - apps(end+1) = string(relativePath(root, ... - fullfile(entries(k).folder, entries(k).name))); - end - apps = sort(unique(apps)); +function restoreRepairPathEntries(root, state) +entries = state.entries; +rootAvailable = false; +try + assertRepairRoot(root); + rootAvailable = true; +catch end - -function [snapshotFolder, movedCount] = moveCurrentInstallToSnapshot(root, progressFcn) - snapshotFolder = uniqueInstallSnapshotFolder(root); - ensureFolder(snapshotFolder); - entries = installRootEntries(root); - [~, snapshotName, snapshotExt] = fileparts(snapshotFolder); - snapshotLeaf = string(snapshotName) + string(snapshotExt); - entries = entries(string({entries.name}) ~= snapshotLeaf); - movedCount = 0; - entryCount = numel(entries); - for k = 1:entryCount - name = string(entries(k).name); - source = fullfile(root, char(name)); - target = fullfile(snapshotFolder, char(name)); - notifyTopLevelProgress(progressFcn, "Moving old runtime", ... - k, entryCount, name, 0.55, 0.18); - movefile(source, target, "f"); - movedCount = movedCount + 1; +for index = find(state.isRepairEntry) + if ~rootAvailable + candidate = ""; + elseif strlength(state.relativePaths(index)) == 0 + candidate = string(root); + else + candidate = string(fullfile(root, state.relativePaths(index))); end -end - -function snapshotFolder = uniqueInstallSnapshotFolder(root) - stamp = datestr(now, 'yyyymmdd_HHMMSS'); - baseName = "LabKit-previous-" + string(stamp); - snapshotFolder = fullfile(root, char(baseName)); - suffix = 1; - while exist(snapshotFolder, "file") ~= 0 || exist(snapshotFolder, "dir") ~= 0 - snapshotFolder = fullfile(root, char(baseName + "-" + string(suffix))); - suffix = suffix + 1; + if strlength(candidate) > 0 && exist(candidate, "dir") == 7 + entries(index) = candidate; + else + entries(index) = ""; end end - -function copiedCount = copyReplacementTree(sourceRoot, root, progressFcn) - entries = installRootEntries(sourceRoot); - copiedCount = 0; - entryCount = numel(entries); - for k = 1:entryCount - name = string(entries(k).name); - source = fullfile(sourceRoot, char(name)); - target = fullfile(root, char(name)); - notifyTopLevelProgress(progressFcn, "Copying replacement", ... - k, entryCount, name, 0.75, 0.24); - copyfile(source, target, "f"); - copiedCount = copiedCount + 1; - end +entries = entries(strlength(entries) > 0); +path(char(strjoin(entries, pathsep))); +rehash; end -function entries = installRootEntries(root) - entries = [dir(fullfile(root, "*")); dir(fullfile(root, ".*"))]; - names = string({entries.name}); - keep = ~ismember(names, [".", ".."]); - entries = entries(keep); - names = string({entries.name}); - [~, uniqueOrder] = unique(names, "stable"); - entries = entries(uniqueOrder); - [~, order] = sort(lower(string({entries.name}))); - entries = entries(order); +function restoreWorkingDirectory(originalFolder, root, parent, relative, wasInsideRepairRoot, pathState) +restoreRepairPathEntries(root, pathState); +if ~wasInsideRepairRoot && exist(originalFolder, "dir") == 7 + cd(originalFolder); +elseif strlength(relative) > 0 && exist(fullfile(root, relative), "dir") == 7 + cd(fullfile(root, relative)); +elseif exist(root, "dir") == 7 + cd(root); +elseif exist(parent, "dir") == 7 + cd(parent); +elseif exist(originalFolder, "dir") == 7 + cd(originalFolder); end - -function notifyTopLevelProgress(progressFcn, action, index, count, name, startValue, span) - if count == 0 - notifyProgress(progressFcn, action + ": no top-level items.", startValue + span); - return; - end - value = startValue + span * min(1, max(0, index / count)); - message = sprintf("%s %d/%d: %s", char(action), index, count, char(name)); - notifyProgress(progressFcn, message, value); end -function result = summaryStruct(root, copiedCount, movedCount, snapshotFolder, message) - result = struct("updated", copiedCount > 0, ... - "root", string(root), ... - "copiedCount", copiedCount, "deletedCount", 0, ... - "movedCount", movedCount, ... - "snapshotFolder", string(snapshotFolder), ... - "message", string(message)); +function result = summaryStruct(root, message) +result = struct("root", string(root), "message", string(message)); end -%% Section: Shared filesystem and path helpers - function notifyProgress(progressFcn, message, value) - try - progressFcn(string(message), value); - catch - end -end - -function updateProgressDialog(dlg, message, value) - if isempty(dlg) || ~isvalid(dlg) - return; - end - dlg.Message = char(message); - if isfinite(value) - dlg.Indeterminate = 'off'; - dlg.Value = min(max(value, 0), 1); - else - dlg.Indeterminate = 'on'; - end -end - -function files = collectFiles(root, pattern, excludedFolders) - entries = dir(fullfile(root, "**", pattern)); - files = strings(1, 0); - for k = 1:numel(entries) - if entries(k).isdir - continue; - end - filepath = string(fullfile(entries(k).folder, entries(k).name)); - rel = string(relativePath(root, filepath)); - parts = split(strrep(rel, filesep, "/"), "/"); - if any(ismember(parts, excludedFolders)) - continue; - end - files(end+1) = filepath; - end +if ~isempty(progressFcn) + progressFcn(char(message), value); end - -function ensureFolder(folder) - if strlength(string(folder)) > 0 && exist(folder, "dir") ~= 7 - mkdir(folder); - end end function removeFolderIfPresent(folder) - if exist(folder, "dir") == 7 - rmdir(folder, "s"); - end +if strlength(string(folder)) > 0 && exist(folder, "dir") == 7 + rmdir(folder, "s"); end - -function writeText(filepath, text) - ensureFolder(fileparts(filepath)); - fid = fopen(filepath, "w"); - assert(fid > 0, "Could not write file: %s", filepath); - cleaner = onCleanup(@() fclose(fid)); - fprintf(fid, "%s", text); - clear cleaner; end -function name = sanitizeFilename(value) - name = regexprep(char(string(value)), '[^A-Za-z0-9._-]', '-'); - if isempty(name) - name = 'release'; - end +function mode = repairGuiTestMode() +mode = "visible"; +if isappdata(groot, "labkitLauncherGuiTestMode") + mode = string(getappdata(groot, "labkitLauncherGuiTestMode")); end - -function tf = pathContains(folder) - paths = strsplit(path, pathsep); - tf = any(strcmp(paths, folder)); end -function rel = relativePath(root, filepath) - rel = char(filepath); - prefix = [char(root) filesep]; - if startsWith(rel, prefix) - rel = rel(numel(prefix)+1:end); - end - rel = strrep(rel, filesep, '/'); +function tf = isTextScalar(value) +tf = ischar(value) || (isstring(value) && isscalar(value)); end diff --git a/site/apps/dic/dic-postprocess.html b/site/apps/dic/dic-postprocess.html index 64af68eab..8591b033e 100644 --- a/site/apps/dic/dic-postprocess.html +++ b/site/apps/dic/dic-postprocess.html @@ -92,7 +92,7 @@
  • DIC family
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/dic/dic-preprocess.html b/site/apps/dic/dic-preprocess.html index 014b469e8..4a21c4915 100644 --- a/site/apps/dic/dic-preprocess.html +++ b/site/apps/dic/dic-preprocess.html @@ -86,7 +86,7 @@
  • DIC family
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/chrono-overlay.html b/site/apps/electrochemistry/chrono-overlay.html index a8e1c2fa8..62f3f6564 100644 --- a/site/apps/electrochemistry/chrono-overlay.html +++ b/site/apps/electrochemistry/chrono-overlay.html @@ -65,7 +65,7 @@
  • DTA Library
  • CIC
  • VT Resistance
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/cic.html b/site/apps/electrochemistry/cic.html index 16d21852a..b1edcb0d4 100644 --- a/site/apps/electrochemistry/cic.html +++ b/site/apps/electrochemistry/cic.html @@ -76,7 +76,7 @@
  • DTA pulse detection
  • VT Resistance
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/csc.html b/site/apps/electrochemistry/csc.html index 4353b6462..cc81822cf 100644 --- a/site/apps/electrochemistry/csc.html +++ b/site/apps/electrochemistry/csc.html @@ -76,7 +76,7 @@
  • Electrochemistry family
  • DTA Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/eis.html b/site/apps/electrochemistry/eis.html index 7318310f0..425b82a6b 100644 --- a/site/apps/electrochemistry/eis.html +++ b/site/apps/electrochemistry/eis.html @@ -31,25 +31,26 @@

    Basic Workflow

    1. Add the EIS DTA files.
    2. Choose X and Y quantities.
    3. +
    4. Choose , Ω, , or for impedance axes. New projects default to .
    5. Enable logarithmic X or Y scaling only for strictly positive plotted data.
    6. Use Fit X/Y limits to re-estimate independent limits from the current data, or Use equal X/Y scale when equal data units are wanted.
    7. Adjust marker, line, grid, and legend presentation.
    8. Export the current plot data CSV.

    Axis Quantities

    -

    The available quantities are frequency, log10 frequency, time, point number, real impedance, imaginary impedance, negative imaginary impedance, impedance magnitude, phase, DC current, and DC voltage. Default axes are Zreal (ohm) and -Zimag (ohm).

    +

    The available quantities are frequency, log10 frequency, time, point number, real impedance, imaginary impedance, negative imaginary impedance, impedance magnitude, phase, DC current, and DC voltage. Default axes are Zreal and -Zimag, displayed in kΩ for a new project.

    Use Zreal versus -Zimag for the conventional Nyquist orientation. Use frequency versus magnitude or phase for Bode-style views. The log-axis checkbox changes MATLAB axes scaling; choosing log10(Freq) changes the data coordinate itself. Do not apply both transformations unless that is explicitly intended.

    Plot Parameters

    -
    ParameterDefault
    Line width1.4
    Marker size6
    Show markerson
    Log X / Log Yoff / off
    Legend / Gridon / on
    +
    ParameterDefault
    Impedance unit
    Line width1.4
    Marker size6
    Show markerson
    Log X / Log Yoff / off
    Legend / Gridon / on

    The app never infers an equal aspect ratio from the selected quantities: a Nyquist plot starts with independently fitted limits. Use Use equal X/Y scale only when equal data units are useful for the current comparison. Use Fit X/Y limits to return to independent limits after equal scaling or a manual zoom. Equal scaling expands a fitted limit when necessary so X and Y data units have the same on-screen length; it is a one-time reset and does not constrain later wheel zooming. Axis and styling changes preserve the current source set and the current viewport; the two view buttons explicitly replace that viewport.

    Output

    -

    Export current plot CSV writes the selected X/Y values for each valid file on a shared row index. Each file retains its own X and Y pair, so unequal curve lengths do not imply interpolation. A result manifest records the selected axes, plot parameters, source references, and output role.

    +

    Export current plot CSV writes the selected X/Y values for each valid file on a shared row index. Each file retains its own X and Y pair, so unequal curve lengths do not imply interpolation. Impedance columns use the selected display unit and include an ASCII unit suffix such as kohm in the column name. A result manifest records the selected axes, impedance unit, plot parameters, source references, and output role.

    Use Without The GUI

    [item, status] = labkit.dta.loadFile("spectrum.DTA", "eis");
     assert(status.ok, status.message);
    -curve = labkit.dta.getZCurve(item);
    -x = eis.analysisRun.valuesForAxis(curve, "Zreal (ohm)");
    -y = eis.analysisRun.valuesForAxis(curve, "-Zimag (ohm)");
    +units = eis.impedanceDisplay.catalog();
    +x = eis.analysisRun.valuesForAxis(item, "Zreal", units.choices(3));
    +y = eis.analysisRun.valuesForAxis(item, "-Zimag", units.choices(3));
     plot(x, y, "o-");
     axis equal

    valuesForAxis is app-owned and not currently part of the published app API catalog. The DTA loader and getZCurve are supported reusable APIs.

    @@ -57,6 +58,7 @@

    Errors And Limitations

    • Log axes omit or reject nonpositive coordinates according to MATLAB axes behavior; inspect the data rather than treating missing points as zero.
    • Overlaying files does not normalize electrode area or fixture geometry.
    • +
    • Changing the impedance display unit rescales impedance axes and exported impedance columns; it does not alter the DTA values stored in base ohms.
    • Axis labels describe parsed DTA columns; they do not validate the experiment configuration recorded by the instrument.
    @@ -64,7 +66,7 @@
  • Electrochemistry family
  • DTA Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/electrochemistry/index.html b/site/apps/electrochemistry/index.html index 32e7f76ff..9239a7bc1 100644 --- a/site/apps/electrochemistry/index.html +++ b/site/apps/electrochemistry/index.html @@ -27,7 +27,7 @@

    Choose An App

    Measurement or taskAppRequired DTA contentMain result
    Compare voltage and current transients across filesChrono Overlaychrono curve with T, Vf, Imaligned overlay and wide CSV
    Charge-injection capacity and polarization voltageCICbiphasic chrono transientper-file CIC, charge, and voltage metrics
    Charge-storage capacity by time and CV integrationCSCCV/CT cycles with T, Vf, Imper-cycle CT/CV CSC comparison
    Impedance inspection and exportEISZCURVEconfigurable Nyquist/Bode-style overlay
    Steady pulse resistanceVT Resistancebiphasic chrono transientcathodic, anodic, and mean resistance

    Shared File Behavior

    File controls accept one or more .DTA files from one folder in a single selection. A canceled chooser leaves the current project unchanged. CIC, CSC, and VT Resistance use the selected row as the active preview while retaining the loaded source list for batch export. Invalid items are reported per file; one failed item does not silently replace another result.

    -

    The DTA library returns structured items, curve tables, headers, units, metadata, parser messages, and status. Apps use exact required columns for scientific calculations and do not infer missing physical quantities from a plot label.

    +

    The DTA library returns structured items, curve tables, headers, units, metadata, parser messages, and status. Apps use exact required columns for scientific calculations and do not infer missing physical quantities from a plot label. The Apps require the version 3 unit-explicit DTA contract rather than branching between duplicate legacy and canonical item fields.

    Units And Traceability

    Time is seconds, voltage is volts, current is amperes, impedance is ohms, charge is coulombs, electrode area is square centimetres, and normalized CIC or CSC is reported in the unit shown by the app. UI display conversions do not change stored base-unit calculations.

    Export tables include the source identity and analysis settings needed to interpret the result. Runtime-generated .labkit.json files are provenance manifests describing inputs, parameters, and output roles; they are not a second numerical result and can be kept with the corresponding CSV.

    diff --git a/site/apps/electrochemistry/vt-resistance.html b/site/apps/electrochemistry/vt-resistance.html index 6777ac0ad..5759939d6 100644 --- a/site/apps/electrochemistry/vt-resistance.html +++ b/site/apps/electrochemistry/vt-resistance.html @@ -71,7 +71,7 @@
  • CIC
  • DTA Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/gait/gait-analysis.html b/site/apps/gait/gait-analysis.html index b69caad06..9484e9497 100644 --- a/site/apps/gait/gait-analysis.html +++ b/site/apps/gait/gait-analysis.html @@ -103,7 +103,7 @@
  • gait_analysis.analysisRun.computeGait
  • Video Marker
  • Gait apps
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/batch-crop.html b/site/apps/image-measurement/batch-crop.html index b28f56d27..42739ee06 100644 --- a/site/apps/image-measurement/batch-crop.html +++ b/site/apps/image-measurement/batch-crop.html @@ -31,13 +31,13 @@

    Basic Workflow

    1. Load images and select a task.
    2. Set crop width and height or switch to physical mode.
    3. -
    4. Drag the highlighted crop center/ROI in the preview or enter center X/Y.
    5. +
    6. Click anywhere in the preview to place the crop center. Drag the center marker or anywhere inside the highlighted ROI to move it, or enter center X/Y.
    7. Set rotation and padding.
    8. Calibrate physical scale when required.
    9. Duplicate tasks for additional ROIs.
    10. Choose format and output folder, then export.
    -

    The preview rectangle remains draggable and resizable. Editing the ROI updates the selected task immediately; switching tasks restores that task's geometry. Marker and ROI refreshes preserve axes zoom.

    +

    The preview rectangle remains draggable by either its center marker or its interior; a click without dragging sets its center at that preview location, including inside the rectangle. Change crop width and height with their controls. Editing the ROI updates the selected task immediately; switching tasks restores that task's geometry. ROI refreshes preserve axes zoom.

    Pixel Mode

    Default crop dimensions are 1024 by 1024 pixels. Rotation defaults to 0 degrees and padding to 0%. Center controls use source-image coordinates. When crop geometry crosses the rotated image boundary, edge-continuous padding supplies pixels so every exported crop retains the requested dimensions.

    Physical Mode And Scale

    @@ -77,7 +77,7 @@
  • Image Measurement family
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/curvature.html b/site/apps/image-measurement/curvature.html index 1edcb7d91..1b3ea6a9c 100644 --- a/site/apps/image-measurement/curvature.html +++ b/site/apps/image-measurement/curvature.html @@ -37,7 +37,7 @@

    Basic Workflow

  • Export the result CSV and overlay PNG.
  • The edit buttons change to Finish curve edit or Finish reference edit while their managed interaction is active. Curve and reference edits are mutually exclusive. Anchor edits and result overlays preserve the current axes zoom.

    -

    The Summary + Results tab reports curve length, radius, curvature, RMSE, fit center, and pixels per selected unit. Details explains the next valid step before a result exists and reports the current measurement afterward. The Log tab records file, edit, fit, calibration, and export actions.

    +

    The Summary + Results tab reports curve length, radius, curvature, RMSE, fit center, and pixels per selected unit. Details explains the next valid step before a result exists and reports the current measurement afterward. Tools > Diagnostics > Open Session Log... records file, edit, fit, calibration, export, and runtime actions without consuming a workflow tab.

    The chosen image is saved as a portable project source. Older projects are upgraded on load without changing the curve, calibration, or result meaning.

    Curve Editing

    Curve points are ordered by placement. Undo last point removes the newest anchor and Clear curve removes the complete trace. Neighboring duplicate points are removed before numeric fitting. At least three distinct points are required for a circle fit; length requires at least two.

    @@ -66,7 +66,7 @@
  • Image Measurement family
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/flir-thermal.html b/site/apps/image-measurement/flir-thermal.html index 718a9e839..a35f5a985 100644 --- a/site/apps/image-measurement/flir-thermal.html +++ b/site/apps/image-measurement/flir-thermal.html @@ -77,7 +77,7 @@
  • Thermal Library
  • Image Measurement family
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/focus-stack.html b/site/apps/image-measurement/focus-stack.html index a96ce7ecb..edb5335f7 100644 --- a/site/apps/image-measurement/focus-stack.html +++ b/site/apps/image-measurement/focus-stack.html @@ -73,7 +73,7 @@
  • Image Measurement family
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/image-enhance.html b/site/apps/image-measurement/image-enhance.html index 683fe2a5c..272e8631e 100644 --- a/site/apps/image-measurement/image-enhance.html +++ b/site/apps/image-measurement/image-enhance.html @@ -70,7 +70,7 @@
  • Image Match
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/image-match.html b/site/apps/image-measurement/image-match.html index 9ac99ed3d..3c1db56d1 100644 --- a/site/apps/image-measurement/image-match.html +++ b/site/apps/image-measurement/image-match.html @@ -69,7 +69,7 @@
  • Image Enhance
  • Image Library
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/image-measurement/video-marker.html b/site/apps/image-measurement/video-marker.html index 2a3e872b6..3d70470e7 100644 --- a/site/apps/image-measurement/video-marker.html +++ b/site/apps/image-measurement/video-marker.html @@ -18,7 +18,7 @@
    - +

    app

    Video Marker

    @@ -64,8 +64,8 @@

    Outputs

  • output manifests recording coordinate options and file roles.
  • CSV dialogs start in a source-adjacent video_marker output folder. Result manifests are written beside the chosen CSV.

    -

    Debug Diagnostics

    -

    Launch with Diagnostics=labkit.app.diagnostic.Options(Level="verbose") to record the richer sanitized callback, checkpoint, count, project, dialog, resource, and failure trace. Add Sample="synthetic" and an artifact folder to run the App's BuildDebugSample contract, which creates a synthetic video, a valid initial marking project, and declared marker/coordinate output targets without including user filenames or laboratory data.

    +

    Diagnostics And Synthetic Inputs

    +

    Use Tools > Diagnostics to inspect the live session log, enable trace capture, or export a diagnostic bundle after a problem occurs. Use Tools > Developer Tools > Generate Synthetic Inputs... to create a synthetic video, a valid marking project, and declared marker/coordinate output targets without including user filenames or laboratory data. Generation does not load those inputs into the running App.

    Use Without The GUI

    previousFrame = peaks(61);
     nextFrame = circshift(previousFrame, [-2 3]);
    @@ -99,7 +99,7 @@ 
     
  • Gait Analysis
  • Image Measurement family
  • API Reference
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/index.html b/site/apps/index.html index 613fd2779..a97dfd52d 100644 --- a/site/apps/index.html +++ b/site/apps/index.html @@ -58,6 +58,7 @@

    How To Read An App Page

    Shared framework contracts are documented once in the App Framework, not repeated in every App page. An App page mentions shared behavior only when that App changes it or when the behavior is necessary to complete the App's workflow.

    Common App Behavior

    The App Framework owns lifecycle, busy state, file selection, state snapshots, screenshot actions, plot tools, and managed interactions. Apps own scientific choices, workflow-specific defaults, result schemas, and exports. See the App Framework for behavior shared across apps.

    +

    Every App opens as a clean project. Use Tools > Diagnostics to inspect the current session history, enable future trace capture, or export a diagnostic bundle after a problem. Apps with declared sample generation expose Tools > Developer Tools > Generate Synthetic Inputs...; generation writes anonymous inputs without loading them or changing the open project. The runtime guide defines these shared contracts.

    Action and input-selection buttons provide concise hover help. The shared Tools menu contains plot, screenshot, and project-state actions when the corresponding capability is available.

    Input data and exported results should remain outside the replaceable LabKit runtime folder. Apps do not overwrite source files unless an app page states an explicit in-place operation.

    Programmatic Use

    diff --git a/site/apps/labkit-core/figure-studio.html b/site/apps/labkit-core/figure-studio.html index f894b5eb4..7b310bdfc 100644 --- a/site/apps/labkit-core/figure-studio.html +++ b/site/apps/labkit-core/figure-studio.html @@ -71,7 +71,7 @@

    Start The Launcher

    labkit_launcher

    The window paints before app discovery completes. Its status area first reports that the app list is loading, then shows the selected app, integrity problems, tool availability, or the active maintenance operation.

    Launcher Window

    -
    GroupActionBehavior
    Run AppsOpen Selected AppChecks the selected app requirements, adds the app root, and calls its App SDK entrypoint without retired runtime launch arguments.
    Run AppsOpen DebugStarts the same App through its typed SDK diagnostics contract, records verbose structured events under artifacts/diagnostics/launcher/, and loads the App-owned anonymous synthetic sample.
    Run AppsRefresh App ListRepeats public and configured private-app discovery without restarting the launcher.
    Run AppsDocumentation and HistoryOpens the generated manual for the selected app.
    Versions and InstallLatestInstalls the current main branch archive.
    Versions and InstallReleaseInstalls the latest stable GitHub release.
    Versions and InstallVersionsOpens the release, tag, and commit selector for deliberate upgrade or rollback.
    Development and MaintenanceUpdate DocumentationRegenerates site/ with the repository-owned documentation compiler.
    Development and MaintenanceRun Code AnalyzerScans the checkout and writes JSON and HTML Code Analyzer reports.
    Development and MaintenanceProfile Selected AppStarts the selected app under the MATLAB profiler and saves its report when the app closes.
    Development and MaintenanceClean ArtifactsRemoves ignored generated reports under artifacts/; it does not delete app projects or exported laboratory results.
    Package and PublishPackage CheckedCreates one source ZIP containing all checked apps and their runtime support.
    Package and PublishChecked P-codeCreates a runtime-only ZIP with MATLAB source encoded as P-code.
    +
    GroupActionBehavior
    Run AppsOpen Selected AppChecks the selected app requirements, adds the app root, and calls its App SDK entrypoint without retired runtime launch arguments.
    Run AppsRefresh App ListRepeats public and configured private-app discovery without restarting the launcher.
    Run AppsDocumentation and HistoryOpens the generated manual for the selected app.
    Versions and InstallLatestInstalls the current main branch archive.
    Versions and InstallReleaseInstalls the latest stable GitHub release.
    Versions and InstallVersionsOpens the release, tag, and commit selector for deliberate upgrade or rollback.
    Development and MaintenanceUpdate DocumentationRegenerates site/ with the repository-owned documentation compiler.
    Development and MaintenanceRun Code AnalyzerScans the checkout and writes JSON and HTML Code Analyzer reports.
    Development and MaintenanceProfile Selected AppStarts the selected app under the MATLAB profiler and saves its report when the app closes.
    Development and MaintenanceClean ArtifactsRemoves ignored generated reports under artifacts/; it does not delete app projects or exported laboratory results.
    Package and PublishPackage CheckedCreates one source ZIP containing all checked apps and their runtime support.
    Package and PublishChecked P-codeCreates a runtime-only ZIP with MATLAB source encoded as P-code.

    Double-clicking an app row is equivalent to selecting it and opening it normally. The checkbox column controls package membership; ordinary launch selection does not change the checked set.

    -

    Debug sessions use a new isolated artifact folder on every launch. The folder contains the runtime event stream, session manifest, synthetic sample manifest, and any anonymous fixture files declared by the selected App. Normal launches keep the SDK's bounded standard diagnostics in memory and do not create this verbose artifact set.

    +

    Every launch uses the same clean App path. Use the App's Tools > Diagnostics menu to inspect its live session log, enable trace capture, or export a diagnostic ZIP after a problem occurs. Apps that declare a synthetic input pack expose Tools > Developer Tools > Generate Synthetic Inputs.... Generation writes anonymous fixture files and a manifest into a new folder but does not load them or mutate the running project.

    Programmatic Calls

    The launcher exposes a small non-GUI surface:

    fig = labkit_launcher;
    @@ -72,7 +72,7 @@ 
     
  • Architecture
  • Private Apps
  • Release Process
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/neurophysiology/nerve-response-analysis.html b/site/apps/neurophysiology/nerve-response-analysis.html index 56a95e48a..7b4fd1daf 100644 --- a/site/apps/neurophysiology/nerve-response-analysis.html +++ b/site/apps/neurophysiology/nerve-response-analysis.html @@ -87,7 +87,7 @@
  • nerve_response_analysis.analysisRun.measureCapMetrics
  • RHS Preview
  • Response Review and Stats
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/neurophysiology/response-review-stats.html b/site/apps/neurophysiology/response-review-stats.html index cded76149..5fb9782af 100644 --- a/site/apps/neurophysiology/response-review-stats.html +++ b/site/apps/neurophysiology/response-review-stats.html @@ -92,7 +92,7 @@
  • response_review_stats.analysisRun.measureAlignedSegments
  • response_review_stats.analysisRun.summarizeMetrics
  • Nerve Response Analysis
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/neurophysiology/rhs-preview.html b/site/apps/neurophysiology/rhs-preview.html index cb510cbac..dd4fcfc36 100644 --- a/site/apps/neurophysiology/rhs-preview.html +++ b/site/apps/neurophysiology/rhs-preview.html @@ -75,7 +75,7 @@
  • rhs_preview.analysisRun.readPreviewWindow
  • Nerve Response Analysis
  • RHS library
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/statistics/ttest-wizard.html b/site/apps/statistics/ttest-wizard.html index fc60c1a2c..679213e03 100644 --- a/site/apps/statistics/ttest-wizard.html +++ b/site/apps/statistics/ttest-wizard.html @@ -133,7 +133,7 @@
  • Simple Scientific CSV Exchange
  • Figure Studio
  • App Framework
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/apps/wearable/ecg-print.html b/site/apps/wearable/ecg-print.html index ed4292cf8..a9d628c65 100644 --- a/site/apps/wearable/ecg-print.html +++ b/site/apps/wearable/ecg-print.html @@ -56,7 +56,7 @@

    Analyze ECG

  • Review the four plots: waveform and peaks, template-noise RMS, template SNR, and the template with either a residual band or individual segments.
  • The filter is applied to the full selected channel before the time region is cropped. This reduces boundary artifacts at the region edges.

    -

    The Files + Analysis tab keeps the workflow in five ordered sections: Recording, Import Parsing, Channel + ROI, Signal Processing + SNR, and Exports. Bounded numeric settings use paired spinner-and-slider controls. Summary + Results contains the analysis summary and file-header preview, while Log records the current session workflow. The ECG Preview workspace keeps four vertically stacked time-series axes available on every tab.

    +

    The Files + Analysis tab keeps the workflow in five ordered sections: Recording, Import Parsing, Channel + ROI, Signal Processing + SNR, and Exports. Bounded numeric settings use paired spinner-and-slider controls. Summary + Results contains the analysis summary and file-header preview. Tools > Diagnostics > Open Session Log... records the current workflow and earlier runtime context. The ECG Preview workspace keeps four vertically stacked time-series axes available on every tab.

    Analysis Parameters

    ParameterDefaultMeaning
    Fallback sample rate2000 HzUsed only when import cannot derive time spacing
    Bandpass0.5 to 40 HzApplied before peak detection; the upper cutoff is kept below 45% of sample rate
    Peak methodQRS streamingQRS streaming, Pan-Tompkins, or local peaks
    Peak distance0.28 sMinimum interval between accepted detections
    Segment half-window0.7 sData retained before and after each event
    Template top N30Number of best segments used to build the template
    Smooth beats15Smoothing span used in exported per-segment trends
    Template plotTemplate + residual bandAlternative view is template plus individual segments

    Peak polarity is selected automatically. The default detector threshold is 2.8 standard deviations inside the app calculation.

    @@ -96,7 +96,7 @@
    ++syntheticInputs/ app-owned clean-room synthetic input generation

    Create only the packages the app needs. Names should describe a workflow or domain capability that changes together, not a broad technical phase. Avoid generic +actions, +renderers, +ops, +io, +ui, +userInterface, +view, +export, +helpers, and +utils packages for new app code. Avoid fixed +app namespaces, family-level private/ helpers, *Workflow.m string dispatchers, and +core/dispatch.m routers.

    +state, +actions, +ui, +view, +ops, +io, and +export packages were retired with the workflow-first migration. Current app work should follow the workflow-first shape.

    App SDK Boundary

    diff --git a/site/development/build-apps/complete-app.html b/site/development/build-apps/complete-app.html index f90a35621..92124709d 100644 --- a/site/development/build-apps/complete-app.html +++ b/site/development/build-apps/complete-app.html @@ -57,7 +57,7 @@

    2. One App Definition

    Family="Examples", ... AppVersion="1.0.0", ... Updated="2026-07-19", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... ProjectSchema=trace_viewer.projectSpec(), ... CreateSession=@trace_viewer.createSession, ... Workbench=trace_viewer.workbench.buildLayout(), ... diff --git a/site/framework/app-sdk-api.html b/site/framework/app-sdk-api.html index 5ff6ed557..795ee5463 100644 --- a/site/framework/app-sdk-api.html +++ b/site/framework/app-sdk-api.html @@ -31,21 +31,20 @@

    Start With The Core Contract

  • CallbackContext exposes runtime capabilities to callbacks.
  • Browse By Capability

    -
    CapabilityAPI entry point
    Layout and controlsApp SDK layout API
    View snapshotsApp SDK view API
    Typed eventsApp SDK event API
    Projects and sourcesApp SDK project API
    ResultsApp SDK result API
    Managed interactionsApp SDK interaction API
    Plot mechanicsApp SDK plot API
    Diagnostics and dialogsApp SDK diagnostic API and dialog API
    +
    CapabilityAPI entry point
    Layout and controlsApp SDK layout API
    View snapshotsApp SDK view API
    Typed eventsApp SDK event API
    Projects and sourcesApp SDK project API
    ResultsApp SDK result API
    Managed interactionsApp SDK interaction API
    Plot mechanicsApp SDK plot API
    Runtime diagnosticsRuntime diagnostics and session logging
    Dialog resultsApp SDK dialog API

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/framework/guides/runtime.html b/site/framework/guides/runtime.html index e79dcc468..94ae8a82c 100644 --- a/site/framework/guides/runtime.html +++ b/site/framework/guides/runtime.html @@ -18,7 +18,7 @@
    - +

    guide

    Runtime And Lifecycle

    @@ -36,14 +36,14 @@

    Definition And Launch

    Family="Examples", ... AppVersion="1.0.0", ... Updated="2026-07-19", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... Workbench=example.workbench.buildLayout(), ... ProjectSchema=example.projectSpec(), ... CreateSession=@example.createSession, ... PresentWorkbench=@example.workbench.present); end

    Required Definition arguments are product metadata, requirements, and one labkit.app.layout.workbench value. Optional callbacks are:

    -
    ArgumentSignaturePurpose
    CreateSessionsession = callback(project,callbackContext)Rebuild transient App data from durable project state.
    PresentWorkbenchview = callback(applicationState)Return the App-owned fragment of the complete visible snapshot.
    OnStartapplicationState = callback(applicationState,callbackContext)Perform a real post-first-commit request or resource initialization.
    BuildDebugSamplesample = callback(callbackContext)Build clean-room debug input when the App supports it.
    +
    ArgumentSignaturePurpose
    CreateSessionsession = callback(project,callbackContext)Rebuild transient App data from durable project state.
    PresentWorkbenchview = callback(applicationState)Return the App-owned fragment of the complete visible snapshot.
    OnStartapplicationState = callback(applicationState,callbackContext)Perform a real post-first-commit request or resource initialization.
    BuildSyntheticSamplesample = callback(callbackContext)Build clean-room debug input when the App supports it.

    Ordinary default state needs no startup callback. Exact syntax and errors are in the generated public API reference.

    launch("requirements") and launch("version") answer metadata without creating a figure. launch() constructs the private native adapter and shows the App.

    Static Workbench Contract

    @@ -150,13 +150,23 @@

    CallbackContext

    Use context methods only at a callback or reconstruction boundary. Pure readers, calculations, result builders, and render-model builders accept ordinary explicit values.

    callbackContext.chooseOption(prompt, choices, ...) owns ordinary native confirmation choices. Title controls the dialog title, DefaultChoice selects the Enter-key action, and CancelChoice is returned when the user dismisses the dialog. All three named choices must be members of the declared nonempty unique choice row. File and folder methods remain separate because they return paths and use platform file choosers.

    An App-specific project button may choose a MAT file and return callbackContext.restoreProjectDocument(filepath). The context prepares the same migrated, relinked project/session candidate used by the framework Load State menu; the active callback transaction still owns validation, native presentation, rollback, document metadata, and title publication. callbackContext.newProjectDocument() similarly returns the schema's fresh project/session state and publishes a new unsaved document identity only when that callback transaction commits.

    +

    Diagnostics And Session Logging

    +

    Every ordinary App launch starts one sanitized session event stream and durable journal. Launch arguments do not select a debug mode, change startup behavior, or generate sample data. Runtime automatically records lifecycle, callback, transaction, dialog, project, source, result, and failure boundaries with correlated operation IDs.

    +

    App callbacks add domain events through callbackContext.log(severity,eventName,message,Name=Value). Use info for useful progress and completed user actions, warning for recoverable conditions, and error or critical for failures. Stable event names and structured allowlisted attributes support diagnosis; messages remain concise and safe for display. Pass caught exceptions through the dedicated Exception option instead of copying stack, path, identifier, or scientific data into free text.

    +

    The App's Tools > Diagnostics menu opens the live session viewer, enables more detailed trace capture for future activity, and exports a diagnostic bundle from the same session history. Enabling trace does not restart the App or reconstruct earlier detail. Journal degradation is itself exposed in the surviving in-memory stream; logging failures never alter callback transaction semantics or scientific results.

    Persistence, Results, And Cleanup

    +

    Synthetic Inputs

    +

    An App that declares BuildSyntheticSample exposes Tools > Developer Tools > Generate Synthetic Inputs.... The action writes an anonymous, validated labkit.app.synthetic.Pack and synthetic-input-pack.json into a new folder beneath the selected destination. Generation does not load the pack, mutate the open project, or suppress OnStart; every App launch follows the same clean startup path. Users deliberately import the generated files through the App's ordinary controls.

    labkit.app.project.Schema owns current project creation, validation, and ordered version migration. Runtime owns the project envelope, atomic save, restore, recovery, and relinking loop.

    +

    Saved-project compatibility boundary

    +

    Every save writes exactly one labkitProject variable using the current App payload version. A restore accepts an older payload only when the App's current schema declares one Migrate(project, fromVersion) callback; Runtime invokes it once for each missing version in order and then validates the current payload. A payload newer than the running App is rejected rather than guessed at.

    +

    An App may declare an exact legacy MAT variable name in LegacyImports when real user files still require a one-way import. That callback converts the legacy value directly to the current project and optional resume state. It is read-only: current saves never write the legacy variable, and Runtime contains no App ID, field-shape, or filename heuristics.

    +

    These readers are supported data contracts while their Apps declare and test them. They are not an excuse for duplicate live state fields or old runtime APIs. Removing a supported payload migration or importer is an explicit breaking saved-data decision; adding one requires App-owned persistence evidence plus a runtime restore test for the framework mechanism.

    labkit.app.result.File and labkit.app.result.Package describe App-owned outputs. CallbackContext.writeResultPackage writes through the runtime so source and project provenance remain consistent.

    Resources have event, interaction, document, or application scope. Replacing the same scope and ID is idempotent; the runtime cleans every surviving resource on scope end or close.

    Validation

    Use focused contract tests for Definition, layout, callbacks, snapshots, project schema, and runtime transactions. Add downstream App tests for changed behavior and a bounded hidden-GUI test for native wiring. Automated hidden GUI tests do not prove dialog quality, pointer feel, scientific validity, or a complete interactive workflow.

    -

    See Testing and Build A Complete App.

    Change history

    +

    See Testing and Build A Complete App.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/framework/index.html b/site/framework/index.html index 14ce1b901..5afaa5b41 100644 --- a/site/framework/index.html +++ b/site/framework/index.html @@ -43,7 +43,7 @@

    Smallest App

    Entrypoint="labkit_Example_app", AppId="example", ... Title="Example", Family="Examples", ... AppVersion="1.0.0", Updated="2026-07-19", ... - Requirements=labkit.contract.requirements("app", ">=1 <2"), ... + Requirements=labkit.contract.requirements("app", ">=2 <3"), ... Workbench=workbench); end

    The entrypoint calls definition().launch(...). Definition compiles the immutable semantic graph before creating a figure, validates direct callback and renderer signatures, and builds one private native platform plan.

    @@ -62,11 +62,12 @@

    Paved Road

  • Use layout.group(..., Title="...") for a nested reader-facing control boundary inside a section; leave Title blank for arrangement-only groups.
  • A control tab containing one growable file list, table, log, status, or plot surface fills the available tab height. Tabs with longer mixed content remain scrollable.
  • Declare editable overlays with labkit.app.interaction.* on the plot area; supply their current values with same-named Snapshot methods.
  • +
  • For a managed rectangle with OnBackgroundPressed, an un-dragged click anywhere on its plot—including inside the rectangle—uses that point callback; dragging the rectangle still uses its change callback.
  • Use labkit.app.plot.clearAxes, showMessage, and fitAxesToGraphics for renderer mechanics; EqualDataUnits=true makes a one-time fitted equal-scale view from the settled native axes allocation without dispatching pending UI callbacks, without changing the allocation or locking later zoom. Apps still decide message wording and viewport policy.
  • Use labkit.app.project.Schema, labkit.app.result.File, and labkit.app.result.Package only when those optional capabilities exist.
  • Use labkit.app.project.sourceRecord only in pure project creation or migration code that must turn a legacy path into a portable source value; runtime callbacks still resolve paths through CallbackContext.
  • -

    Runtime validates candidate state and the complete view snapshot before publishing either. The private MATLAB adapter maps semantic IDs to native components, preserves plot viewports, normalizes native event differences, and never exposes component registries to Apps.

    +

    Runtime validates candidate state and the complete view snapshot before publishing either. The private MATLAB adapter maps semantic IDs to native components, skips unchanged native property writes, reuses direct associations between controls and their labels, preserves plot viewports, normalizes native event differences, and never exposes component registries to Apps.

    Normal App launches show the completed native window. Official GUI validation uses the same launch path with a framework-owned visibility policy: hidden keeps the final window off screen and minimized minimizes it after startup. Tests therefore exercise real controls without individual Apps or test methods having to hide the window after launch.

    Built-in App Tools

    The native runtime installs one top-level Tools menu so framework-owned utilities do not compete with the App's workflow controls:

    @@ -82,7 +83,7 @@
  • App catalog
  • App development
  • Project history
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/getting-started/index.html b/site/getting-started/index.html index 4b00c6dea..9bd2f1795 100644 --- a/site/getting-started/index.html +++ b/site/getting-started/index.html @@ -71,7 +71,7 @@

    Next Steps

  • LabKit Launcher
  • Public API reference
  • Development guide
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/index.html b/site/history/index.html index f36162e4d..b47e78524 100644 --- a/site/history/index.html +++ b/site/history/index.html @@ -30,7 +30,7 @@

    Reading The Timeline

    The folder is chronological, while the metadata inside each record names the apps, framework, or libraries affected by the change. Every record also has a global positive-integer sequence. Sequence values are unique and contiguous: the first record is 1, and each later change takes the next value even when several changes share a date. The generated timeline sorts by descending sequence, so titles and filenames cannot reorder same-day versions. Dates must remain nondecreasing as sequence increases. The authoritative history record format defines every metadata field and body section.

    The documentation site uses that metadata to show a relevant Change history section on each component page. A change that affects several components is stored once and linked from all of them.

    Find A Change

    -

    Use the site search for an app command, public function, Change ID, or feature name. You can also browse the complete timeline below by date. Current version numbers come from the launcher, facade version.m files, and App definition.m metadata rather than from a separate table in this history.

    Complete timeline

    +

    Use the site search for an app command, public function, Change ID, or feature name. You can also browse the complete timeline below by date. Current version numbers come from the launcher, facade version.m files, and App definition.m metadata rather than from a separate table in this history.

    Complete timeline

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html b/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html index 47fe8ec9a..4bb80c137 100644 --- a/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html +++ b/site/history/records/2026/07/LK-20260713-dic-rigid-point-editor.html @@ -31,7 +31,7 @@

    Single-click DIC rigid point matc component: `labkit_DICPreprocess_app` | `1.3.6 -> 1.4.0` scope: Single-click DIC rigid point matching

    Context

    -

    DIC manual rigid matching had draggable points but maintained a separate pointer implementation and required a less consistent placement workflow than the ROI-center anchors used by Imager Reconstruction.

    +

    DIC manual rigid matching had draggable points but maintained a separate pointer implementation and required a less consistent placement workflow than the ROI-center anchors used by related reconstruction workflows.

    Decision and rationale

    Extend the existing app-neutral anchor editor with a discrete point mode, then keep moving/fixed pair order, numbering, minimum pair count, and rigid-fit policy inside DIC.

    Changes

    diff --git a/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html b/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html index f5d84e833..bb080d391 100644 --- a/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html +++ b/site/history/records/2026/07/LK-20260720-launcher-app-sdk-diagnostics.html @@ -57,7 +57,7 @@

    Validation

    Evidence

    The validation details above are the supporting evidence for this record.

    Known limitations and follow-up

    -

    Developer-led interactive validation should confirm the visible debug launch, the selected App's synthetic state, and the usefulness of the generated diagnostic bundle on the deployment MATLAB version.

    +

    Developer-led interactive validation should confirm the visible debug launch, the selected App's synthetic state, and the usefulness of the generated diagnostic bundle on the deployment MATLAB version.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260723-test-catalog-cutover.html b/site/history/records/2026/07/LK-20260723-test-catalog-cutover.html index 3408c0a32..e920d3b06 100644 --- a/site/history/records/2026/07/LK-20260723-test-catalog-cutover.html +++ b/site/history/records/2026/07/LK-20260723-test-catalog-cutover.html @@ -58,7 +58,7 @@

    Validation

    Evidence

    The catalog writes exact identities and semantic reasons into each run's plan.json; the final headless summary.json records 202 passed identities with no failures or incomplete tests. The PR workflow independently executes headless and hidden-GUI profiles on Linux, macOS, and Windows.

    Known limitations and follow-up

    -

    Hidden-GUI checks remain structural and do not replace manual validation of native dialogs, visual quality, pointer behavior, real-data suitability, or scientific interpretation. The PR must receive green cross-platform CI before merge.

    +

    Hidden-GUI checks remain structural and do not replace manual validation of native dialogs, visual quality, pointer behavior, real-data suitability, or scientific interpretation. The PR must receive green cross-platform CI before merge.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/history/records/2026/07/LK-20260726-runtime-diagnostics-and-workflow-repair.html b/site/history/records/2026/07/LK-20260726-runtime-diagnostics-and-workflow-repair.html new file mode 100644 index 000000000..d9f21b7a7 --- /dev/null +++ b/site/history/records/2026/07/LK-20260726-runtime-diagnostics-and-workflow-repair.html @@ -0,0 +1,94 @@ + + + + + + + +Runtime diagnostics and workflow repair converge on one product path - LabKit MATLAB Workbench + + + +
    +
    + +LabKit MATLAB Workbench + +
    +
    + +
    + +
    +

    history

    +

    Runtime diagnostics and workflow repair converge on one product path

    +
    id: LK-20260726-runtime-diagnostics-and-workflow-repair
    +date: 2026-07-26
    +sequence: 159
    +type: feat
    +compatibility: breaking
    +component: `labkit_launcher` | `1.6.0 -> 1.7.0`
    +component: `labkit.app` | `1.2.4 -> 2.0.0`
    +component: `labkit.dta` | `2.0.3 -> 3.0.0`
    +component: `labkit.image` | `2.0.2 -> 2.0.3`
    +component: `labkit_DICPostprocess_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_DICPreprocess_app` | `1.6.1 -> 1.7.0`
    +component: `labkit_ChronoOverlay_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_CIC_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_CSC_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_EIS_app` | `1.5.2 -> 1.6.0`
    +component: `labkit_VTResistance_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_GaitAnalysis_app` | `2.1.1 -> 2.2.0`
    +component: `labkit_BatchImageCrop_app` | `1.8.1 -> 1.9.0`
    +component: `labkit_CurvatureMeasurement_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_FLIRThermal_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_FocusStack_app` | `1.6.1 -> 1.7.0`
    +component: `labkit_ImageEnhance_app` | `1.7.1 -> 1.8.0`
    +component: `labkit_ImageMatch_app` | `1.7.1 -> 1.8.0`
    +component: `labkit_VideoMarker_app` | `1.6.1 -> 1.7.0`
    +component: `labkit_FigureStudio_app` | `0.6.5 -> 0.7.0`
    +component: `labkit_NerveResponseAnalysis_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_ResponseReviewStats_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_RHSPreview_app` | `1.5.1 -> 1.6.0`
    +component: `labkit_TTestWizard_app` | `1.2.0 -> 1.3.0`
    +component: `labkit_ECGPrint_app` | `1.5.1 -> 1.6.0`
    +scope: Always-on session diagnostics and semantic App events
    +scope: Clean startup and explicit synthetic-input generation
    +scope: Launcher repair boundary and preserved product interaction
    +scope: Batch Crop native ROI interaction and preview resolution
    +scope: Explicit DTA units and EIS impedance display units
    +

    Context

    +

    Debug launching had combined synthetic data, diagnostic persistence, and App startup into one mode, while ordinary incidents often left too little useful history. App-owned Log tabs and status/error adapters competed with a partial framework recorder. Batch Crop also exposed the practical consequence of testing construction without native interaction: its ROI could be visible yet fail to respond, and ordinary images were unnecessarily downsampled.

    +

    The root Launcher had simultaneously accumulated installed-product behavior, repair implementation, and maintenance-tool logic in one rescue file. DTA inputs also carried ambiguous unit inference into downstream electrochemistry workflows.

    +

    Decision and rationale

    +

    Every normal App launch now owns one clean Runtime session with an always-on, privacy-bounded event journal. Runtime instruments framework operations; Apps add stable semantic events through CallbackContext.log. Trace capture, session viewing, and bundle export are runtime tools, while synthetic input generation is a separate explicit developer action that never loads its output or changes the open project.

    +

    The public App SDK advances to 2.0 because launch diagnostic options and the old status, error, checkpoint, count, recorder, and debug-sample contracts were removed rather than kept as misleading compatibility aliases. Each migrated App requires labkit.app >=2 <3.

    +

    The root Launcher remains a small self-contained repair entry. The installed launcher keeps the established catalog, status, App information, double-click launch, version browsing, and visual identity, while independent maintenance operations live under tools/.

    +

    Changes

    +
      +
    • Added canonical session events, durable bounded journals, active-session leases, degradation reporting, operation/result versus rollback semantics, a live session viewer, trace control, and diagnostic bundle export.
    • +
    • Migrated all public Apps to semantic user/developer events, removed duplicate Log tabs, and exposed explicit anonymous synthetic-input generation from the ordinary Tools menu.
    • +
    • Kept default launches data-free and action-free; hidden GUI tests now own their visibility fixture instead of relying on a test tag.
    • +
    • Restored Batch Crop ROI body drag, center drag, and click-to-place behavior through managed native interaction, while preserving the viewport and using full-resolution previews unless an explicit high-resolution budget applies.
    • +
    • Made DTA item units explicit, rejected unknown pulse modes, and added EIS impedance display choices from milliohms through megohms with kilohms as the default.
    • +
    • Split Launcher rescue, installed composition, version management, cleanup, and documentation responsibilities without removing product capabilities.
    • +
    +

    User and data impact

    +

    Users can diagnose a problem after it occurs in an ordinary session, export a sanitized history, and deliberately generate anonymous reproduction inputs without restarting in a special mode. App status remains concise, while developer detail is available through Diagnostics. Existing scientific source files are not rewritten.

    +

    Batch Crop keeps source resolution for ordinary images and provides responsive native ROI editing. EIS changes only display and export scaling selected by the user; calculations retain canonical ohms. Launcher repair remains explicit and never downloads or replaces files merely because it was opened.

    +

    Compatibility and migration

    +

    labkit.app 2.0 intentionally removes the former launch/debug and diagnostic adapter APIs. App source using those APIs must migrate to BuildSyntheticSample and semantic CallbackContext.log events. Saved App project schemas and their declared migrations are unchanged.

    +

    labkit.dta 3.0 replaces ambiguous numeric unit inference with explicit unit metadata. Consumers must use declared canonical values. labkit.image 2.0.3 removes library-owned default megapixel policy; Apps choose preview budgets according to their own workflow.

    +

    Validation

    +

    Focused framework suites cover event schema, RNG preservation, operation outcomes, multi-process leases, privacy allowlists, projection degradation, viewer behavior, bundle export, Runtime instrumentation, and repository architecture. All 63 public hidden native App smoke cases pass. Every migrated App family has focused workflow coverage, including Batch Crop native ROI and preview-resolution contracts, EIS units, DTA parity, Launcher dispatch/repair, and synthetic-input state neutrality.

    +

    Evidence

    +

    The debug-repair branch contains purpose-based implementation and focused-test commits. Final evidence is the branch-review validation result, pull-request CI, merged-main CI, and developer-led manual checks recorded in the pull request.

    +

    Known limitations and follow-up

    +

    Hidden GUI tests do not establish pointer feel, dialog usability, visual quality, or the usefulness and privacy of exported incident bundles. Those remain explicit manual acceptance gates before merge.

    +
    Generated from tracked Markdown and MATLAB source contracts.
    +
    +
    + + + + \ No newline at end of file diff --git a/site/libraries/dta/index.html b/site/libraries/dta/index.html index f8e2a2a01..113d114c0 100644 --- a/site/libraries/dta/index.html +++ b/site/libraries/dta/index.html @@ -72,8 +72,9 @@

    Pulse Detection

    The default mode, "Metadata first, then auto", first looks for negative and positive ISTEP/TSTEP metadata. If current-step metadata is absent, it can use VSTEP/TSTEP timing. If metadata detection fails, it locates dominant negative and positive segments in the measured current.

    [pulse, message] = labkit.dta.detectPulses( ...
         item.t_s, item.Im_A, item.meta, "Metadata first, then auto");
    -

    Other modes are "Metadata only" and "Auto from Im only". Programmatic aliases "metadata_first", "metadata_only", and "current_only" are also accepted. Current-based detection uses 25 percent of max(abs(Im)) as its threshold, with a 1e-12 A floor, then selects the longest cathodic segment and the longest later anodic segment.

    -

    On success, pulse.cath, pulse.gap, and pulse.anod provide times in seconds and currents in amperes. pulse.ok is false and numeric fields are NaN when a valid cathodic/anodic pair cannot be found. Always check pulse.ok before using the windows in a calculation.

    +

    Other modes are "Metadata only" and "Auto from Im only". Programmatic aliases "metadata_first", "metadata_only", and "current_only" are also accepted. Current-based detection uses 25 percent of max(abs(Im)) as its threshold, with a 1e-12 A floor, then selects the longest cathodic segment and the longest later anodic segment. An unknown mode returns pulse.ok=false with an explanatory message; it is never silently treated as the default.

    +

    On success, pulse.pre, pulse.cath, pulse.gap, pulse.anod, and pulse.post provide unit-explicit start_s and end_s windows. pulse.cath.current_A and pulse.anod.current_A contain nominal or measured phase currents, and pulse.gap.center_s contains the alignment center. pulse.ok is false and numeric fields are NaN when a valid cathodic/anodic pair cannot be found. Always check pulse.ok before using the windows in a calculation.

    +

    Version 3 returns only this unit-explicit item and pulse model. Chrono items use t_s, Vf_V, and Im_A; EIS items use the fields listed below. Earlier unit-ambiguous aliases are not emitted.

    EIS Data

    An EIS item selects the ZCURVE table, or the first compatible table with Freq, Zreal, and Zimag columns. It exposes these unit-explicit vectors:

    FieldMeaning
    freq_HzFrequency in hertz
    time_sElapsed time in seconds
    pointPoint number
    Zreal_ohmReal impedance in ohms
    Zimag_ohmImaginary impedance in ohms
    negZimag_ohmNegative imaginary impedance in ohms
    Zmod_ohmImpedance magnitude in ohms
    Zphz_degPhase angle in degrees
    Idc_ADC current in amperes
    Vdc_VDC voltage in volts
    @@ -103,7 +104,7 @@
  • CIC, CSC, and EIS use DTA records in interactive workflows.
  • Contract functions explain how apps declare a compatible labkit.dta API version.
  • Project history lists parser, schema, and compatibility changes.
  • -

    Change history

    +

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/libraries/image/index.html b/site/libraries/image/index.html index cd906d003..d54543cec 100644 --- a/site/libraries/image/index.html +++ b/site/libraries/image/index.html @@ -35,7 +35,7 @@

    Common Calls

    preview = min(max(preview, 0), 1); luma = labkit.image.rgb2gray(preview); [preview, scale] = labkit.image.resizeToFit(preview, "MaxHeight", 1500); -[preview, budget] = labkit.image.previewBudget(preview, "MaxPixels", 1.2e6); +[preview, budget] = labkit.image.previewBudget(preview, "MaxPixels", appPreviewBudget); blurred = labkit.image.meanFilter2(preview(:, :, 1), 7); enhanced = labkit.image.adjustBrightnessContrast(preview, 10, 20); @@ -48,6 +48,7 @@

    Normalization Helpers

    labkit.image.im2double follows the MATLAB im2double call contract for supported numeric image classes, including the optional "indexed" mode.

    labkit.image.rgb2gray follows the MATLAB rgb2gray call contract for RGB images and colormaps while using the documented Rec.601 luma transform.

    labkit.image.ensureRgb changes channel shape only. It expands grayscale data to three channels or drops channels after RGB without changing class or sample values. Callers that need display-ready RGB data explicitly compose im2double, ensureRgb, and [0, 1] clamping as shown above.

    +

    labkit.image.previewBudget preserves native pixels by default. An App passes an explicit finite "MaxPixels" value only when its own workflow permits sampling; the resulting integer coordinate scale is returned with the preview.

    Provided Operations

    The module provides:

      @@ -55,7 +56,7 @@

      Provided Operations

    • path normalization and display names
    • imread/imwrite wrappers that normalize app-facing edge behavior
    • MATLAB-compatible image conversion, explicit RGB shaping, preview-size fitting, and edge-normalized mean filtering
    • -
    • display-pixel budget helpers for responsive previews while preserving a documented integer coordinate scale
    • +
    • caller-owned display-pixel budget helpers for responsive previews while preserving a documented integer coordinate scale
    • generic image enhancement primitives such as brightness/contrast, HSV hue/saturation, gray-world white balance, local contrast, and sharpening

    Applications add:

    @@ -67,7 +68,7 @@

    Provided Operations

    Use labkit.image.readFiles when an app needs generic source-image records. Apps may copy the returned path, name, and image fields into their own item structures. Specialized formats and result structures remain documented by the app that uses them.

    Reference Contract

    -

    The generated Image API pages linked from the public API index document exact syntax, inputs, outputs, implemented options and defaults, legal values, failure behavior, examples, and related functions. In particular, resizeToFit accepts only the documented "bilinear" and "nearest" methods and rejects any other method instead of silently selecting an interpolation policy.

    Change history

    +

    The generated Image API pages linked from the public API index document exact syntax, inputs, outputs, implemented options and defaults, legal values, failure behavior, examples, and related functions. In particular, resizeToFit accepts only the documented "bilinear" and "nearest" methods and rejects any other method instead of silently selecting an interpolation policy.

    Change history

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/CallbackContext.html b/site/reference/api/labkit/app/CallbackContext.html index 468108b3a..4186635bf 100644 --- a/site/reference/api/labkit/app/CallbackContext.html +++ b/site/reference/api/labkit/app/CallbackContext.html @@ -18,18 +18,14 @@
    - +

    reference

    FrameworkApp SDK API

    labkit.app.CallbackContext

    Provide declared App-neutral runtime capabilities.

    Syntax

    -
    labkit.app.CallbackContext.appendStatus(context, message) -context.appendStatus(message) -context.reportError(operation, exception) -context.diagnosticCheckpoint(id) -context.diagnosticCount(id, count) +
    context.log(severity, eventName, message, Name=Value) context.alert(message, title) result = context.chooseOption(prompt, choices, Name=Value) result = context.chooseInputFile(filters, startPath) @@ -48,7 +44,7 @@

    Syntax

    context.clearResourceScope(scope) result = context.writeResultPackage(folder, result)

    Description

    CallbackContext is the sealed callback capability boundary. Each specifically named method invokes one private runtime operation. Apps receive this value only as a callback argument and never construct it. It exposes no figure, registry, component, launch request, debug object, or nested service bag.

    -

    Inputs

    state
    Complete App-owned state value.
    message
    Scalar reader-facing text.
    operation
    Scalar diagnostic operation text.
    exception
    Scalar MException.
    id
    Stable semantic diagnostic or resource identifier.
    count
    Nonnegative integer diagnostic count.
    title
    Scalar reader-facing dialog title.
    prompt
    Scalar reader-facing choice prompt.
    choices
    Row string or cellstr array.
    Title
    Reader-facing choice-dialog title. Default: "Choose an option".
    DefaultChoice
    Choice selected by pressing Enter. Default: the first choice.
    CancelChoice
    Choice returned when the dialog is dismissed. Default: the first choice.
    filters
    Runtime-supported file-dialog filter value.
    startPath
    Scalar starting file or folder path.
    filepath
    Scalar project or recovery source or destination path.
    sources
    Runtime-owned portable source collection.
    ids
    Optional source identifiers to resolve.
    scope
    "event", "interaction", "document", or "application".
    value
    App-neutral resource value.
    cleanup
    Empty or fixed callback cleanup(value).
    folder
    Scalar result-package folder.
    result
    labkit.app.result.Package value for writeResultPackage.
    +

    Inputs

    state
    Complete App-owned state value.
    message
    Scalar reader-facing text.
    severity
    Log severity from "trace" through "critical".
    eventName
    Stable semantic event identifier.
    Category
    Semantic App capability category. Default: "workflow".
    Audience
    "user" or "developer"; default: "user".
    Attributes
    Scalar privacy-safe structured details. Default: struct().
    Exception
    Scalar MException associated with the event. Default: [].
    id
    Stable semantic diagnostic or resource identifier.
    count
    Nonnegative integer diagnostic count.
    title
    Scalar reader-facing dialog title.
    prompt
    Scalar reader-facing choice prompt.
    choices
    Row string or cellstr array.
    Title
    Reader-facing choice-dialog title. Default: "Choose an option".
    DefaultChoice
    Choice selected by pressing Enter. Default: the first choice.
    CancelChoice
    Choice returned when the dialog is dismissed. Default: the first choice.
    filters
    Runtime-supported file-dialog filter value.
    startPath
    Scalar starting file or folder path.
    filepath
    Scalar project or recovery source or destination path.
    sources
    Runtime-owned portable source collection.
    ids
    Optional source identifiers to resolve.
    scope
    "event", "interaction", "document", or "application".
    value
    App-neutral resource value.
    cleanup
    Empty or fixed callback cleanup(value).
    folder
    Scalar result-package folder.
    result
    labkit.app.result.Package value for writeResultPackage.

    Outputs

    state
    Complete restored App state prepared for the current runtime transaction.
    result
    labkit.app.dialog.Choice for dialogs, project saves, and result writing.
    paths
    Column string array of resolved source paths.
    value
    Stored resource value.

    Errors

    labkit:app:contract:InvalidValue
    A public argument is malformed.
    labkit:app:runtime:InvariantFailure
    A private backend operation is unavailable.

    Typical Call

    function state = runAnalysis(state,event,callbackContext)
    @@ -57,9 +53,10 @@ 

    Syntax

    event callbackContext (1,1) labkit.app.CallbackContext end -callbackContext.appendStatus("Analysis started."); +callbackContext.log("info", "analysis.started", ... +"Analysis started."); end
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/CallbackContext.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/Definition.html b/site/reference/api/labkit/app/Definition.html index 9ae9c8161..b472c5fd4 100644 --- a/site/reference/api/labkit/app/Definition.html +++ b/site/reference/api/labkit/app/Definition.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -31,14 +31,13 @@

    Syntax

    Requirements=requirements, Workbench=workbench, Name=Value) fig = app.launch() fig = app.launch(InitialProject=project) -fig = app.launch(Diagnostics=diagnosticOptions) requirements = app.launch("requirements") version = app.launch("version")

    Description

    Definition validates product metadata, layout-owned callbacks and renderers, global IDs, callback roles, and references in one atomic constructor. The static target graph is cached once. validateViewSnapshot checks a complete view snapshot against that graph without rebuilding the layout.

    Required Name-Value Arguments

    Entrypoint
    Public MATLAB launch function name as a scalar identifier.
    AppId
    Stable App identifier beginning with an ASCII letter and containing letters, digits, underscore, hyphen, or period.
    Title
    Nonempty reader-facing scalar text.
    Family
    Nonempty reader-facing scalar text.
    AppVersion
    Semantic version in X.Y.Z form.
    Updated
    Product date in YYYY-MM-DD form.
    Requirements
    Empty value or labkit.contract.requirements result.
    Workbench
    Root value returned by labkit.app.layout.workbench.
    -

    Optional Name-Value Arguments

    DisplayName
    Nonempty scalar text. Default: Title.
    ProjectSchema
    labkit.app.project.Schema or empty for a static App. Default: empty.
    CreateSession
    Fixed callback session = callback(project,context). Portable project sources remain opaque; use context.resolveSourcePaths while rebuilding transient session data. Default: empty.
    PresentWorkbench
    Fixed callback view = callback(state). Default: empty.
    OnStart
    Fixed callback state = callback(state,context), invoked after the first view commit. Default: empty.
    BuildDebugSample
    Fixed callback pack = callback(context). Default: empty.
    +

    Optional Name-Value Arguments

    DisplayName
    Nonempty scalar text. Default: Title.
    ProjectSchema
    labkit.app.project.Schema or empty for a static App. Default: empty.
    CreateSession
    Fixed callback session = callback(project,context). Portable project sources remain opaque; use context.resolveSourcePaths while rebuilding transient session data. Default: empty.
    PresentWorkbench
    Fixed callback view = callback(state). Default: empty.
    OnStart
    Fixed callback state = callback(state,context), invoked after the first view commit. Default: empty.
    BuildSyntheticSample
    Fixed callback pack = callback(context). Default: empty.

    Outputs

    app
    Immutable compiled labkit.app.Definition value.
    -

    Definition Methods

    launch()
    Build and show the native MATLAB App figure.
    launch(Diagnostics=options)
    Use one labkit.app.diagnostic.Options value for standard or verbose sanitized runtime recording.
    launch("requirements")
    Return declared facade requirements without creating a figure.
    launch("version")
    Return product version metadata without creating a figure.
    validateViewSnapshot(view)
    Validate target references, target capabilities and complete target coverage. Returns true or throws before any runtime UI mutation.
    +

    Definition Methods

    launch()
    Build and show the native MATLAB App figure.
    launch("requirements")
    Return declared facade requirements without creating a figure.
    launch("version")
    Return product version metadata without creating a figure.
    validateViewSnapshot(view)
    Validate target references, target capabilities and complete target coverage. Returns true or throws before any runtime UI mutation.

    Errors

    labkit:app:contract:UnknownArgument
    A required argument is missing or an argument is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Metadata, requirements, workbench, callbacks, or renderers are malformed.
    labkit:app:contract:DuplicateId
    A layout ID is duplicated.
    labkit:app:contract:UnknownReference
    A view target is undeclared.
    labkit:app:contract:UnsupportedOperation
    A view operation is not legal for its target.
    labkit:app:contract:InvalidValue
    A launch request or output count is unsupported.

    Typical Call

    workbench = labkit.app.layout.workbench({ ...
     labkit.app.layout.button("run", "Run", @runAnalysis, ...
    @@ -47,7 +46,7 @@ 

    Syntax

    Entrypoint="labkit_Example_app", AppId="example.app", ... Title="Example", Family="Examples", AppVersion="1.0.0", ... Updated="2026-07-19", Requirements=[], Workbench=workbench);
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/Definition.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/diagnostic/Options.html b/site/reference/api/labkit/app/diagnostic/Options.html deleted file mode 100644 index 94470d08b..000000000 --- a/site/reference/api/labkit/app/diagnostic/Options.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - -labkit.app.diagnostic.Options - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    FrameworkApp SDK API

    -

    labkit.app.diagnostic.Options

    -

    Configure one App SDK diagnostic session.

    -

    Syntax

    -
    options = labkit.app.diagnostic.Options() -options = labkit.app.diagnostic.Options(Name=Value)
    -

    Description

    Options selects standard or verbose runtime recording and whether a Definition should build its declared anonymous synthetic sample. Omitting Diagnostics from Definition.launch is equivalent to the default standard options. This value never exposes a runtime, recorder, figure registry, or callback transport.

    -

    Optional Name-Value Arguments

    Level
    "standard" or "verbose". Standard keeps a bounded in-memory diagnostic history; verbose additionally writes structured artifacts when ArtifactFolder is nonempty. Default: "standard".
    ArtifactFolder
    Scalar diagnostic-session folder. Empty keeps recording in memory only. Default: "".
    Sample
    "none" or "synthetic". Synthetic requires the Definition's BuildDebugSample contract. Default: "none".
    -

    Outputs

    options
    Immutable diagnostic configuration.
    -

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    A supplied value is malformed or outside its documented legal set.
    -

    Example

    options = labkit.app.diagnostic.Options(Level="verbose");
    -assert(options.Level == "verbose")
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/Options.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/app/interaction/rectangle.html b/site/reference/api/labkit/app/interaction/rectangle.html index e9bb0c6f0..1ff781fdb 100644 --- a/site/reference/api/labkit/app/interaction/rectangle.html +++ b/site/reference/api/labkit/app/interaction/rectangle.html @@ -28,7 +28,7 @@

    Syntax

    spec = labkit.app.interaction.rectangle(id,onChanged,Name=Value)

    Description

    Creates the semantic declaration for a persistent editable rectangle, including an optional background-point callback on the same axis.

    Inputs

    id
    Unique MATLAB identifier.
    onChanged
    Callback state = callback(state,position,context).
    -

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    OnBackgroundPressed
    Optional callback state = callback(state,point,context). Default: [].
    +

    Options

    Axis
    Axis ID. Default: "main".
    Style
    Scalar visual-option struct. Default: struct().
    Instruction
    Scalar guidance text. Default: "".
    ViewportPolicy
    "preserve" or "fit". Default: "preserve".
    OnBackgroundPressed
    Optional point callback state = callback(state,point,context). It receives clicks on blank plot space and clicks on the rectangle that do not move it. Default: [].

    Outputs

    spec
    Immutable interaction declaration.

    Errors

    Throws labkit:app:contract:* for invalid values.

    Typical Call

    spec = labkit.app.interaction.rectangle("crop",@moveCrop);
    diff --git a/site/reference/api/labkit/app/layout/button.html b/site/reference/api/labkit/app/layout/button.html index 3833d7991..ccc9d88f9 100644 --- a/site/reference/api/labkit/app/layout/button.html +++ b/site/reference/api/labkit/app/layout/button.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -33,7 +33,7 @@

    Syntax

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or handlers.

    Typical Call

    node = labkit.app.layout.button("run", "Run", @runAnalysis, ...
     Tooltip="Compute the current analysis from the selected inputs.");
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/button.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/dataTable.html b/site/reference/api/labkit/app/layout/dataTable.html index bcfc3b282..402bbb960 100644 --- a/site/reference/api/labkit/app/layout/dataTable.html +++ b/site/reference/api/labkit/app/layout/dataTable.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by layout containers.

    Errors

    Throws labkit:app:contract:* for invalid options or callback signatures.

    Typical Call

    node = labkit.app.layout.dataTable("results", Columns=["Name" "Value"]);
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/dataTable.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/field.html b/site/reference/api/labkit/app/layout/field.html index 5aa676a76..c1b7df6b9 100644 --- a/site/reference/api/labkit/app/layout/field.html +++ b/site/reference/api/labkit/app/layout/field.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -33,7 +33,7 @@

    Syntax

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or handlers.

    Typical Call

    node = labkit.app.layout.field("gain", Kind="numeric", ...
     Bind="project.parameters.gain");
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/field.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/fileList.html b/site/reference/api/labkit/app/layout/fileList.html index 3945ff689..97fdffb21 100644 --- a/site/reference/api/labkit/app/layout/fileList.html +++ b/site/reference/api/labkit/app/layout/fileList.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -34,7 +34,7 @@

    Syntax

    Typical Call

    node = labkit.app.layout.fileList("files", ...
     Bind="project.inputs.sources", ...
     ChooseTooltip="Choose calibrated source images for this analysis.");
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/fileList.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/group.html b/site/reference/api/labkit/app/layout/group.html index 7cc0c6c65..75e1e3527 100644 --- a/site/reference/api/labkit/app/layout/group.html +++ b/site/reference/api/labkit/app/layout/group.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by containers.

    Errors

    Throws labkit:app:contract:* for invalid IDs, children, or options.

    Typical Call

    node = labkit.app.layout.group("inputs", {gainField});
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/group.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/logPanel.html b/site/reference/api/labkit/app/layout/logPanel.html deleted file mode 100644 index 793915f0f..000000000 --- a/site/reference/api/labkit/app/layout/logPanel.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - -labkit.app.layout.logPanel - LabKit MATLAB Workbench - - - -
    -
    - -LabKit MATLAB Workbench - -
    -
    - -
    - -
    -

    reference

    -

    FrameworkApp SDK API

    -

    labkit.app.layout.logPanel

    -

    Add a text display for App log messages.

    -

    Syntax

    -
    node = labkit.app.layout.logPanel(id, Name=Value)
    -

    Description

    Declares a runtime-populated multiline App log display.

    -

    Inputs

    id
    Unique MATLAB identifier for the layout target.
    -

    Options

    Title
    Visible panel title. Default: "Log".
    -

    Outputs

    node
    Immutable internal layout node accepted by containers.
    -

    Errors

    Throws labkit:app:contract:InvalidValue for an invalid ID.

    -

    Typical Call

    node = labkit.app.layout.logPanel("appLog");
    - -

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+app/+layout/logPanel.m.

    -
    Generated from tracked Markdown and MATLAB source contracts.
    -
    -
    - - - - \ No newline at end of file diff --git a/site/reference/api/labkit/app/layout/plotArea.html b/site/reference/api/labkit/app/layout/plotArea.html index 0293de681..8417bd0b8 100644 --- a/site/reference/api/labkit/app/layout/plotArea.html +++ b/site/reference/api/labkit/app/layout/plotArea.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    diff --git a/site/reference/api/labkit/app/layout/rangeField.html b/site/reference/api/labkit/app/layout/rangeField.html index a9c20af9c..73bd66d1a 100644 --- a/site/reference/api/labkit/app/layout/rangeField.html +++ b/site/reference/api/labkit/app/layout/rangeField.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by layout containers.

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or callbacks.

    Typical Call

    node = labkit.app.layout.rangeField("window", Limits=[0 10]);
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/rangeField.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/section.html b/site/reference/api/labkit/app/layout/section.html index e110b0550..77eb6b0e4 100644 --- a/site/reference/api/labkit/app/layout/section.html +++ b/site/reference/api/labkit/app/layout/section.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by containers.

    Errors

    Throws labkit:app:contract:* for invalid IDs, children, or options.

    Typical Call

    node = labkit.app.layout.section("inputs", "Inputs", {gainField});
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/section.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/slider.html b/site/reference/api/labkit/app/layout/slider.html index baaff8f85..d12090271 100644 --- a/site/reference/api/labkit/app/layout/slider.html +++ b/site/reference/api/labkit/app/layout/slider.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by layout containers.

    Errors

    Throws labkit:app:contract:* for invalid IDs, options, or callbacks.

    Typical Call

    node = labkit.app.layout.slider("frame", Limits=[1 100], Step=1);
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/slider.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/statusPanel.html b/site/reference/api/labkit/app/layout/statusPanel.html index 0e701d64b..6efafe4b5 100644 --- a/site/reference/api/labkit/app/layout/statusPanel.html +++ b/site/reference/api/labkit/app/layout/statusPanel.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by containers.

    Errors

    Throws labkit:app:contract:InvalidValue for an invalid ID.

    Typical Call

    node = labkit.app.layout.statusPanel("status");
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/statusPanel.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/tab.html b/site/reference/api/labkit/app/layout/tab.html index 5355bd8db..9d8d5d2cc 100644 --- a/site/reference/api/labkit/app/layout/tab.html +++ b/site/reference/api/labkit/app/layout/tab.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -31,7 +31,7 @@

    Syntax

    Outputs

    node
    Immutable internal layout node accepted by workbench.

    Errors

    Throws labkit:app:contract:* for invalid IDs, titles, or children.

    Typical Call

    node = labkit.app.layout.tab("settings", "Settings", {gainField});
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/tab.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/workbench.html b/site/reference/api/labkit/app/layout/workbench.html index 4fe43577b..d6ff6ac69 100644 --- a/site/reference/api/labkit/app/layout/workbench.html +++ b/site/reference/api/labkit/app/layout/workbench.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -32,7 +32,7 @@

    Syntax

    Outputs

    node
    Immutable root layout node.

    Errors

    Throws labkit:app:contract:* for invalid children or workspace values.

    Typical Call

    node = labkit.app.layout.workbench({runButton});
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/workbench.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/layout/workspace.html b/site/reference/api/labkit/app/layout/workspace.html index 5468baa7f..caad2243b 100644 --- a/site/reference/api/labkit/app/layout/workspace.html +++ b/site/reference/api/labkit/app/layout/workspace.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -33,7 +33,7 @@

    Syntax

    Outputs

    node
    Immutable workspace node accepted by layout.workbench.

    Errors

    Throws labkit:app:contract:* for invalid content, pages, or callbacks.

    Typical Call

    node = labkit.app.layout.workspace(plotArea);
    - +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/+layout/workspace.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/diagnostic/Artifact.html b/site/reference/api/labkit/app/synthetic/Artifact.html similarity index 63% rename from site/reference/api/labkit/app/diagnostic/Artifact.html rename to site/reference/api/labkit/app/synthetic/Artifact.html index 6b3763eb0..578059264 100644 --- a/site/reference/api/labkit/app/diagnostic/Artifact.html +++ b/site/reference/api/labkit/app/synthetic/Artifact.html @@ -5,7 +5,7 @@ -labkit.app.diagnostic.Artifact - LabKit MATLAB Workbench +labkit.app.synthetic.Artifact - LabKit MATLAB Workbench @@ -18,26 +18,26 @@
    - +

    reference

    FrameworkApp SDK API

    -

    labkit.app.diagnostic.Artifact

    -

    Describe one anonymous diagnostic-sample artifact.

    +

    labkit.app.synthetic.Artifact

    +

    Describe one anonymous synthetic-input artifact.

    Syntax

    -
    artifact = labkit.app.diagnostic.Artifact( ... +
    artifact = labkit.app.synthetic.Artifact( ... id,role,relativePath,Name=Value)
    -

    Description

    Identifies one synthetic input, expected export, or support file beneath a diagnostic sample root without exposing a user file path.

    -

    Inputs

    id
    Nonempty semantic identifier unique within a SamplePack.
    role
    Nonempty App-owned artifact purpose.
    relativePath
    Nonempty diagnostic-root-relative path without traversal.
    +

    Description

    Identifies one synthetic input, expected export, or support file beneath a synthetic-input root without exposing a user file path.

    +

    Inputs

    id
    Nonempty semantic identifier unique within a Pack.
    role
    Nonempty App-owned artifact purpose.
    relativePath
    Nonempty synthetic-root-relative path without traversal.

    Optional Name-Value Arguments

    Expectation
    "loads", "rejects", "exports", or "support". Default: "loads".
    -

    Outputs

    artifact
    Immutable diagnostic artifact value.
    +

    Outputs

    artifact
    Immutable synthetic-input artifact value.

    Errors

    labkit:app:contract:UnknownArgument
    An option is unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Text, path, or Expectation is malformed.
    -

    Example

    artifact = labkit.app.diagnostic.Artifact( ...
    +

    Example

    artifact = labkit.app.synthetic.Artifact( ...
     "input","source","samples/input.csv");
     assert(artifact.Expectation == "loads")
    - +

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/Artifact.m.

    +

    This page is generated from the MATLAB help text in +labkit/+app/+synthetic/Artifact.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/diagnostic/SampleContext.html b/site/reference/api/labkit/app/synthetic/Context.html similarity index 51% rename from site/reference/api/labkit/app/diagnostic/SampleContext.html rename to site/reference/api/labkit/app/synthetic/Context.html index 97b98e8c7..cd25bb26a 100644 --- a/site/reference/api/labkit/app/diagnostic/SampleContext.html +++ b/site/reference/api/labkit/app/synthetic/Context.html @@ -5,7 +5,7 @@ -labkit.app.diagnostic.SampleContext - LabKit MATLAB Workbench +labkit.app.synthetic.Context - LabKit MATLAB Workbench @@ -18,27 +18,27 @@
    - +

    reference

    FrameworkApp SDK API

    -

    labkit.app.diagnostic.SampleContext

    -

    Provide bounded folders for anonymous debug samples.

    +

    labkit.app.synthetic.Context

    +

    Provide bounded folders for anonymous synthetic inputs.

    Syntax

    -
    context = labkit.app.diagnostic.SampleContext(artifactFolder) +
    context = labkit.app.synthetic.Context(rootFolder) filepath = context.samplePath(relativePath) filepath = context.outputPath(relativePath) record = context.sourceRecord(id,role,filepath,required) artifact = context.artifact(id,role,filepath,Name=Value)
    -

    Description

    SampleContext creates one diagnostic root with samples and outputs children. App-owned BuildDebugSample callbacks may write only anonymous synthetic files beneath these folders. The value does not expose a runtime, recorder, project store, or UI object.

    -

    Inputs

    artifactFolder
    Nonempty scalar diagnostic-session folder.
    relativePath
    Nonempty child path without an absolute root, empty segment, current segment, or parent traversal.
    id
    Stable portable-source identifier.
    role
    Stable portable-source role.
    filepath
    Path returned by samplePath.
    required
    Logical scalar source requirement.
    -

    Outputs

    context
    Immutable diagnostic sample context.
    filepath
    Absolute path bounded by SampleFolder or OutputFolder.
    record
    Portable source value from labkit.app.project.sourceRecord.
    artifact
    Typed diagnostic artifact whose relative path is derived from a filepath beneath ArtifactFolder.
    -

    Errors

    labkit:app:contract:InvalidValue
    A folder, relative path, or source argument is malformed. MATLAB filesystem errors propagate when the diagnostic folders cannot be created.
    -

    Typical Call

    context = labkit.app.diagnostic.SampleContext(tempname);
    +

    Description

    Context creates one synthetic-input root with samples and outputs children. App-owned BuildSyntheticSample callbacks may write only anonymous synthetic files beneath these folders. The value does not expose a runtime, recorder, project store, or UI object.

    +

    Inputs

    rootFolder
    Nonempty scalar folder dedicated to one generated pack.
    relativePath
    Nonempty child path without an absolute root, empty segment, current segment, or parent traversal.
    id
    Stable portable-source identifier.
    role
    Stable portable-source role.
    filepath
    Path returned by samplePath.
    required
    Logical scalar source requirement.
    +

    Outputs

    context
    Immutable synthetic-input context.
    filepath
    Absolute path bounded by SampleFolder or OutputFolder.
    record
    Portable source value from labkit.app.project.sourceRecord.
    artifact
    Typed synthetic artifact whose relative path is derived from a filepath beneath RootFolder.
    +

    Errors

    labkit:app:contract:InvalidValue
    A folder, relative path, or source argument is malformed. MATLAB filesystem errors propagate when the synthetic folders cannot be created.
    +

    Typical Call

    context = labkit.app.synthetic.Context(tempname);
     filepath = context.samplePath("input.csv");
    - +

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/SampleContext.m.

    +

    This page is generated from the MATLAB help text in +labkit/+app/+synthetic/Context.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/diagnostic/SamplePack.html b/site/reference/api/labkit/app/synthetic/Pack.html similarity index 66% rename from site/reference/api/labkit/app/diagnostic/SamplePack.html rename to site/reference/api/labkit/app/synthetic/Pack.html index ad87ec74d..e0d1cba06 100644 --- a/site/reference/api/labkit/app/diagnostic/SamplePack.html +++ b/site/reference/api/labkit/app/synthetic/Pack.html @@ -5,7 +5,7 @@ -labkit.app.diagnostic.SamplePack - LabKit MATLAB Workbench +labkit.app.synthetic.Pack - LabKit MATLAB Workbench @@ -18,28 +18,28 @@
    - +

    reference

    FrameworkApp SDK API

    -

    labkit.app.diagnostic.SamplePack

    +

    labkit.app.synthetic.Pack

    Describe one typed anonymous App reproduction scenario.

    Syntax

    -
    pack = labkit.app.diagnostic.SamplePack( ... +
    pack = labkit.app.synthetic.Pack( ... Scenario=scenario,InitialProject=project,Artifacts=artifacts)
    -

    Description

    Couples one App-authored synthetic project with its anonymous artifact declarations so verbose diagnostics can reproduce a named scenario.

    -

    Required Name-Value Arguments

    Scenario
    Nonempty stable scenario identifier.
    InitialProject
    Scalar current App project struct.
    Artifacts
    Row cell array of labkit.app.diagnostic.Artifact values. Empty is legal for an App whose scenario needs no files.
    -

    Outputs

    pack
    Immutable diagnostic sample pack.
    +

    Description

    Couples one App-authored synthetic project with its anonymous artifact declarations so users and tests can reproduce a named scenario through the App's ordinary import workflow.

    +

    Required Name-Value Arguments

    Scenario
    Nonempty stable scenario identifier.
    InitialProject
    Scalar current App project struct.
    Artifacts
    Row cell array of labkit.app.synthetic.Artifact values. Empty is legal for an App whose scenario needs no files.
    +

    Outputs

    pack
    Immutable synthetic-input pack.

    Errors

    labkit:app:contract:UnknownArgument
    An argument is missing, unknown, duplicated, or unpaired.
    labkit:app:contract:InvalidValue
    Scenario, project, or Artifacts is malformed.
    labkit:app:contract:DuplicateId
    Artifact IDs or relative paths are duplicated.
    -

    Example

    artifact = labkit.app.diagnostic.Artifact( ...
    +

    Example

    artifact = labkit.app.synthetic.Artifact( ...
     "input","source","samples/input.csv");
    -pack = labkit.app.diagnostic.SamplePack( ...
    +pack = labkit.app.synthetic.Pack( ...
     Scenario="representative",InitialProject=struct(), ...
     Artifacts={artifact});
     assert(pack.Scenario == "representative")
    - +

    Source

    -

    This page is generated from the MATLAB help text in +labkit/+app/+diagnostic/SamplePack.m.

    +

    This page is generated from the MATLAB help text in +labkit/+app/+synthetic/Pack.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/app/version.html b/site/reference/api/labkit/app/version.html index a6e3a9390..d718539fd 100644 --- a/site/reference/api/labkit/app/version.html +++ b/site/reference/api/labkit/app/version.html @@ -18,7 +18,7 @@
    - +

    reference

    FrameworkApp SDK API

    @@ -31,8 +31,8 @@

    Syntax

    Outputs

    info
    Scalar structure returned by labkit.contract.versionInfo with name, current, compatible, status, and notes fields.

    Errors

    labkit:contract:InvalidVersionInfo
    Embedded facade metadata is invalid.

    Example

    info = labkit.app.version();
    -assert(startsWith(info.current, "1."))
    - +assert(startsWith(info.current, "2."))
    +

    Source

    This page is generated from the MATLAB help text in +labkit/+app/version.m.

    Generated from tracked Markdown and MATLAB source contracts.
    diff --git a/site/reference/api/labkit/dta/detectPulses.html b/site/reference/api/labkit/dta/detectPulses.html index 6acee90a6..6ddb12206 100644 --- a/site/reference/api/labkit/dta/detectPulses.html +++ b/site/reference/api/labkit/dta/detectPulses.html @@ -31,7 +31,7 @@

    Syntax

    Inputs

    t
    Numeric time vector in seconds. Values are used in their supplied order and must correspond element-for-element with Im.
    Im
    Numeric current vector in amperes with the same number of elements as t.
    meta
    Chrono metadata structure, usually item.meta from loadFile. A steps field may contain I, V, and T values parsed from ISTEP, VSTEP, and TSTEP metadata. Use struct() when metadata is unavailable.
    mode
    Character vector, string scalar, or structure with a mode field. See Mode Values. Default: "Metadata first, then auto".

    Mode Values

    metadata_first
    Accepts "metadata_first" or "Metadata first, then auto". Uses metadata first and falls back to Im when metadata detection fails. This is the default.
    metadata_only
    Accepts "metadata_only" or "Metadata only". Does not use measured-current fallback.
    current_only
    Accepts "current_only" or "Auto from Im only". Ignores metadata and detects segments from Im. The threshold is 25 percent of max(abs(Im)), with a 1e-12 A numerical floor.

    Outputs

    pulse
    Scalar structure describing the detected windows. pulse.ok is false and numeric window fields are NaN when detection fails.
    message
    Character vector explaining which method succeeded or why detection failed. The same text is stored in pulse.message.
    -

    Output Fields

    ok
    Logical success flag.
    method
    "metadata-current", "metadata-voltage", "auto-from-Im", or "-".
    cath
    Structure with start_s, end_s, and current_A.
    anod
    Structure with start_s, end_s, and current_A.
    gap
    Structure with start_s, end_s, and center_s.
    cath_start
    Cathodic-window start time in seconds.
    cath_end
    Cathodic-window end time in seconds.
    anod_start
    Anodic-window start time in seconds.
    anod_end
    Anodic-window end time in seconds.
    Ic_nominal
    Nominal or median cathodic current in amperes.
    Ia_nominal
    Nominal or median anodic current in amperes.
    pre_start
    Start time of the pre-pulse region in seconds.
    pre_end
    End time of the pre-pulse region in seconds.
    gap_start
    Start time between cathodic and anodic pulses in seconds.
    gap_end
    End time between cathodic and anodic pulses in seconds.
    post_start
    Start time of the post-pulse region in seconds.
    post_end
    End time of the post-pulse region in seconds.
    +

    Output Fields

    ok
    Logical success flag.
    method
    "metadata-current", "metadata-voltage", "auto-from-Im", or "-".
    pre
    Structure with start_s and end_s.
    cath
    Structure with start_s, end_s, and current_A.
    gap
    Structure with start_s, end_s, and center_s.
    anod
    Structure with start_s, end_s, and current_A.
    post
    Structure with start_s and end_s.

    Failure Behavior

    Missing usable metadata, absent opposite-polarity current phases, or unsupported mode text returns pulse.ok=false with NaN window fields and an explanatory message. t and Im must be corresponding numeric vectors; incompatible MATLAB values or lengths may raise the originating indexing or conversion error.

    Example

    t = (0:0.001:0.020).';
     Im = zeros(size(t));
    diff --git a/site/reference/api/labkit/image/previewBudget.html b/site/reference/api/labkit/image/previewBudget.html
    index f1fcb7c73..38a941df8 100644
    --- a/site/reference/api/labkit/image/previewBudget.html
    +++ b/site/reference/api/labkit/image/previewBudget.html
    @@ -27,10 +27,10 @@ 

    labkit.image.previewBudget

    Syntax

    [preview, info] = labkit.image.previewBudget(imageData) [preview, info] = labkit.image.previewBudget(imageData, Name, Value)
    -

    Description

    Produces a lightweight preview by taking every Nth row and column. N is the smallest integer stride whose estimated processing area does not exceed MaxPixels. The estimate is source rows times source columns times Expansion, so callers can reserve memory for workflows that pad, tile, or otherwise enlarge an image before display.

    +

    Description

    When MaxPixels is finite, produces a lightweight preview by taking every Nth row and column. N is the smallest integer stride whose estimated processing area does not exceed MaxPixels. The estimate is source rows times source columns times Expansion, so callers can reserve memory for workflows that pad, tile, or otherwise enlarge an image before display. The default is Inf and preserves native pixels; each caller owns whether a display budget is appropriate for its workflow.

    This is sampling rather than interpolating resize. The first source pixel is always retained, and image class and channel count are preserved. A preview may use fewer pixels than the budget because the stride is an integer.

    Inputs

    imageData
    Nonempty numeric or logical 2-D or 3-D image array.
    -

    Name-Value Arguments

    MaxPixels
    Positive display-area budget. The default is 1.2e6 pixels. Invalid, empty, or nonpositive values fall back to the default rather than throwing an error.
    Expansion
    Positive multiplier applied only to estimatedPixels. The default is 1. Invalid values fall back to 1.
    +

    Name-Value Arguments

    MaxPixels
    Positive display-area budget or Inf. Default: Inf, retaining native pixels. Invalid, empty, or nonpositive values fall back to the default rather than throwing an error.
    Expansion
    Positive multiplier applied only to estimatedPixels. The default is 1. Invalid values fall back to 1.

    Outputs

    preview
    imageData sampled with the selected integer stride.
    info
    Scalar structure describing the preview calculation.

    Info Fields

    scaleFactor
    Integer row and column stride N.
    coordinateScale
    Reciprocal 1/N for mapping source distances to the sampled preview scale.
    maxPixels
    Validated display-area budget.
    estimatedPixels
    Source row-column area multiplied by Expansion.
    sourceSize
    Original size vector.
    previewSize
    Returned preview size vector.

    Errors

    labkit:image:InvalidImageData
    imageData is empty, nonnumeric/nonlogical, or has more than three dimensions.
    labkit:image:InvalidPreviewBudgetOptions
    Name-value arguments are not paired.
    labkit:image:InvalidPreviewBudgetOption
    An option name is unsupported.
    diff --git a/tests/+labkittest/classifyPath.m b/tests/+labkittest/classifyPath.m index ebcd8da0d..a377a0b68 100644 --- a/tests/+labkittest/classifyPath.m +++ b/tests/+labkittest/classifyPath.m @@ -26,7 +26,7 @@ return; end if startsWith(file, ".agents/") || file == "AGENTS.md" || ... - file == "tests/AGENTS.md" || file == ".gitignore" + endsWith(file, "/AGENTS.md") || file == ".gitignore" classification = mapped(file, "repository-policy", "system/repository", ... "repository policy behavior"); return; @@ -37,6 +37,16 @@ "documentation source or generated output; docsCheck owns consistency"); return; end + if startsWith(file, "tools/maintenance/") + classification = mapped(file, "maintenance-tool", "system/maintenance", ... + "independently callable repository maintenance tool"); + return; + end + if file == "tools/deployment/manageLabKitVersions.m" + classification = mapped(file, "deployment-tool", "system/deployment", ... + "independently callable deployment and version-management tool"); + return; + end if startsWith(file, "artifacts/") || startsWith(file, ".Trash/") || ... startsWith(file, ".DS_Store") classification = ignored(file, "generated-artifact", ... @@ -48,6 +58,16 @@ "cross-owner synthetic fixture behavior"); return; end + if file == "labkit_launcher.m" + classification = mapped(file, "repair-launcher", "system/launcher", ... + "self-contained repair-launcher bootstrap behavior"); + return; + end + if startsWith(file, "+labkit/+app/+internal/+launcher/") + classification = mapped(file, "installed-launcher", "system/launcher", ... + "installed Launcher composition and routing behavior"); + return; + end if startsWith(file, "+labkit/") && numel(parts) >= 2 && startsWith(parts(2), "+") area = erase(parts(2), "+"); if ismember(area, ["app", "biosignal", "contract", "dta", "image", "rhs", "thermal"]) diff --git a/tests/+labkittest/isolatedAppProbe.m b/tests/+labkittest/isolatedAppProbe.m index b667bdd4c..ec3f0c360 100644 --- a/tests/+labkittest/isolatedAppProbe.m +++ b/tests/+labkittest/isolatedAppProbe.m @@ -3,7 +3,7 @@ function isolatedAppProbe(root, appFolder, packageName, scratchRoot) % labkittest.isolatedAppProbe(ROOT, APPFOLDER, PACKAGE, SCRATCHROOT) is the % reset-path implementation used by labkittest.runIsolatedAppProbes. It % restores MATLAB's default path, adds ROOT and APPFOLDER only, validates the -% App definition, and builds its synthetic debug sample. It throws on any +% App definition, and generates its synthetic inputs. It throws on any % boundary or contract failure so the caller can aggregate every App result. root = string(root); @@ -33,11 +33,22 @@ function isolatedAppProbe(root, appFolder, packageName, scratchRoot) requirements = labkit.contract.checkRequirements(definition.Requirements); assert(requirements.ok, "LabKit:IsolatedProbe:Requirements", ... "App definition requirements are not satisfiable."); - sample = definition.BuildDebugSample(labkit.app.diagnostic.SampleContext( ... - fullfile(scratchRoot, packageName))); - assert(isa(sample, "labkit.app.diagnostic.SamplePack"), ... - "LabKit:IsolatedProbe:DebugSample", ... - "App definition must create a synthetic SamplePack."); + sample = labkit.app.internal.SyntheticInputGenerator.generate( ... + definition, fullfile(scratchRoot, packageName)); + assert(isa(sample, "labkit.app.synthetic.Pack"), ... + "LabKit:IsolatedProbe:SyntheticInputs", ... + "App definition must create a synthetic Pack."); + accepted = definition.ProjectSchema.Validate(sample.InitialProject); + assert(isequal(accepted, true), ... + "LabKit:IsolatedProbe:SyntheticInputs", ... + "Synthetic Pack InitialProject must satisfy the App project schema."); + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=fullfile(scratchRoot, "session-journal")); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, sample.InitialProject, struct(), ... + journal); + runtimeCleanup = onCleanup(@() runtime.close()); + clear runtimeCleanup clear cleanup end diff --git a/tests/+labkittest/locate.m b/tests/+labkittest/locate.m index 02612612c..03d2ab7e0 100644 --- a/tests/+labkittest/locate.m +++ b/tests/+labkittest/locate.m @@ -39,6 +39,16 @@ "headless", "", true, classification.Reason); return; end + if classification.Role == "repair-launcher" || classification.Role == "installed-launcher" + targets = target(opts.SpecsRoot, classification.Owner, "system", ... + "headless", "", true, classification.Reason); + return; + end + if ismember(classification.Role, ["maintenance-tool", "deployment-tool"]) + targets = target(opts.SpecsRoot, classification.Owner, "system", ... + "headless", "", true, classification.Reason); + return; + end parts = split(opts.File, "/"); if startsWith(opts.File, "+labkit/") && numel(parts) >= 2 && ... startsWith(parts(2), "+") diff --git a/tests/+labkittest/publicApps.m b/tests/+labkittest/publicApps.m index 6ab29ec77..6644e5367 100644 --- a/tests/+labkittest/publicApps.m +++ b/tests/+labkittest/publicApps.m @@ -14,8 +14,15 @@ listing = listing(listing.Visibility == "public", :); values = struct(); for k = 1:height(listing) - relativeEntry = string(listing.RelativePath(k)); - appFolder = fileparts(fullfile(root, char(relativeEntry))); + entries = dir(fullfile(root, "apps", "**", ... + char(listing.Command(k) + ".m"))); + entries = entries(~[entries.isdir]); + if numel(entries) ~= 1 + error("LabKit:TestCatalog:InvalidAppEntrypoint", ... + "Public App %s must have exactly one source entrypoint.", ... + listing.Command(k)); + end + appFolder = string(entries(1).folder); definitions = dir(fullfile(appFolder, "+*", "definition.m")); if numel(definitions) ~= 1 error("LabKit:TestCatalog:InvalidAppDefinition", ... diff --git a/tests/+labkittest/runIsolatedAppProbes.m b/tests/+labkittest/runIsolatedAppProbes.m index 1a4fa3bf0..797141ffd 100644 --- a/tests/+labkittest/runIsolatedAppProbes.m +++ b/tests/+labkittest/runIsolatedAppProbes.m @@ -7,7 +7,7 @@ % % APPS can be a descriptor array from labkittest.publicApps or that % function's scalar parameter-value structure. STATUS is zero only when -% every App proves its definition and synthetic debug-sample contract. +% every App proves its definition and synthetic-input contract. % OUTPUT names every failing App, so batching startup does not hide which % deployable boundary failed. The probe continues after an App failure and % reports the aggregate after every supplied App has been probed. diff --git a/tests/+labkittest/runtimeFactoryCalls.m b/tests/+labkittest/runtimeFactoryCalls.m new file mode 100644 index 000000000..48f5ea442 --- /dev/null +++ b/tests/+labkittest/runtimeFactoryCalls.m @@ -0,0 +1,148 @@ +function calls = runtimeFactoryCalls(source) +%RUNTIMEFACTORYCALLS Parse executable RuntimeFactory calls from MATLAB source. +% Expected caller: repository test guardrails. Strings and comments are masked +% before matching, while balanced delimiters preserve complete multi-line calls. + +source = char(join(string(source), newline)); +masked = maskNonCode(source); +pattern = "RuntimeFactory\.(createHeadless|createMatlab)\s*\("; +[starts, ends, tokens] = regexp(masked, pattern, "start", "end", "tokens"); +calls = repmat(struct("Method", "", "Line", 0, ... + "Arguments", strings(1, 0), "JournalRoot", ""), 1, numel(starts)); +for index = 1:numel(starts) + opening = ends(index); + closing = closingDelimiter(masked, opening); + if closing == 0 + error("LabKit:TestJournal:UnbalancedCall", ... + "RuntimeFactory call at source character %d is unbalanced.", starts(index)); + end + arguments = splitArguments( ... + source(opening + 1:closing - 1), masked(opening + 1:closing - 1)); + calls(index) = struct( ... + "Method", string(tokens{index}{1}), ... + "Line", numel(regexp(source(1:starts(index)), newline)) + 1, ... + "Arguments", arguments, ... + "JournalRoot", journalRootArgument(arguments)); +end +end + +function value = journalRootArgument(arguments) +value = ""; +if numel(arguments) == 5 + expression = strtrim(erase(arguments(5), "...")); + equals = find(char(expression) == char(61), 1); + if ~isempty(equals) && normalizedName(extractBefore(expression, equals)) == ... + "journalroot" + value = nonemptyExpression(extractAfter(expression, equals)); + end + return; +end +if numel(arguments) ~= 6 || normalizedName(arguments(5)) ~= "journalroot" + return; +end +value = nonemptyExpression(arguments(6)); +end + +function value = normalizedName(value) +value = lower(erase(strtrim(string(value)), [char(39), char(34)])); +end + +function value = nonemptyExpression(value) +value = strtrim(string(value)); +if value == "[]" + value = ""; +end +end + +function masked = maskNonCode(source) +masked = source; +singleQuote = char(39); +doubleQuote = char(34); +index = 1; +while index <= numel(source) + value = source(index); + if value == char(37) + while index <= numel(source) && source(index) ~= newline + masked(index) = char(32); + index = index + 1; + end + elseif value == doubleQuote || ... + (value == singleQuote && ~isTransposeOperator(source, index)) + quote = value; + masked(index) = char(32); + index = index + 1; + while index <= numel(source) + value = source(index); + masked(index) = char(32); + if value == quote + if index < numel(source) && source(index + 1) == quote + masked(index + 1) = char(32); + index = index + 2; + continue; + end + index = index + 1; + break; + end + index = index + 1; + end + else + index = index + 1; + end +end +end + +function tf = isTransposeOperator(source, index) +previous = index - 1; +while previous >= 1 && isspace(source(previous)) + previous = previous - 1; +end +if previous == 0 + tf = false; + return; +end +value = source(previous); +tf = isstrprop(value, "alphanum") || value == char(95) || ... + any(value == [char(46), char(41), char(93), char(125)]); +end + +function closing = closingDelimiter(masked, opening) +depth = 0; +closing = 0; +for index = opening:numel(masked) + if masked(index) == char(40) + depth = depth + 1; + elseif masked(index) == char(41) + depth = depth - 1; + if depth == 0 + closing = index; + return; + end + end +end +end + +function arguments = splitArguments(source, masked) +if strlength(strtrim(string(source))) == 0 + arguments = strings(1, 0); + return; +end +commas = zeros(1, numel(masked)); +commaCount = 0; +depth = 0; +for index = 1:numel(masked) + if any(masked(index) == [char(40), char(91), char(123)]) + depth = depth + 1; + elseif any(masked(index) == [char(41), char(93), char(125)]) + depth = depth - 1; + elseif masked(index) == char(44) && depth == 0 + commaCount = commaCount + 1; + commas(commaCount) = index; + end +end +boundaries = [1, commas(1:commaCount) + 1]; +stops = [commas(1:commaCount) - 1, numel(source)]; +arguments = strings(1, numel(boundaries)); +for index = 1:numel(boundaries) + arguments(index) = strtrim(string(source(boundaries(index):stops(index)))); +end +end diff --git a/tests/+labkittest/temporarySessionJournal.m b/tests/+labkittest/temporarySessionJournal.m new file mode 100644 index 000000000..3420dd068 --- /dev/null +++ b/tests/+labkittest/temporarySessionJournal.m @@ -0,0 +1,13 @@ +function journal = temporarySessionJournal(definition, rootFolder) +%TEMPORARYSESSIONJOURNAL Create one test-private journal beneath caller-owned scratch. +% Expected callers are LabKit specifications and isolated test probes. The +% caller supplies a TemporaryFolderFixture or isolated-process scratch root. + +rootFolder = string(rootFolder); +if ~(isscalar(rootFolder) && strlength(rootFolder) > 0) + error("LabKit:TestJournal:InvalidRoot", ... + "Temporary session journals require one nonempty scratch root."); +end +journal = labkit.app.internal.SessionJournal( ... + definition, RootFolder=rootFolder); +end diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 7615c173b..6fefd98bb 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -14,6 +14,11 @@ folders, selector registries, wrappers, or stage tags by hand. Run focused evidence through `labkittest.run`, and use `buildtool changedFast` only at the final integration gate. +Every App `projectSpec.m` selects nonempty owner-level `persistence` evidence +for defaults, validation, and migration semantics. App conformance separately +validates each synthetic sample pack, keeps Debug startup on the clean default +project, and launches the synthetic project through the native adapter. + `headless`, `gui`, `isolated`, and `coverage` are full catalog profiles. `changedFast` is focused local evidence: an App or facade path maps to its bounded closure, while framework, build, and repository-policy paths map to @@ -22,4 +27,7 @@ ignored because `docsCheck` owns them. An unclassified path fails planning; add a production role or an explicit no-test classification rather than widening the run. Generated artifacts live under `artifacts/test-results/` and are never tracked. A plan may also name an explicit manual check; it is a handoff for -native interaction or scientific review, never passing test evidence. +native interaction or scientific review, never passing test evidence. A +state-only geometry assertion does not prove pointer ownership or native +control creation; add hidden-GUI structure evidence when either is the +regression boundary. diff --git a/tests/specs/apps/conformance/AppSmokeConformanceSpec.m b/tests/specs/apps/conformance/AppSmokeConformanceSpec.m index bf5644b80..37481cba2 100644 --- a/tests/specs/apps/conformance/AppSmokeConformanceSpec.m +++ b/tests/specs/apps/conformance/AppSmokeConformanceSpec.m @@ -7,8 +7,12 @@ methods (Test, TestTags = {'Contract:product', 'Env:hidden-gui'}) function launchesThroughTheSupportedDefinition(testCase, App) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; definition = feval(char(App.Package + ".definition")); - runtime = labkit.app.internal.RuntimeFactory.createMatlab(definition); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, [], struct(), journal); cleanup = onCleanup(@() runtime.close()); figure = runtime.figureHandle(); @@ -24,5 +28,43 @@ function launchesThroughTheSupportedDefinition(testCase, App) end clear cleanup end + + function generatesSyntheticInputsWithoutMutatingRunningApp(testCase, App) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = feval(char(App.Package + ".definition")); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, [], struct(), ... + journal); + cleanup = onCleanup(@() runtime.close()); + stateBeforeGeneration = runtime.State; + + pack = runtime.generateSyntheticInputs(folder); + + testCase.verifyClass(pack, "labkit.app.synthetic.Pack"); + testCase.verifyTrue(isgraphics(runtime.figureHandle(), "figure")); + testCase.verifyEqual(runtime.State, stateBeforeGeneration); + testCase.verifyTrue(isfile(fullfile( ... + folder, "synthetic-input-pack.json"))); + clear cleanup + end + + function launchesEverySyntheticProjectNatively(testCase, App) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = feval(char(App.Package + ".definition")); + pack = labkit.app.internal.SyntheticInputGenerator.generate( ... + definition, folder); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, pack.InitialProject, struct(), ... + journal); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyTrue(isgraphics(runtime.figureHandle(), "figure")); + testCase.verifyFalse(runtime.StartupFailed); + clear cleanup + end end end diff --git a/tests/specs/apps/dic/dic_postprocess/workbench/DicPostprocessWorkflowSpec.m b/tests/specs/apps/dic/dic_postprocess/workbench/DicPostprocessWorkflowSpec.m index 09d5f27e0..f8728e0fd 100644 --- a/tests/specs/apps/dic/dic_postprocess/workbench/DicPostprocessWorkflowSpec.m +++ b/tests/specs/apps/dic/dic_postprocess/workbench/DicPostprocessWorkflowSpec.m @@ -14,8 +14,10 @@ function generatesExportsAndRestoresSyntheticDicOutputs(testCase) "chooseOutputFolder", @(~) labkit.app.dialog.Choice(folder), ... "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice(summaryPath), ... "alert", @(~, ~) []); + definition = dic_postprocess.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - dic_postprocess.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m index 89b3cb9d3..1043b99f4 100644 --- a/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m +++ b/tests/specs/apps/dic/dic_preprocess/workbench/DicPreprocessWorkflowSpec.m @@ -11,8 +11,10 @@ function alignsCropsExportsAndRestoresASyntheticPair(testCase) backend = struct( ... "chooseOutputFolder", @(~) labkit.app.dialog.Choice(folder), ... "alert", @(~, ~) []); + definition = dic_preprocess.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - dic_preprocess.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m b/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m index 37c06611a..3fb37c697 100644 --- a/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m +++ b/tests/specs/apps/electrochem/chrono_overlay/sourceFiles/ChronoOverlaySourceSpec.m @@ -4,7 +4,8 @@ methods (Test, TestTags = {'Contract:source', 'Env:headless'}) function alignsAtTheDetectedBlankGapCenter(testCase) item = ChronoOverlaySourceSpec.item((0:0.1:0.8).', ... - struct("ok", true, "gap_start", 0.3, "gap_end", 0.5, ... + struct("ok", true, "gap", struct( ... + "start_s", 0.3, "end_s", 0.5, "center_s", 0.4), ... "method", "synthetic")); [aligned, message] = chrono_overlay.sourceFiles.alignByPulseGap(item); diff --git a/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m b/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m index 880f420b5..a839fd52c 100644 --- a/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m +++ b/tests/specs/apps/electrochem/chrono_overlay/workbench/ChronoOverlayWorkflowSpec.m @@ -11,8 +11,10 @@ function loadsAlignsExportsAndRestoresAChronoTrace(testCase) backend = struct( ... "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice(output), ... "alert", @(~, ~) []); + definition = chrono_overlay.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - chrono_overlay.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/electrochem/cic/project/CicProjectSpec.m b/tests/specs/apps/electrochem/cic/project/CicProjectSpec.m new file mode 100644 index 000000000..fc6d762ce --- /dev/null +++ b/tests/specs/apps/electrochem/cic/project/CicProjectSpec.m @@ -0,0 +1,17 @@ +classdef CicProjectSpec < matlab.unittest.TestCase + % CICPROJECTSPEC Invariant: CIC project defaults satisfy the durable validation contract. + + methods (Test, TestTags = {'Contract:persistence', 'Env:headless'}) + function createsValidDefaultsAndRejectsAnUnknownPulseMode(testCase) + spec = cic.projectSpec(); + project = spec.Create(); + invalid = project; + invalid.parameters.pulseMode = "unknown"; + + testCase.verifyTrue(spec.Validate(project)); + testCase.verifyEmpty(project.inputs.sources); + testCase.verifyError(@() spec.Validate(invalid), ... + "cic:InvalidProject"); + end + end +end diff --git a/tests/specs/apps/electrochem/cic/workbench/CicPresentationSpec.m b/tests/specs/apps/electrochem/cic/workbench/CicPresentationSpec.m index 01f3e9ada..56da7bad8 100644 --- a/tests/specs/apps/electrochem/cic/workbench/CicPresentationSpec.m +++ b/tests/specs/apps/electrochem/cic/workbench/CicPresentationSpec.m @@ -56,8 +56,8 @@ function buildsAStableTimeVoltagePlotRequest(testCase) testCase.verifyEqual(request.y, item.analysis.Vf); testCase.verifyEqual(string(request.xLabel), choices.xAxes(1)); testCase.verifyEqual(string(request.yLabel), "Vf (V vs Ref.)"); - testCase.verifyEqual(request.coords.cathStartX, item.analysis.pulse.cath_start); - testCase.verifyEqual(request.coords.anodEndX, item.analysis.pulse.anod_end); + testCase.verifyEqual(request.coords.cathStartX, item.analysis.pulse.cath.start_s); + testCase.verifyEqual(request.coords.anodEndX, item.analysis.pulse.anod.end_s); end end end diff --git a/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m b/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m index 08353490d..2193a9a58 100644 --- a/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m +++ b/tests/specs/apps/electrochem/cic/workbench/CicWorkflowSpec.m @@ -14,8 +14,10 @@ function loadsRecomputesExportsAndRestoresAChronoSession(testCase) "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice( ... fullfile(folder, "cic_results.csv")), ... "alert", @(~, ~) []); + definition = cic.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - cic.definition(), [], backend); + definition, [], backend, journal); cleanupRuntime = onCleanup(@() runtime.close()); figure = runtime.figureHandle(); diff --git a/tests/specs/apps/electrochem/csc/project/CscProjectSpec.m b/tests/specs/apps/electrochem/csc/project/CscProjectSpec.m new file mode 100644 index 000000000..a5d7dcc71 --- /dev/null +++ b/tests/specs/apps/electrochem/csc/project/CscProjectSpec.m @@ -0,0 +1,17 @@ +classdef CscProjectSpec < matlab.unittest.TestCase + % CSCPROJECTSPEC Invariant: CSC project defaults satisfy the durable validation contract. + + methods (Test, TestTags = {'Contract:persistence', 'Env:headless'}) + function createsValidDefaultsAndRejectsAnUnknownAnalysisMode(testCase) + spec = csc.projectSpec(); + project = spec.Create(); + invalid = project; + invalid.parameters.mode = "unknown"; + + testCase.verifyTrue(spec.Validate(project)); + testCase.verifyEmpty(project.inputs.sources); + testCase.verifyError(@() spec.Validate(invalid), ... + "csc:InvalidProject"); + end + end +end diff --git a/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m b/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m index 0605b0337..11a022598 100644 --- a/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m +++ b/tests/specs/apps/electrochem/csc/workbench/CscWorkflowSpec.m @@ -5,7 +5,12 @@ function loadsACvCtFileAndUpdatesComparisonPlots(testCase) source = testfixtures.dtaFixturePath( ... "cv_cyclic_voltammetry_pt_reference.DTA"); - runtime = labkit.app.internal.RuntimeFactory.createMatlab(csc.definition()); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = csc.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, [], struct(), journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/electrochem/eis/analysisRun/EisScientificSpec.m b/tests/specs/apps/electrochem/eis/analysisRun/EisScientificSpec.m index ba1768234..cbd81d447 100644 --- a/tests/specs/apps/electrochem/eis/analysisRun/EisScientificSpec.m +++ b/tests/specs/apps/electrochem/eis/analysisRun/EisScientificSpec.m @@ -5,13 +5,26 @@ function mapsCanonicalImpedanceAndLogFrequencyAxes(testCase) item = EisScientificSpec.canonicalItem(testCase); axes = eis.overlayPlot.axisItems(); + units = eis.impedanceDisplay.catalog(); - realImpedance = eis.analysisRun.valuesForAxis(item, axes(5)); + realImpedance = eis.analysisRun.valuesForAxis( ... + item, axes(5), units.choices(3)); logFrequency = eis.analysisRun.valuesForAxis(item, axes(2)); + milliohm = eis.analysisRun.valuesForAxis( ... + item, axes(5), units.choices(1)); + ohm = eis.analysisRun.valuesForAxis( ... + item, axes(5), units.choices(2)); + megohm = eis.analysisRun.valuesForAxis( ... + item, axes(5), units.choices(4)); - testCase.verifyEqual(realImpedance, item.Zreal_ohm, "AbsTol", 1e-12); + testCase.verifyEqual(realImpedance, ... + item.Zreal_ohm / 1e3, "AbsTol", 1e-12); testCase.verifyEqual(logFrequency, log10(item.freq_Hz), "AbsTol", 1e-12); - testCase.verifyEqual(realImpedance(1), 138.7798, "AbsTol", 1e-12); + testCase.verifyEqual(milliohm, item.Zreal_ohm * 1e3, ... + "RelTol", 1e-12); + testCase.verifyEqual(ohm, item.Zreal_ohm, "AbsTol", 1e-12); + testCase.verifyEqual(megohm, item.Zreal_ohm / 1e6, ... + "AbsTol", 1e-12); end end diff --git a/tests/specs/apps/electrochem/eis/project/EisProjectSpec.m b/tests/specs/apps/electrochem/eis/project/EisProjectSpec.m new file mode 100644 index 000000000..c106ce4df --- /dev/null +++ b/tests/specs/apps/electrochem/eis/project/EisProjectSpec.m @@ -0,0 +1,26 @@ +classdef EisProjectSpec < matlab.unittest.TestCase + % EISPROJECTSPEC Compatibility: EIS impedance display units preserve old project meaning while new projects default to kilohms. + + methods (Test, TestTags = {'Contract:persistence', 'Env:headless'}) + function defaultsNewProjectsToKilohmsAndPreservesOldOhmProjects(testCase) + spec = eis.projectSpec(); + units = eis.impedanceDisplay.catalog(); + current = spec.Create(); + legacy = current; + legacy.parameters = rmfield(legacy.parameters, "impedanceUnit"); + legacy.parameters.xName = "Zreal (ohm)"; + legacy.parameters.yName = "-Zimag (ohm)"; + + migrated = spec.Migrate(legacy, 1); + + testCase.verifyEqual(current.parameters.impedanceUnit, ... + units.choices(3)); + testCase.verifyEqual(migrated.parameters.impedanceUnit, ... + units.choices(2)); + testCase.verifyEqual(migrated.parameters.xName, "Zreal"); + testCase.verifyEqual(migrated.parameters.yName, "-Zimag"); + testCase.verifyTrue(spec.Validate(current)); + testCase.verifyTrue(spec.Validate(migrated)); + end + end +end diff --git a/tests/specs/apps/electrochem/eis/resultFiles/EisResultSpec.m b/tests/specs/apps/electrochem/eis/resultFiles/EisResultSpec.m index 300870c0b..628c4ecd3 100644 --- a/tests/specs/apps/electrochem/eis/resultFiles/EisResultSpec.m +++ b/tests/specs/apps/electrochem/eis/resultFiles/EisResultSpec.m @@ -5,18 +5,21 @@ function buildsStableAxisAndFileNamedExportColumns(testCase) item = EisResultSpec.canonicalItem(testCase); axes = eis.overlayPlot.axisItems(); + units = eis.impedanceDisplay.catalog(); tableData = eis.resultFiles.buildExportTable(item, ... - axes(5), axes(7), false, false); + axes(5), axes(7), units.choices(3), false, false); expectedX = matlab.lang.makeValidName(sprintf("X_%s_%s", ... - "zreal_ohm", matlab.lang.makeValidName(item.name))); + "zreal_kohm", matlab.lang.makeValidName(item.name))); expectedY = matlab.lang.makeValidName(sprintf("Y_%s_%s", ... - "zimag_ohm", matlab.lang.makeValidName(item.name))); + "zimag_kohm", matlab.lang.makeValidName(item.name))); testCase.verifyEqual(tableData.Properties.VariableNames(1), {'RowIndex'}); testCase.verifyTrue(ismember(expectedX, tableData.Properties.VariableNames)); testCase.verifyTrue(ismember(expectedY, tableData.Properties.VariableNames)); testCase.verifyEqual(height(tableData), item.n); + testCase.verifyEqual(tableData.(expectedX), ... + item.Zreal_ohm / 1e3, "AbsTol", 1e-12); end end diff --git a/tests/specs/apps/electrochem/eis/workbench/EisPresentationSpec.m b/tests/specs/apps/electrochem/eis/workbench/EisPresentationSpec.m index bf1f8c4a0..6961fba67 100644 --- a/tests/specs/apps/electrochem/eis/workbench/EisPresentationSpec.m +++ b/tests/specs/apps/electrochem/eis/workbench/EisPresentationSpec.m @@ -4,7 +4,11 @@ methods (Test, TestTags = {'Contract:presentation', 'Env:headless'}) function presentsLoadedFilesWithoutUiHandlesInApplicationState(testCase) definition = eis.definition(); - runtime = labkit.app.internal.RuntimeFactory.createHeadless(definition); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(definition, root); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); cleanup = onCleanup(@() runtime.close()); fixture = testfixtures.dtaFixturePath("eis_potentiostatic_zcurve.DTA"); @@ -15,6 +19,9 @@ function presentsLoadedFilesWithoutUiHandlesInApplicationState(testCase) testCase.verifyClass(snapshot, "labkit.app.view.Snapshot"); testCase.verifyEqual(numel(state.session.cache.items), 1); testCase.verifyEqual(state.session.selection.files.Indices, 1); + units = eis.impedanceDisplay.catalog(); + testCase.verifyEqual(state.project.parameters.impedanceUnit, ... + units.choices(3)); testCase.verifyFalse(contains(evalc("disp(state)"), "matlab.ui")); clear cleanup end diff --git a/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m b/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m index 44c35a5e1..7a9623d14 100644 --- a/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m +++ b/tests/specs/apps/electrochem/eis/workbench/EisWorkflowSpec.m @@ -10,18 +10,24 @@ function loadsPlotsExportsAndRestoresAnEisFile(testCase) backend = struct( ... "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice(output), ... "alert", @(~, ~) []); + definition = eis.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - eis.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); runtime.applyFileSelection("files", source, 1); axesValue = findall(figureValue, "Tag", "plot.main"); + units = eis.impedanceDisplay.catalog(); + runtime.applyControlValue("impedanceUnit", units.choices(4)); runtime.applyControlValue("showMarkers", false); runtime.invokeAction("exportPlot"); testCase.verifyNumElements(runtime.State.session.cache.items, 1); testCase.verifyNotEmpty(axesValue.Children); + testCase.verifySubstring(string(axesValue.XLabel.String), ... + units.choices(4)); testCase.verifyTrue(isfile(output)); testCase.verifyTrue(isfile(fullfile(folder, "labkit_result.json"))); saved = fullfile(folder, "eis-project.mat"); diff --git a/tests/specs/apps/electrochem/vt_resistance/analysisRun/VtResistanceScientificSpec.m b/tests/specs/apps/electrochem/vt_resistance/analysisRun/VtResistanceScientificSpec.m index 7b12629b8..591c00883 100644 --- a/tests/specs/apps/electrochem/vt_resistance/analysisRun/VtResistanceScientificSpec.m +++ b/tests/specs/apps/electrochem/vt_resistance/analysisRun/VtResistanceScientificSpec.m @@ -52,10 +52,10 @@ function centerWindowAndRawVoltagePoliciesKeepTheExpectedResistance(testCase) testCase.verifyTrue(center.ok, center.message); testCase.verifyEqual(center.cathSteadyStart, ... - center.pulse.cath_start + .2 * (center.pulse.cath_end - center.pulse.cath_start), ... + center.pulse.cath.start_s + .2 * (center.pulse.cath.end_s - center.pulse.cath.start_s), ... AbsTol=1e-15); testCase.verifyEqual(center.cathSteadyEnd, ... - center.pulse.cath_start + .8 * (center.pulse.cath_end - center.pulse.cath_start), ... + center.pulse.cath.start_s + .8 * (center.pulse.cath.end_s - center.pulse.cath.start_s), ... AbsTol=1e-15); testCase.verifyEqual(center.Ravg_abs_ohm, 100, AbsTol=1e-10); testCase.verifyTrue(raw.ok, raw.message); diff --git a/tests/specs/apps/electrochem/vt_resistance/project/VtResistanceProjectSpec.m b/tests/specs/apps/electrochem/vt_resistance/project/VtResistanceProjectSpec.m new file mode 100644 index 000000000..e6e66bf0c --- /dev/null +++ b/tests/specs/apps/electrochem/vt_resistance/project/VtResistanceProjectSpec.m @@ -0,0 +1,17 @@ +classdef VtResistanceProjectSpec < matlab.unittest.TestCase + % VTRESISTANCEPROJECTSPEC Invariant: VT Resistance project defaults satisfy the durable validation contract. + + methods (Test, TestTags = {'Contract:persistence', 'Env:headless'}) + function createsValidDefaultsAndRejectsAnUnknownWindowMode(testCase) + spec = vt_resistance.projectSpec(); + project = spec.Create(); + invalid = project; + invalid.parameters.steadyWindow = "unknown"; + + testCase.verifyTrue(spec.Validate(project)); + testCase.verifyEmpty(project.inputs.sources); + testCase.verifyError(@() spec.Validate(invalid), ... + "vt_resistance:InvalidProject"); + end + end +end diff --git a/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m b/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m index d19b257c2..123effa45 100644 --- a/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m +++ b/tests/specs/apps/electrochem/vt_resistance/workbench/VtResistanceWorkflowSpec.m @@ -11,8 +11,10 @@ function loadsRecomputesExportsAndRestoresAChronoFile(testCase) backend = struct( ... "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice(output), ... "alert", @(~, ~) []); + definition = vt_resistance.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - vt_resistance.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/gait/gait_analysis/workbench/GaitWorkflowSpec.m b/tests/specs/apps/gait/gait_analysis/workbench/GaitWorkflowSpec.m index e57df02b0..f86ce86ff 100644 --- a/tests/specs/apps/gait/gait_analysis/workbench/GaitWorkflowSpec.m +++ b/tests/specs/apps/gait/gait_analysis/workbench/GaitWorkflowSpec.m @@ -5,14 +5,16 @@ function analyzesNavigatesExportsAndRestoresSyntheticPose(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - context = labkit.app.diagnostic.SampleContext(folder); - pack = gait_analysis.debug.writeSamplePack(context); + context = labkit.app.synthetic.Context(folder); + pack = gait_analysis.syntheticInputs.writeSamplePack(context); posePath = pack.InitialProject.inputs.sources(1).reference.originalPath; backend = struct( ... "chooseOutputFolder", @(~) labkit.app.dialog.Choice(folder), ... "alert", @(~, ~) []); + definition = gait_analysis.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - gait_analysis.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/image_measurement/batch_crop/cropGeometry/BatchCropGeometrySpec.m b/tests/specs/apps/image_measurement/batch_crop/cropGeometry/BatchCropGeometrySpec.m index 12cf29086..a6004eb93 100644 --- a/tests/specs/apps/image_measurement/batch_crop/cropGeometry/BatchCropGeometrySpec.m +++ b/tests/specs/apps/image_measurement/batch_crop/cropGeometry/BatchCropGeometrySpec.m @@ -28,6 +28,55 @@ function keepsOriginalCoordinatesAcrossThePreviewCanvasTransform(testCase) testCase.verifyEqual(recovered, point, AbsTol=1e-9); end + function preservesNativePreviewPixelsForOrdinaryImages(testCase) + item = batch_crop.sourceFiles.emptyItem(); + item.image = uint8(zeros(2000, 3000)); + geometry = batch_crop.cropGeometry.currentGeometry( ... + batch_crop.cropGeometry.emptyCanvasCache(), 1, item, 0); + + testCase.verifyEqual(geometry.coordinateScale, 1); + testCase.verifySize(geometry.canvas, [2000 3000]); + end + + function movesTheCropCenterWhenTheManagedRoiMoves(testCase) + state = stateWithImage(uint8(zeros(200, 300))); + state.project.parameters.cropWidth = 40; + state.project.parameters.cropHeight = 30; + state.project.inputs.items(1).centerXY = [150 120]; + state.project.inputs.items(1).centerSet = true; + item = batch_crop.sourceFiles.currentItem(state); + [geometry, ~] = batch_crop.cropGeometry.currentGeometry( ... + state.session.cache.canvas, 1, item, 0); + position = batch_crop.cropGeometry.cropRectanglePosition( ... + geometry, item.centerXY, [40 30]); + + state = batch_crop.cropGeometry.changeCropRectangle( ... + state, position + [20 -10 0 0], ... + labkit.app.internal.CallbackContextFactory.create( ... + struct("log", @(varargin) []))); + + testCase.verifyEqual(state.project.inputs.items(1).centerXY, ... + [170.5 110.5]); + testCase.verifyTrue(state.project.inputs.items(1).centerSet); + end + + function setsTheCropCenterWhenAnyCanvasPointIsClicked(testCase) + state = stateWithImage(uint8(zeros(200, 300))); + state.project.parameters.cropWidth = 40; + state.project.parameters.cropHeight = 30; + state.project.inputs.items(1).centerXY = [150 120]; + state.project.inputs.items(1).centerSet = true; + + state = batch_crop.cropGeometry.changeCenterFromPreview( ... + state, [155 125], ... + labkit.app.internal.CallbackContextFactory.create( ... + struct("log", @(varargin) []))); + + testCase.verifyEqual(state.project.inputs.items(1).centerXY, ... + [155.5 125.5]); + testCase.verifyTrue(state.project.inputs.items(1).centerSet); + end + function plansOnePhysicalOutputSizeAcrossUnequalCalibrations(testCase) items = [physicalItem("a.png", 4); physicalItem("b.png", 8)]; @@ -97,6 +146,18 @@ function preservesTheOriginalPreviewViewportAcrossPaddingAndScaleChanges(testCas end end +function state = stateWithImage(imageData) +project = batch_crop.projectSpec().Create(); +project.inputs.items = batch_crop.cropTasks.forSourceIds("image1"); +state = struct("project", project, "session", struct( ... + "selection", struct("currentIndex", 1), ... + "workflow", struct("cropDefaultsInitialized", true, ... + "scaleReferenceEditing", false), ... + "view", struct("scaleBar", []), ... + "cache", struct("images", {{imageData}}, "paths", "source.png", ... + "canvas", batch_crop.cropGeometry.emptyCanvasCache()))); +end + function item = physicalItem(path, pixelsPerUnit) item = batch_crop.sourceFiles.emptyItem(); item.path = path; diff --git a/tests/specs/apps/image_measurement/batch_crop/cropPreview/CropRoiPresentationSpec.m b/tests/specs/apps/image_measurement/batch_crop/cropPreview/CropRoiPresentationSpec.m new file mode 100644 index 000000000..d9e43757c --- /dev/null +++ b/tests/specs/apps/image_measurement/batch_crop/cropPreview/CropRoiPresentationSpec.m @@ -0,0 +1,31 @@ +classdef CropRoiPresentationSpec < matlab.unittest.TestCase + % CROPROIPRESENTATIONSPEC Regression: crop ROI visibly exposes both drag targets. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rendersAnUnambiguousCenterAndBodyDragAffordance(testCase) + figureValue = figure(Visible="off"); + cleanup = onCleanup(@() close(figureValue)); + axesValue = axes(Parent=figureValue); + model = struct( ... + "imageData", uint8(zeros(80, 120)), ... + "xData", [1 120], "yData", [1 80], ... + "center", [60 40], ... + "cropRectangle", [35.5 25.5 50 30], ... + "scaleBar", [], "title", "Crop preview"); + + batch_crop.cropPreview.draw(struct("main", axesValue), model); + + centerMarker = findall(axesValue, Type="line", Marker="o"); + labels = string({findall(axesValue, Type="text").String}); + testCase.verifyNumElements(centerMarker, 1); + testCase.verifyEqual(centerMarker.XData, 60); + testCase.verifyEqual(centerMarker.YData, 40); + testCase.verifyTrue(any(contains(labels, "Crop center"))); + testCase.verifyTrue(any( ... + labels == "Crop ROI — drag center or inside box")); + testCase.verifyEqual(string(centerMarker.HitTest), "off"); + testCase.verifyEqual(string(centerMarker.PickableParts), "none"); + clear cleanup + end + end +end diff --git a/tests/specs/apps/image_measurement/batch_crop/cropPreview/PreviewResolutionSpec.m b/tests/specs/apps/image_measurement/batch_crop/cropPreview/PreviewResolutionSpec.m new file mode 100644 index 000000000..3471e905b --- /dev/null +++ b/tests/specs/apps/image_measurement/batch_crop/cropPreview/PreviewResolutionSpec.m @@ -0,0 +1,18 @@ +classdef PreviewResolutionSpec < matlab.unittest.TestCase + % PREVIEWRESOLUTIONSPEC Regression: ordinary previews retain app-owned resolution. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function retainsAnOrdinaryCanvasWithoutASecondSamplingPass(testCase) + canvas = uint8(zeros(901, 1501)); + geometry = struct("canvas", canvas); + placement = struct("xData", [1 1501], "yData", [1 901]); + + render = batch_crop.cropPreview.renderData(geometry, placement); + + testCase.verifyEqual(render.imageData, canvas); + testCase.verifyEqual(render.scaleFactor, 1); + testCase.verifyEqual(render.xData, [1 1501]); + testCase.verifyEqual(render.yData, [1 901]); + end + end +end diff --git a/tests/specs/apps/image_measurement/batch_crop/syntheticInputs/SyntheticProjectSpec.m b/tests/specs/apps/image_measurement/batch_crop/syntheticInputs/SyntheticProjectSpec.m new file mode 100644 index 000000000..949929882 --- /dev/null +++ b/tests/specs/apps/image_measurement/batch_crop/syntheticInputs/SyntheticProjectSpec.m @@ -0,0 +1,42 @@ +classdef SyntheticProjectSpec < matlab.unittest.TestCase + % Regression: generated inputs satisfy the current Batch Crop project contract. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function provesSyntheticProject(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = batch_crop.definition(); + pack = labkit.app.internal.SyntheticInputGenerator.generate( ... + definition, folder); + + testCase.verifyTrue(definition.ProjectSchema.Validate( ... + pack.InitialProject)); + testCase.verifyEqual(exist(fullfile( ... + folder, "synthetic-input-pack.json"), "file"), 2); + centers = vertcat(pack.InitialProject.inputs.items.centerXY); + testCase.verifyTrue(all(isfinite(centers), "all")); + end + end + + methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) + function startsTheSyntheticProjectWithoutControlLimitFailures(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = batch_crop.definition(); + pack = labkit.app.internal.SyntheticInputGenerator.generate( ... + definition, folder); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, pack.InitialProject, struct(), ... + journal); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyTrue(isgraphics(runtime.figureHandle(), "figure")); + testCase.verifyFalse(runtime.StartupFailed); + roi = findall(runtime.figureHandle(), Type="rectangle"); + testCase.verifyNumElements(roi, 1); + testCase.verifyEqual(string(roi.HitTest), "on"); + clear cleanup + end + end +end diff --git a/tests/specs/apps/image_measurement/curvature/sourceFiles/CurvatureSourceSpec.m b/tests/specs/apps/image_measurement/curvature/sourceFiles/CurvatureSourceSpec.m index a56c541c1..bb2a8f8c9 100644 --- a/tests/specs/apps/image_measurement/curvature/sourceFiles/CurvatureSourceSpec.m +++ b/tests/specs/apps/image_measurement/curvature/sourceFiles/CurvatureSourceSpec.m @@ -11,7 +11,7 @@ function clearingAnImageSelectionClearsCurveState(testCase) "view", struct("scaleBar", [1 2]), "cache", struct("image", []))); selection = labkit.app.event.ListSelection(); context = labkit.app.internal.CallbackContextFactory.create( ... - struct("appendStatus", @(~) [])); + struct("log", @(varargin) [])); actual = curvature.sourceFiles.selectionChanged(state, selection, context); diff --git a/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkflowSpec.m b/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkflowSpec.m index beaa7b3fe..2b8f65664 100644 --- a/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkflowSpec.m +++ b/tests/specs/apps/image_measurement/curvature/workbench/CurvatureWorkflowSpec.m @@ -13,8 +13,10 @@ function tracesFitsExportsAndRestoresASyntheticCurve(testCase) "chooseOutputFile", @(filters, ~) chooseFile( ... filters, csvPath, overlayPath), ... "alert", @(~, ~) []); + definition = curvature.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - curvature.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalWorkflowSpec.m b/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalWorkflowSpec.m index 7393e9924..8f5a22c41 100644 --- a/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalWorkflowSpec.m +++ b/tests/specs/apps/image_measurement/flir_thermal/workbench/FlirThermalWorkflowSpec.m @@ -5,12 +5,15 @@ function displaysMeasuresExportsAndRestoresSyntheticRadiometricImages(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - context = labkit.app.diagnostic.SampleContext(folder); - pack = flir_thermal.debug.writeSamplePack(context); + context = labkit.app.synthetic.Context(folder); + pack = flir_thermal.syntheticInputs.writeSamplePack(context); backend = struct("chooseOutputFolder", @(~) labkit.app.dialog.Choice(folder), ... "alert", @(~, ~) []); + definition = flir_thermal.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - flir_thermal.definition(), pack.InitialProject, backend); + definition, pack.InitialProject, backend, ... + journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkflowSpec.m b/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkflowSpec.m index 9071be57f..97ec4ce48 100644 --- a/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkflowSpec.m +++ b/tests/specs/apps/image_measurement/focus_stack/workbench/FocusStackWorkflowSpec.m @@ -12,8 +12,10 @@ function loadsFusesExportsAndRestoresASyntheticStack(testCase) backend = struct( ... "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice(output), ... "alert", @(~, ~) []); + definition = focus_stack.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - focus_stack.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchWorkflowSpec.m b/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchWorkflowSpec.m index 586ec42ea..48be39f02 100644 --- a/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchWorkflowSpec.m +++ b/tests/specs/apps/image_measurement/image_match/workbench/ImageMatchWorkflowSpec.m @@ -11,8 +11,10 @@ function loadsMatchesExportsAndRestoresSyntheticImages(testCase) backend = struct( ... "chooseOutputFolder", @(~) labkit.app.dialog.Choice(folder), ... "alert", @(~, ~) []); + definition = image_match.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - image_match.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/image_measurement/video_marker/videoSource/VideoMarkerSourceSpec.m b/tests/specs/apps/image_measurement/video_marker/videoSource/VideoMarkerSourceSpec.m index af77846f2..33cd9ec34 100644 --- a/tests/specs/apps/image_measurement/video_marker/videoSource/VideoMarkerSourceSpec.m +++ b/tests/specs/apps/image_measurement/video_marker/videoSource/VideoMarkerSourceSpec.m @@ -11,7 +11,7 @@ function clearingTheVideoRemovesFramesAndDerivedExports(testCase) project.results.coordinateManifestPath = "coordinates.labkit.json"; state = struct("project", project); context = labkit.app.internal.CallbackContextFactory.create(struct( ... - "removeResource", @(~, ~) [], "appendStatus", @(~) [])); + "removeResource", @(~, ~) [], "log", @(varargin) [])); actual = video_marker.videoSource.selectionChanged(state, [], context); diff --git a/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerWorkflowSpec.m b/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerWorkflowSpec.m index c2e445e3c..52eee3539 100644 --- a/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerWorkflowSpec.m +++ b/tests/specs/apps/image_measurement/video_marker/workbench/VideoMarkerWorkflowSpec.m @@ -5,16 +5,19 @@ function marksPredictsExportsAndRestoresSyntheticVideo(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - context = labkit.app.diagnostic.SampleContext(folder); - pack = video_marker.debug.writeSamplePack(context); + context = labkit.app.synthetic.Context(folder); + pack = video_marker.syntheticInputs.writeSamplePack(context); markerPath = context.outputPath("markers.csv"); coordinatePath = context.outputPath("coordinates.csv"); backend = struct( ... "chooseOutputFile", @(~, defaultPath) chooseOutput( ... defaultPath, markerPath, coordinatePath), ... "alert", @(~, ~) []); + definition = video_marker.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - video_marker.definition(), pack.InitialProject, backend); + definition, pack.InitialProject, backend, ... + journal); cleanup = onCleanup(@() runtime.close()); points = [24 34; 32 38; 40 42; 48 46; 56 50]; diff --git a/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m b/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m index 84fd721fe..63107ca9f 100644 --- a/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m +++ b/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioPresentationSpec.m @@ -6,7 +6,8 @@ function declaresFigureSourceStyleAndExportControls(testCase) ids = nodeIds(figure_studio.workbench.buildLayout()); testCase.verifyTrue(all(ismember( ... - ["preview" "outputFolder" "exportCurrent" "appLog"], ids))); + ["preview" "outputFolder" "exportCurrent"], ids))); + testCase.verifyFalse(any(ids == "appLog")); end end end diff --git a/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioWorkflowSpec.m b/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioWorkflowSpec.m index d57d1fe28..58db50395 100644 --- a/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioWorkflowSpec.m +++ b/tests/specs/apps/labkit_core/figure_studio/workbench/FigureStudioWorkflowSpec.m @@ -1,6 +1,14 @@ classdef FigureStudioWorkflowSpec < matlab.unittest.TestCase %FIGURESTUDIOWORKFLOWSPEC Specify the bounded FIG-preview-export workflow. + methods (TestMethodSetup) + function keepNativeRuntimeHidden(testCase) + previous = getenv("LABKIT_GUI_TEST_MODE"); + testCase.addTeardown(@setenv, "LABKIT_GUI_TEST_MODE", previous); + setenv("LABKIT_GUI_TEST_MODE", "hidden"); + end + end + methods (Test, TestTags = {'Contract:presentation', 'Env:hidden-gui'}) function loadsAFigureIntoTheInteractivePreviewAndExportsPng(testCase) folder = testCase.applyFixture( ... @@ -12,8 +20,10 @@ function loadsAFigureIntoTheInteractivePreviewAndExportsPng(testCase) "chooseOutputFile", @(~, ~) labkit.app.dialog.Choice(outputPath), ... "chooseOutputFolder", @(~) labkit.app.dialog.Choice(folder), ... "alert", @(~, ~) []); + definition = figure_studio.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - figure_studio.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/neurophysiology/nerve_response_analysis/workbench/NerveResponseWorkflowSpec.m b/tests/specs/apps/neurophysiology/nerve_response_analysis/workbench/NerveResponseWorkflowSpec.m index 364b571c0..52efdff40 100644 --- a/tests/specs/apps/neurophysiology/nerve_response_analysis/workbench/NerveResponseWorkflowSpec.m +++ b/tests/specs/apps/neurophysiology/nerve_response_analysis/workbench/NerveResponseWorkflowSpec.m @@ -5,11 +5,13 @@ function analyzesExportsResetsAndRestoresSyntheticSession(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - context = labkit.app.diagnostic.SampleContext(folder); - pack = nerve_response_analysis.debug.writeSamplePack(context); + context = labkit.app.synthetic.Context(folder); + pack = nerve_response_analysis.syntheticInputs.writeSamplePack(context); + definition = nerve_response_analysis.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - nerve_response_analysis.definition(), pack.InitialProject, ... - struct("alert", @(~, ~) [])); + definition, pack.InitialProject, struct("alert", @(~, ~) []), ... + journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/neurophysiology/response_review_stats/workbench/ResponseReviewWorkflowSpec.m b/tests/specs/apps/neurophysiology/response_review_stats/workbench/ResponseReviewWorkflowSpec.m index cf96202b6..ffcf9dc59 100644 --- a/tests/specs/apps/neurophysiology/response_review_stats/workbench/ResponseReviewWorkflowSpec.m +++ b/tests/specs/apps/neurophysiology/response_review_stats/workbench/ResponseReviewWorkflowSpec.m @@ -12,8 +12,10 @@ function loadsPreviewsExportsResetsAndRestoresMetrics(testCase) backend = struct( ... "alert", @(~, ~) [], ... "chooseOutputFolder", @(~) labkit.app.dialog.Choice(alternateFolder)); + definition = response_review_stats.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - response_review_stats.definition(), [], backend); + definition, [], backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/neurophysiology/rhs_preview/workbench/RhsPreviewWorkflowSpec.m b/tests/specs/apps/neurophysiology/rhs_preview/workbench/RhsPreviewWorkflowSpec.m index e7c0a3ae2..b8203473f 100644 --- a/tests/specs/apps/neurophysiology/rhs_preview/workbench/RhsPreviewWorkflowSpec.m +++ b/tests/specs/apps/neurophysiology/rhs_preview/workbench/RhsPreviewWorkflowSpec.m @@ -5,8 +5,8 @@ function indexesPreviewsExportsAndRestoresSyntheticRhs(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - context = labkit.app.diagnostic.SampleContext(folder); - pack = rhs_preview.debug.writeSamplePack(context); + context = labkit.app.synthetic.Context(folder); + pack = rhs_preview.syntheticInputs.writeSamplePack(context); primary = context.samplePath("rhs_preview/acquisition/representative_primary.rhs"); repeat = context.samplePath("rhs_preview/acquisition/representative_repeat.rhs"); protocolPath = context.outputPath("rhs_protocol_draft.json"); @@ -19,8 +19,10 @@ function indexesPreviewsExportsAndRestoresSyntheticRhs(testCase) "chooseOutputFile", @(~, defaultPath) chooseOutput( ... defaultPath, protocolPath, filterPath), ... "alert", @(~, ~) []); + definition = rhs_preview.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - rhs_preview.definition(), project, backend); + definition, project, backend, journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m b/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m index 43454ba90..dd82751db 100644 --- a/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m +++ b/tests/specs/apps/wearable/ecg_print/workbench/EcgPrintWorkflowSpec.m @@ -5,16 +5,19 @@ function analyzesExportsAndRestoresASyntheticRecording(testCase) folder = testCase.applyFixture( ... matlab.unittest.fixtures.TemporaryFolderFixture).Folder; - context = labkit.app.diagnostic.SampleContext(folder); - pack = ecg_print.debug.writeSamplePack(context); + context = labkit.app.synthetic.Context(folder); + pack = ecg_print.syntheticInputs.writeSamplePack(context); segmentPath = context.outputPath("segments.csv"); waveformPath = context.outputPath("waveform.png"); backend = struct( ... "chooseOutputFile", @(~, defaultPath) chooseOutput( ... defaultPath, segmentPath, waveformPath), ... "alert", @(~, ~) []); + definition = ecg_print.definition(); + journal = labkittest.temporarySessionJournal(definition, folder); runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... - ecg_print.definition(), pack.InitialProject, backend); + definition, pack.InitialProject, backend, ... + journal); cleanup = onCleanup(@() runtime.close()); figureValue = runtime.figureHandle(); diff --git a/tests/specs/framework/app/AppSdkSpec.m b/tests/specs/framework/app/AppSdkSpec.m index 37bd4b370..e447bd882 100644 --- a/tests/specs/framework/app/AppSdkSpec.m +++ b/tests/specs/framework/app/AppSdkSpec.m @@ -11,7 +11,11 @@ function compilesDirectSemanticLayoutCallbacks(testCase) app = AppSdkSpec.definition(layout, "ProjectSchema", ... labkit.app.project.Schema( ... Version=1, Create=@createProject, Validate=@validateProject)); - runtime = labkit.app.internal.RuntimeFactory.createHeadless(app); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + app, [], struct(), journal); cleanup = onCleanup(@() runtime.close()); runtime.applyBinding("gain", 3); @@ -27,7 +31,7 @@ function validatesDefinitionMetadataAndCallbackRoles(testCase) layout = labkit.app.layout.workbench({}); app = AppSdkSpec.definition(layout, "OnStart", @startProbe, ... "CreateSession", @createSession, "PresentWorkbench", @presentProbe, ... - "BuildDebugSample", @debugSample); + "BuildSyntheticSample", @syntheticSample); testCase.verifyEqual(app.launch("version").version, "1.0.0"); testCase.verifyEqual(string(func2str(app.OnStart)), "startProbe"); @@ -37,6 +41,14 @@ function validatesDefinitionMetadataAndCallbackRoles(testCase) "CreateSession", @wrongSession), "labkit:app:contract:CallbackRoleMismatch"); end + function rejectsRetiredLaunchDiagnosticsOption(testCase) + app = AppSdkSpec.definition(labkit.app.layout.workbench({})); + + testCase.verifyError(@() app.launch( ... + Diagnostics=struct()), ... + "labkit:app:contract:UnknownArgument"); + end + function exposesTypedEventsRatherThanAmbiguousTransport(testCase) edit = labkit.app.event.TableCellEdit( ... RowId="row-a", RowIndex=1, ColumnId="group", ColumnIndex=2, ... @@ -63,6 +75,127 @@ function callbackContextHasOnlyNamedRuntimeCapabilities(testCase) testCase.verifyError(@() context.alert("message", "title"), ... "labkit:app:runtime:InvariantFailure"); end + + function syntheticInputsAreDeliberateAndDoNotChangeTheRuntime(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.field("gain", Kind="numeric", ... + Bind="project.parameters.gain")}); + app = AppSdkSpec.definition(layout, "ProjectSchema", ... + labkit.app.project.Schema(Version=1, Create=@createProject, ... + Validate=@validateProject), CreateSession=@sampleSession, ... + OnStart=@startChangesGain, BuildSyntheticSample=@validSyntheticSample); + journal = labkittest.temporarySessionJournal(app, folder); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + stateBeforeGeneration = runtime.State; + + pack = runtime.generateSyntheticInputs(folder); + + testCase.verifyEqual(runtime.State.project.parameters.gain, 99); + testCase.verifyEqual(runtime.State.session.gainAtCreation, 1); + testCase.verifyEqual(runtime.State, stateBeforeGeneration); + testCase.verifyEqual(pack.InitialProject.parameters.gain, 7); + testCase.verifyTrue(isfile(fullfile( ... + folder, "synthetic-input-pack.json"))); + clear cleanup + end + + function restoresDeclaredMigrationsAndReadOnlyImports(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + layout = labkit.app.layout.workbench({}); + schema = labkit.app.project.Schema( ... + Version=2, Create=@createCurrentProject, ... + Validate=@validateCurrentProject, ... + Migrate=@migrateProbeProject, ... + LegacyImports=struct("probeLegacy", @importProbeProject)); + app = AppSdkSpec.definition(layout, "ProjectSchema", schema); + journal = labkittest.temporarySessionJournal(app, folder); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + oldProjectFile = fullfile(folder, "old-project.mat"); + runtime.saveProject(runtime.State, oldProjectFile); + loaded = load(oldProjectFile, "labkitProject"); + labkitProject = loaded.labkitProject; + labkitProject.app.payloadVersion = 1; + labkitProject.payload.parameters = ... + rmfield(labkitProject.payload.parameters, "unit"); + save(oldProjectFile, "labkitProject"); + + runtime.restoreProject(oldProjectFile); + testCase.verifyEqual(runtime.State.project.parameters.unit, "base"); + + legacyFile = fullfile(folder, "legacy-project.mat"); + probeLegacy = struct("gain", 7); + save(legacyFile, "probeLegacy"); + runtime.restoreProject(legacyFile); + testCase.verifyEqual(runtime.State.project.parameters.gain, 7); + testCase.verifyEqual(runtime.State.project.parameters.unit, "base"); + + labkitProject.app.payloadVersion = 3; + newerFile = fullfile(folder, "newer-project.mat"); + save(newerFile, "labkitProject"); + testCase.verifyError(@() runtime.restoreProject(newerFile), ... + "labkit:app:runtime:NewerProjectPayload"); + clear cleanup + end + end + + methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) + function updatesAFieldAndItsCachedLabelWithoutTreeDiscovery(testCase) + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.field("gain", Kind="numeric", ... + Bind="project.parameters.gain")}); + app = AppSdkSpec.definition(layout, "ProjectSchema", ... + labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject), ... + "PresentWorkbench", @presentGainAvailability); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + figureValue = runtime.figureHandle(); + field = findall(figureValue, "Tag", "gain"); + label = findall(figureValue, "Tag", "gain.label"); + + testCase.verifyEqual(string(field.Enable), "on"); + testCase.verifyEmpty(findall(figureValue, ... + "Tag", "labkitAppUtilitySyntheticInputs")); + testCase.verifyEqual(string(label.Enable), "on"); + runtime.applyBinding("gain", 0); + testCase.verifyEqual(string(field.Enable), "off"); + testCase.verifyEqual(string(label.Enable), "off"); + clear cleanup + end + + function exposesSyntheticInputGenerationAsAnOrdinaryTool(testCase) + layout = labkit.app.layout.workbench({}); + app = AppSdkSpec.definition(layout, "ProjectSchema", ... + labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject), ... + "BuildSyntheticSample", @validSyntheticSample); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkittest.temporarySessionJournal(app, root); + runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + app, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + menu = findall(runtime.figureHandle(), ... + "Tag", "labkitAppUtilitySyntheticInputs"); + + testCase.verifyNumElements(menu, 1); + testCase.verifyEqual(string(menu.Text), ... + "Generate Synthetic Inputs..."); + clear cleanup + end end methods (Static, Access = private) @@ -107,10 +240,53 @@ function callbackContextHasOnlyNamedRuntimeCapabilities(testCase) view = labkit.app.view.Snapshot(); end -function pack = debugSample(~) +function pack = syntheticSample(~) pack = struct(); end +function session = sampleSession(project, ~) +session = struct("gainAtCreation", project.parameters.gain); +end + +function state = startChangesGain(state, ~) +state.project.parameters.gain = 99; +end + +function pack = validSyntheticSample(~) +pack = labkit.app.synthetic.Pack( ... + Scenario="sdk-probe", ... + InitialProject=struct("parameters", struct("gain", 7)), ... + Artifacts={}); +end + function session = wrongSession(~) session = struct(); end + +function view = presentGainAvailability(state) +view = labkit.app.view.Snapshot().enabled( ... + "gain", state.project.parameters.gain ~= 0); +end + +function project = createCurrentProject() +project = struct("parameters", struct("gain", 1, "unit", "base")); +end + +function accepted = validateCurrentProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isfield(project, "parameters") && ... + all(isfield(project.parameters, ["gain", "unit"])); +end + +function project = migrateProbeProject(project, fromVersion) +if fromVersion ~= 1 + error("probe:UnsupportedProjectMigration", ... + "Unsupported probe project version."); +end +project.parameters.unit = "base"; +end + +function project = importProbeProject(legacy) +project = createCurrentProject(); +project.parameters.gain = legacy.gain; +end diff --git a/tests/specs/framework/app/AutomaticInstrumentationSpec.m b/tests/specs/framework/app/AutomaticInstrumentationSpec.m new file mode 100644 index 000000000..f2cc28f4e --- /dev/null +++ b/tests/specs/framework/app/AutomaticInstrumentationSpec.m @@ -0,0 +1,314 @@ +classdef AutomaticInstrumentationSpec < matlab.unittest.TestCase + %AUTOMATICINSTRUMENTATIONSPEC Prove Runtime-owned semantic event chains. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function nestsPresentationUnderConstructionAndCallbacks(testCase) + runtime = instrumentationRuntime( ... + testCase, @incrementProject, @presentProject); + cleanup = onCleanup(@() runtime.close()); + + records = runtime.diagnosticEvents(); + construction = oneRecord(records, "runtime.construct.started"); + initialPresentation = ... + oneRecord(records, "presentation.rendered.started"); + testCase.verifyEqual( ... + initialPresentation.parentOperationId, ... + construction.operationId); + testCase.verifyEqual( ... + initialPresentation.rootActionId, construction.rootActionId); + + runtime.invokeAction("run"); + records = runtime.diagnosticEvents(); + callbacks = records(string({records.eventName}) == ... + "callback.pressed.started"); + presentations = records(string({records.eventName}) == ... + "presentation.rendered.started"); + callbackPresentation = presentations(end); + completed = records(string({records.eventName}) == ... + "presentation.rendered.completed"); + + testCase.verifyNumElements(callbacks, 1); + testCase.verifyNumElements(presentations, 2); + testCase.verifyNumElements(completed, 2); + testCase.verifyEqual(callbackPresentation.parentOperationId, ... + callbacks.operationId); + testCase.verifyEqual(callbackPresentation.rootActionId, ... + callbacks.rootActionId); + testCase.verifyEqual(completed(end).operationResult, "completed"); + testCase.verifyEqual( ... + completed(end).stateDisposition, "notApplicable"); + clear cleanup + end + + function recordsFailedPresentationInsideOneRolledBackCallback(testCase) + runtime = instrumentationRuntime( ... + testCase, @requestFailedPresentation, @presentProject); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyError(@() runtime.invokeAction("run"), ... + "labkit:app:runtime:ActionFailed"); + records = runtime.diagnosticEvents(); + callback = oneRecord(records, "callback.pressed.started"); + presentation = oneRecord( ... + records, "presentation.rendered.failed"); + callbackFailure = oneRecord(records, "callback.pressed.failed"); + + testCase.verifyEqual( ... + presentation.parentOperationId, callback.operationId); + testCase.verifyEqual( ... + presentation.rootActionId, callback.rootActionId); + testCase.verifyEqual( ... + presentation.operationResult, "failed"); + testCase.verifyEqual( ... + presentation.stateDisposition, "notApplicable"); + testCase.verifyEqual( ... + callbackFailure.stateDisposition, "rolledBack"); + testCase.verifyEqual(runtime.State.project.count, 0); + clear cleanup + end + + function recordsProjectSaveAndRestoreTransactions(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + runtime = instrumentationRuntime( ... + testCase, @incrementProject, @presentProject); + cleanup = onCleanup(@() runtime.close()); + projectFile = fullfile(folder, "project.mat"); + + runtime.saveProject(runtime.State, projectFile); + runtime.invokeAction("run"); + runtime.restoreProject(projectFile); + records = runtime.diagnosticEvents(); + saved = oneRecord(records, "project.saved.completed"); + restoredStart = oneRecord(records, "project.restored.started"); + restored = oneRecord(records, "project.restored.completed"); + presentations = records(string({records.eventName}) == ... + "presentation.rendered.started"); + restorePresentation = presentations( ... + string({presentations.parentOperationId}) == ... + restoredStart.operationId); + + testCase.verifyEqual(saved.operationResult, "completed"); + testCase.verifyEqual(saved.stateDisposition, "committed"); + testCase.verifyEqual(restored.operationResult, "completed"); + testCase.verifyEqual(restored.stateDisposition, "committed"); + testCase.verifyNumElements(restorePresentation, 1); + testCase.verifyEqual(restorePresentation.rootActionId, ... + restoredStart.rootActionId); + testCase.verifyEqual(runtime.State.project.count, 0); + clear cleanup + end + + function recordsNestedSourceDialogResultAndResourceOperations(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writelines("synthetic summary", ... + fullfile(folder, "summary.txt")); + backend = struct("choose", @chooseContinue); + initialProject = createProject(); + initialProject.folder = string(folder); + runtime = instrumentationRuntime( ... + testCase, @exerciseCapabilities, @presentProject, ... + backend, initialProject); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + records = runtime.diagnosticEvents(); + action = oneRecord( ... + records, "interaction.action_invoked.started"); + callbackStart = oneRecord( ... + records, "callback.pressed.started"); + expected = [ + "dialog.option_chosen.started" + "resource.set.started" + "resource.removed.started" + "resource.scope_cleared.started" + "source.paths_resolved.started" + "result.written.started" + ]; + + testCase.verifyEqual( ... + callbackStart.parentOperationId, action.operationId); + testCase.verifyEqual( ... + callbackStart.rootActionId, action.rootActionId); + for eventName = expected.' + operations = records( ... + string({records.eventName}) == eventName); + testCase.verifyNotEmpty(operations); + testCase.verifyTrue(all( ... + string({operations.parentOperationId}) == ... + callbackStart.operationId)); + testCase.verifyTrue(all( ... + string({operations.rootActionId}) == ... + action.rootActionId)); + end + testCase.verifyEqual(runtime.State.project.count, 1); + testCase.verifyTrue(isfile( ... + fullfile(folder, "labkit_result.json"))); + clear cleanup + end + + function recordsManagedInteractionAsTheRootAction(testCase) + runtime = managedInteractionRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + + runtime.applyInteraction( ... + "probeRectangle", "interactionChanged", [1 2 3 4]); + records = runtime.diagnosticEvents(); + interaction = oneRecord( ... + records, "interaction.managed_committed.started"); + callbackStart = oneRecord( ... + records, "callback.interactionChanged.started"); + callbackCompleted = oneRecord( ... + records, "callback.interactionChanged.completed"); + interactionCompleted = oneRecord( ... + records, "interaction.managed_committed.completed"); + + testCase.verifyEqual( ... + callbackStart.parentOperationId, interaction.operationId); + testCase.verifyEqual( ... + callbackStart.rootActionId, interaction.rootActionId); + testCase.verifyEqual( ... + callbackCompleted.stateDisposition, "committed"); + testCase.verifyEqual( ... + interactionCompleted.stateDisposition, "committed"); + testCase.verifyEqual(runtime.State.project.count, 1); + clear cleanup + end + end +end + +function runtime = instrumentationRuntime( ... + testCase, callback, presenter, backend, initialProject) +if nargin < 4 + backend = struct(); +end +if nargin < 5 + initialProject = []; +end +journalRoot = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button( ... + "run", "Run", callback, Tooltip="Run instrumentation probe.")}); +definition = labkit.app.Definition( ... + Entrypoint="labkit_AutomaticInstrumentationProbe_app", ... + AppId="probe.automatic-instrumentation", ... + Title="Automatic instrumentation probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-26", ... + Requirements=[], Workbench=layout, ... + ProjectSchema=labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject), ... + PresentWorkbench=presenter); +runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, initialProject, backend, ... + [], ... + JournalRoot=journalRoot); +end + +function runtime = managedInteractionRuntime(testCase) +journalRoot = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +rectangle = labkit.app.interaction.rectangle( ... + "probeRectangle", @moveRectangle, Axis="main"); +workspace = labkit.app.layout.workspace( ... + labkit.app.layout.plotArea( ... + "preview", @drawProbe, Interactions={rectangle})); +definition = labkit.app.Definition( ... + Entrypoint="labkit_ManagedInstrumentationProbe_app", ... + AppId="probe.managed-instrumentation", ... + Title="Managed instrumentation probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-26", ... + Requirements=[], ... + Workbench=labkit.app.layout.workbench({}, Workspace=workspace), ... + ProjectSchema=labkit.app.project.Schema( ... + Version=1, Create=@createProject, Validate=@validateProject), ... + PresentWorkbench=@presentManaged); +runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), [], ... + JournalRoot=journalRoot); +end + +function project = createProject() +project = struct( ... + "count", 0, "failPresentation", false, "folder", ""); +end + +function accepted = validateProject(project) +accepted = isstruct(project) && isscalar(project) && ... + isequal(string(fieldnames(project)), ... + ["count"; "failPresentation"; "folder"]) && ... + isnumeric(project.count) && isscalar(project.count) && ... + isfinite(project.count) && ... + islogical(project.failPresentation) && ... + isscalar(project.failPresentation) && ... + isstring(project.folder) && isscalar(project.folder); +end + +function applicationState = incrementProject(applicationState, ~) +applicationState.project.count = applicationState.project.count + 1; +end + +function applicationState = requestFailedPresentation(applicationState, ~) +applicationState.project.count = applicationState.project.count + 1; +applicationState.project.failPresentation = true; +end + +function snapshot = presentProject(applicationState) +if applicationState.project.failPresentation + error("probe:PresentationFailure", ... + "Intentional presentation failure."); +end +snapshot = labkit.app.view.Snapshot(); +end + +function snapshot = presentManaged(~) +snapshot = labkit.app.view.Snapshot() ... + .renderPlot("preview", struct()) ... + .rectangle("probeRectangle", [1 2 3 4], ... + ImageSize=[10 10], Enabled=true); +end + +function applicationState = exerciseCapabilities( ... + applicationState, callbackContext) +folder = applicationState.project.folder; +choice = callbackContext.chooseOption( ... + "Continue synthetic operation?", ["continue", "cancel"], ... + DefaultChoice="continue", CancelChoice="cancel"); +if choice.Value ~= "continue" + return; +end +callbackContext.setResource( ... + "event", "probe", struct("ready", true), []); +callbackContext.removeResource("event", "probe"); +callbackContext.setResource( ... + "event", "probe-clear", struct("ready", true), []); +callbackContext.clearResourceScope("event"); +source = labkit.app.project.sourceRecord( ... + "source-1", "input", fullfile(folder, "input.dat"), true); +callbackContext.resolveSourcePaths(source); +output = labkit.app.result.File( ... + "summary", "primary", "summary.txt", MediaType="text/plain"); +result = labkit.app.result.Package( ... + Outputs={output}, Inputs=struct(), ... + Parameters=struct(), Summary=struct("count", 1)); +callbackContext.writeResultPackage(folder, result); +applicationState.project.count = applicationState.project.count + 1; +end + +function choice = chooseContinue(~, ~, ~, ~, ~) +choice = labkit.app.dialog.Choice("continue"); +end + +function applicationState = moveRectangle( ... + applicationState, ~, ~) +applicationState.project.count = applicationState.project.count + 1; +end + +function drawProbe(~, ~) +end + +function record = oneRecord(records, eventName) +record = records(string({records.eventName}) == eventName); +assert(isscalar(record), "Expected one " + eventName + " record."); +end diff --git a/tests/specs/framework/app/SemanticDiagnosticsSpec.m b/tests/specs/framework/app/SemanticDiagnosticsSpec.m new file mode 100644 index 000000000..37bed439e --- /dev/null +++ b/tests/specs/framework/app/SemanticDiagnosticsSpec.m @@ -0,0 +1,85 @@ +classdef SemanticDiagnosticsSpec < matlab.unittest.TestCase + %SEMANTICDIAGNOSTICSSPEC Verify App-owned semantic diagnostic events. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function semanticLogsDriveStatusAndDeveloperEvents(testCase) + layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button("run", "Run", @runProbe, ... + Tooltip="Run the probe.")}); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = probeDefinition(layout); + journal = labkittest.temporarySessionJournal(definition, folder); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + + testCase.verifyEqual(runtime.StatusLog(end), "Semantic status."); + events = runtime.diagnosticEvents(); + status = events(string({events.eventName}) == "probe.status"); + checkpoint = events(string({events.eventName}) == "probe.checkpoint"); + count = events(string({events.eventName}) == "probe.count"); + reported = events(string({events.eventName}) == "probe.operation.failed"); + testCase.verifyNumElements(status, 1); + testCase.verifyEqual(status.message, "Semantic status."); + testCase.verifyNumElements(checkpoint, 1); + testCase.verifyEqual(checkpoint.attributes.enum, "checkpoint"); + testCase.verifyEqual(checkpoint.operationResult, ""); + testCase.verifyEqual(checkpoint.stateDisposition, ""); + testCase.verifyNumElements(count, 1); + testCase.verifyEqual(count.attributes.enum, "count"); + testCase.verifyEqual(count.attributes.count, 2); + testCase.verifyNumElements(reported, 1); + testCase.verifyEqual(reported.operationResult, ""); + testCase.verifyEqual(reported.stateDisposition, ""); + testCase.verifyEqual(reported.exception.identifier, "probe:ExpectedFailure"); + clear cleanup + end + + function streamClosesAbandonedOperationsInMemory(testCase) + stream = labkit.app.internal.SessionEventStream( ... + probeDefinition(labkit.app.layout.workbench({}))); + cleanup = onCleanup(@() stream.close()); + + operation = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + testCase.verifyError(@() stream.finish(operation, "completed"), ... + "labkit:app:contract:InvalidValue"); + stream.close(); + events = stream.records(); + abandoned = events(string({events.eventName}) == "callback.run.abandoned"); + + testCase.verifyNumElements(abandoned, 1); + testCase.verifyEqual(abandoned.operationResult, "abandoned"); + testCase.verifyEqual(abandoned.stateDisposition, "unknown"); + clear cleanup + end + end +end + +function state = runProbe(state, callbackContext) +callbackContext.log("info", "probe.status", "Semantic status."); +callbackContext.log("debug", "probe.checkpoint", ... + "Probe checkpoint.", Audience="developer", ... + Attributes=struct("enum", "checkpoint")); +callbackContext.log("debug", "probe.count", ... + "Probe count.", Audience="developer", ... + Attributes=struct("enum", "count", "count", 2)); +try + error("probe:ExpectedFailure", "Expected diagnostic failure."); +catch exception + callbackContext.log("error", "probe.operation.failed", ... + "Expected probe failure.", Category="failure", ... + Audience="developer", Exception=exception); +end +end + +function definition = probeDefinition(layout) +definition = labkit.app.Definition( ... + "Entrypoint", "labkit_LegacyDiagnosticsProbe_app", ... + "AppId", "probe.legacy-diagnostics", "Title", "Legacy diagnostics probe", ... + "Family", "Tests", "AppVersion", "1.0.0", "Updated", "2026-07-25", ... + "Requirements", [], "Workbench", layout); +end diff --git a/tests/specs/framework/app/SessionDiagnosticBundleSpec.m b/tests/specs/framework/app/SessionDiagnosticBundleSpec.m new file mode 100644 index 000000000..6c4fea39f --- /dev/null +++ b/tests/specs/framework/app/SessionDiagnosticBundleSpec.m @@ -0,0 +1,149 @@ +classdef SessionDiagnosticBundleSpec < matlab.unittest.TestCase + % SESSIONDIAGNOSTICBUNDLESPEC Regression: explicit diagnostic export produces one privacy-safe complete ZIP from the current ordinary session. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function exportsTheExactSafeBundleFromAnActiveSession(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = bundleDefinition(); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), ... + [], ... + JournalRoot=folder); + cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); + + destination = runtime.exportDiagnosticBundle( ... + fullfile(folder, "diagnostics")); + unpacked = fullfile(folder, "unpacked"); + unzip(destination, unpacked); + files = dir(unpacked); + files = sort(string({files(~[files.isdir]).name})); + expected = sort([ + "README.txt" + "manifest.json" + "events.jsonl" + "session.log.txt" + "errors.json" + "redaction-report.json" + ]); + + testCase.verifyTrue(endsWith(destination, ".zip")); + testCase.verifyEqual(files(:), expected); + events = readEvents(unpacked); + testCase.verifyGreaterThan(numel(events), 1); + testCase.verifyGreaterThan( ... + min(diff(double([events.sequence]))), 0); + names = string({events.eventName}); + testCase.verifyTrue(any(names == "analysis.completed")); + testCase.verifyTrue(any(names == "analysis.failed")); + testCase.verifyFalse(any( ... + names == "diagnostics.bundle_exported.started")); + errors = jsondecode(fileread( ... + fullfile(unpacked, "errors.json"))); + testCase.verifyEqual( ... + string(errors.exception.identifier), ... + "labkit:test:SyntheticIncident"); + redaction = jsondecode(fileread( ... + fullfile(unpacked, "redaction-report.json"))); + testCase.verifyEqual( ... + string(redaction.exportProjection), ... + "canonical-safe-events-only"); + testCase.verifyTrue(any( ... + string(redaction.excludedData) == "scientific-data")); + + bundleText = join(readAllBundleText( ... + unpacked, expected), newline); + testCase.verifyFalse(contains(bundleText, string(folder))); + testCase.verifyFalse(contains(bundleText, "private-source.png")); + clear cleanup + end + + function fallsBackToSafeMemoryWhenTheJournalIsUnavailable(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = bundleDefinition(); + journal = labkit.app.internal.SessionJournal( ... + definition, RootFolder=folder, ... + SessionId="session-bundle-fallback", ... + FaultInjector=@failJournalInitialize); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), ... + journal); + cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); + + destination = runtime.exportDiagnosticBundle( ... + fullfile(folder, "fallback.zip")); + unpacked = fullfile(folder, "fallback"); + unzip(destination, unpacked); + events = readEvents(unpacked); + names = string({events.eventName}); + + testCase.verifyTrue(any(names == "analysis.completed")); + testCase.verifyTrue(any(names == "journal.degraded")); + readme = string(fileread( ... + fullfile(unpacked, "README.txt"))); + testCase.verifyTrue(contains( ... + readme, "Journal dropped records:")); + clear cleanup + end + end +end + +function definition = bundleDefinition() +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button( ... + "run", "Run", @emitBundleIncident, ... + Tooltip="Generate a synthetic diagnostic incident.")}); +definition = labkit.app.Definition( ... + Entrypoint="labkit_DiagnosticBundleProbe_app", ... + AppId="probe.diagnostic-bundle", ... + Title="Diagnostic bundle probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-26", ... + Requirements=[], Workbench=layout); +end + +function applicationState = emitBundleIncident( ... + applicationState, callbackContext) +category = "app.probe.diagnostic-bundle.analysis"; +callbackContext.log( ... + "debug", "analysis.branch_selected", ... + "Synthetic branch selected.", ... + Category=category, Audience="developer", ... + Attributes=struct("enum", "synthetic")); +callbackContext.log( ... + "info", "analysis.completed", ... + "Synthetic analysis completed.", ... + Category=category, Audience="user"); +callbackContext.log( ... + "error", "analysis.failed", ... + "Synthetic analysis failed.", ... + Category=category, Audience="user", ... + Exception=MException( ... + "labkit:test:SyntheticIncident", "Synthetic incident.")); +end + +function events = readEvents(folder) +lines = readlines(fullfile(folder, "events.jsonl"), ... + EmptyLineRule="skip"); +events = repmat(jsondecode(lines(1)), numel(lines), 1); +for index = 1:numel(lines) + events(index) = jsondecode(lines(index)); +end +end + +function value = readAllBundleText(folder, files) +value = strings(numel(files), 1); +for index = 1:numel(files) + value(index) = string(fileread( ... + fullfile(folder, files(index)))); +end +end + +function failJournalInitialize(stage) +if string(stage) == "initialize" + error("labkit:test:JournalInitializeFailure", ... + "Intentional initialization failure."); +end +end diff --git a/tests/specs/framework/app/SessionEventStreamOperationSpec.m b/tests/specs/framework/app/SessionEventStreamOperationSpec.m new file mode 100644 index 000000000..b46a90e1d --- /dev/null +++ b/tests/specs/framework/app/SessionEventStreamOperationSpec.m @@ -0,0 +1,150 @@ +classdef SessionEventStreamOperationSpec < matlab.unittest.TestCase + %SESSIONEVENTSTREAMOPERATIONSPEC Verify private operation state boundaries. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function preservesNestedAncestryAndOneTerminalResult(testCase) + stream = labkit.app.internal.SessionEventStream(operationProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + + outer = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + inner = stream.begin("app.probe", "analysis.run", ... + "Running analysis."); + exception = MException("probe:ExpectedFailure", "Expected failure."); + stream.finish(inner, "failed", "rolledBack", exception); + stream.finish(outer, "completed", "committed"); + records = stream.records(); + innerStarted = records(string({records.eventName}) == "analysis.run.started"); + innerFailed = records(string({records.eventName}) == "analysis.run.failed"); + outerCompleted = records(string({records.eventName}) == "callback.run.completed"); + + testCase.verifyNumElements(innerStarted, 1); + testCase.verifyNumElements(innerFailed, 1); + testCase.verifyNumElements(outerCompleted, 1); + testCase.verifyEqual(innerStarted.parentOperationId, outer.Id); + testCase.verifyEqual(innerStarted.rootActionId, outer.RootActionId); + testCase.verifyEqual(innerFailed.operationResult, "failed"); + testCase.verifyEqual(innerFailed.stateDisposition, "rolledBack"); + testCase.verifyEqual(innerFailed.exception.identifier, "probe:ExpectedFailure"); + testCase.verifyEmpty(records(string({records.eventName}) == ... + "analysis.run.completed")); + clear cleanup + end + + function rejectsDoubleUnknownAndOutOfOrderFinishes(testCase) + stream = labkit.app.internal.SessionEventStream(operationProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + + outer = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + inner = stream.begin("app.probe", "analysis.run", ... + "Running analysis."); + testCase.verifyError(@() stream.finish(outer, "completed", "committed"), ... + "labkit:app:runtime:OutOfOrderOperation"); + stream.finish(inner, "completed", "committed"); + testCase.verifyError(@() stream.finish(inner, "completed", "committed"), ... + "labkit:app:runtime:OperationAlreadyFinished"); + unknown = outer; + unknown.Id = "op-unknown"; + testCase.verifyError(@() stream.finish(unknown, "completed", "committed"), ... + "labkit:app:runtime:UnknownOperation"); + stream.finish(outer, "completed", "committed"); + clear cleanup + end + + function rejectsFinishingAnOperationAfterStreamClose(testCase) + stream = labkit.app.internal.SessionEventStream(operationProbeDefinition()); + operation = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + stream.close(); + + testCase.verifyError(@() stream.finish(operation, "completed", "committed"), ... + "labkit:app:runtime:OperationClosed"); + end + + function closesActiveOperationsAsAbandonedFromInnerToOuter(testCase) + stream = labkit.app.internal.SessionEventStream(operationProbeDefinition()); + outer = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + inner = stream.begin("app.probe", "analysis.run", ... + "Running analysis."); + + stream.close(); + records = stream.records(); + innerAbandoned = records(string({records.eventName}) == "analysis.run.abandoned"); + outerAbandoned = records(string({records.eventName}) == "callback.run.abandoned"); + closed = records(string({records.eventName}) == "session.closed"); + + testCase.verifyNumElements(innerAbandoned, 1); + testCase.verifyNumElements(outerAbandoned, 1); + testCase.verifyNumElements(closed, 1); + testCase.verifyEqual(innerAbandoned.parentOperationId, outer.Id); + testCase.verifyEqual(innerAbandoned.rootActionId, outer.RootActionId); + testCase.verifyEqual(innerAbandoned.operationResult, "abandoned"); + testCase.verifyEqual(innerAbandoned.stateDisposition, "unknown"); + testCase.verifyEqual(outerAbandoned.operationResult, "abandoned"); + testCase.verifyEqual(outerAbandoned.stateDisposition, "unknown"); + testCase.verifyLessThan(innerAbandoned.sequence, outerAbandoned.sequence); + testCase.verifyLessThan(outerAbandoned.sequence, closed.sequence); + testCase.verifyEmpty(records(string({records.eventName}) == ... + "analysis.run.completed")); + testCase.verifyEmpty(records(string({records.eventName}) == ... + "callback.run.completed")); + end + + function rejectsUnsupportedTerminalOutcomes(testCase) + stream = labkit.app.internal.SessionEventStream(operationProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + operation = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + + testCase.verifyError(@() stream.finish(operation, "completed"), ... + "labkit:app:contract:InvalidValue"); + testCase.verifyError(@() stream.finish(operation, "banana", "committed"), ... + "labkit:app:contract:InvalidValue"); + stream.finish(operation, "Completed", "committed"); + rolledBack = stream.begin("runtime.callback", "callback.rollback", ... + "Rolling back callback."); + stream.finish(rolledBack, "failed", "rolledBack"); + records = stream.records(); + terminal = records(string({records.eventName}) == ... + "callback.rollback.failed"); + testCase.verifyNumElements(terminal, 1); + testCase.verifyEqual(terminal.operationResult, "failed"); + testCase.verifyEqual(terminal.stateDisposition, "rolledBack"); + clear cleanup + end + + function isolatesAFailingPrivateConsumerAfterRetention(testCase) + stream = labkit.app.internal.SessionEventStream( ... + operationProbeDefinition(), ProjectionHook=@throwFromConsumer); + cleanup = onCleanup(@() stream.close()); + + operation = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback."); + stream.finish(operation, "completed", "committed"); + records = stream.records(); + started = records(string({records.eventName}) == "callback.run.started"); + completed = records(string({records.eventName}) == "callback.run.completed"); + + testCase.verifyNumElements(started, 1); + testCase.verifyNumElements(completed, 1); + testCase.verifyEqual(completed.operationResult, "completed"); + testCase.verifyEqual(completed.stateDisposition, "committed"); + testCase.verifyEqual(completed.operationId, operation.Id); + clear cleanup + end + end +end + +function throwFromConsumer(~) +error("labkit:test:ConsumerFailure", "Intentional downstream failure."); +end + +function definition = operationProbeDefinition() +definition = labkit.app.Definition( ... + "Entrypoint", "labkit_SessionEventOperationProbe_app", ... + "AppId", "probe.session-event-operation", "Title", "Session operation probe", ... + "Family", "Tests", "AppVersion", "1.0.0", "Updated", "2026-07-25", ... + "Requirements", [], "Workbench", labkit.app.layout.workbench({})); +end diff --git a/tests/specs/framework/app/SessionEventStreamSpec.m b/tests/specs/framework/app/SessionEventStreamSpec.m new file mode 100644 index 000000000..a7c5d1622 --- /dev/null +++ b/tests/specs/framework/app/SessionEventStreamSpec.m @@ -0,0 +1,599 @@ +classdef SessionEventStreamSpec < matlab.unittest.TestCase + %SESSIONEVENTSTREAMSPEC Verify the private Phase 2 canonical event stream. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function defaultSessionIdentityDoesNotChangeRng(testCase) + before = rng; + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + + testCase.verifyEqual(rng, before); + clear cleanup + end + + function retainsMinimalPrivacySafeEventBeforeAnyProjection(testCase) + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition(), SessionId="session-test"); + cleanup = onCleanup(@() stream.close()); + + operation = stream.begin("runtime.callback", "callback.run", ... + "Dispatching callback.", Attributes=struct("runtimeAlias", "run")); + stream.log("info", "analysis.completed", "Analysis completed.", ... + Category="app.probe.session-logging.analysisRun", Audience="user", ... + Attributes=struct("validItemCount", 2)); + stream.finish(operation, "completed", "committed"); + records = stream.records(); + completed = records(string({records.eventName}) == "analysis.completed"); + + testCase.verifyNumElements(completed, 1); + testCase.verifyEqual(string(fieldnames(completed)), [ ... + "schemaVersion"; "sequence"; "timestampUtc"; ... + "elapsedSeconds"; "severity"; "audience"; "category"; ... + "eventName"; "message"; "attributes"; "sessionId"; ... + "appId"; "operationId"; "parentOperationId"; ... + "rootActionId"; "operationResult"; "stateDisposition"; ... + "durationSeconds"; "exception"]); + testCase.verifyEqual(completed.sessionId, "session-test"); + testCase.verifyEqual(completed.attributes.validItemCount, 2); + testCase.verifyEqual(completed.operationId, operation.Id); + testCase.verifyEqual(completed.rootActionId, operation.RootActionId); + clear cleanup + end + + function rejectsRawPathBeforeItCanEnterTheRetainedRing(testCase) + global labkitSessionEventStreamConsumerRecords + labkitSessionEventStreamConsumerRecords = strings(0, 1); + resetConsumer = onCleanup(@resetTestConsumer); + folder = string(testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder); + leaf = "input" + "." + "csv"; + windowsPath = string(fullfile(folder, leaf)); + posixPath = "/" + replace(erase(folder, ":"), "\\", "/") + "/" + leaf; + posixSingleComponent = "/" + "artifact"; + separator = string(char(92)); + uncPath = separator + separator + "node" + separator + "share" + ... + separator + leaf; + unsafeValues = [windowsPath, posixPath, posixSingleComponent, uncPath, leaf]; + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition(), ProjectionHook=@recordTestConsumer); + cleanup = onCleanup(@() stream.close()); + labkitSessionEventStreamConsumerRecords = strings(0, 1); + before = numel(stream.records()); + + for unsafeValue = unsafeValues + testCase.verifyError(@() stream.log("info", "source.loaded", ... + "Loaded " + unsafeValue + ".", ... + Category="runtime.source", Audience="developer"), ... + "labkit:app:contract:UnsafeLogData"); + testCase.verifyError(@() stream.log("info", "source.loaded", ... + "Selected source loaded.", Category="runtime.source", ... + Audience="developer", Attributes=struct("source", ... + struct("selection", unsafeValue))), ... + "labkit:app:contract:UnsafeLogData"); + end + + testCase.verifyEqual(numel(stream.records()), before); + testCase.verifyEmpty(labkitSessionEventStreamConsumerRecords); + clear cleanup resetConsumer + end + + function boundsTheProvisionalInMemoryRing(testCase) + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + + for index = 1:512 + stream.log("debug", "runtime.tick", "Tick recorded.", ... + Category="runtime.lifecycle", Audience="developer", ... + Attributes=struct("ordinal", index)); + end + records = stream.records(); + + testCase.verifyNumElements(records, 512); + testCase.verifyEqual(records(1).sequence, 2); + testCase.verifyEqual(records(end).sequence, 513); + clear cleanup + end + + function retainsUnitRatiosAndOrdinaryPunctuation(testCase) + global labkitSessionEventStreamConsumerRecords + labkitSessionEventStreamConsumerRecords = strings(0, 1); + resetConsumer = onCleanup(@resetTestConsumer); + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition(), ProjectionHook=@recordTestConsumer); + cleanup = onCleanup(@() stream.close()); + labkitSessionEventStreamConsumerRecords = strings(0, 1); + + stream.log("info", "analysis.unit_ratio", ... + "Rate settled at the expected mV/s ratio (A/B).", ... + Category="app.probe.session-logging.analysisRun", Audience="user", ... + Attributes=struct("unit", "mV/s", "enum", "nominal-ratio")); + records = stream.records(); + retained = records(string({records.eventName}) == "analysis.unit_ratio"); + + testCase.verifyNumElements(retained, 1); + testCase.verifyEqual(retained.attributes.unit, "mV/s"); + testCase.verifyEqual(retained.attributes.enum, "nominal-ratio"); + testCase.verifyEqual(labkitSessionEventStreamConsumerRecords, ... + "analysis.unit_ratio"); + clear cleanup resetConsumer + end + + function retainsOnlyTheFixedAttributeGrammarAtItsBoundaries(testCase) + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + attributes = boundedSafeAttributes(); + + stream.log("info", "analysis.attribute_boundary", ... + "Attribute grammar accepted.", ... + Category="app.probe.session-logging.analysisRun", Audience="user", ... + Attributes=attributes); + stream.log("debug", "analysis.attribute_byte_boundary", ... + "Canonical byte boundary accepted.", Category="runtime.lifecycle", ... + Audience="developer", Attributes=attributesAtCanonicalByteCount(1024)); + units = ["mV/s", "kOhm", "M" + string(char(8486)), ... + string(char(181)) + "m", string(char(956)) + "m", ... + string(char(176)) + "C", "%", "a.u.", "1", ... + "mA/cm^2", "Ohm*cm^2", "s^-1"]; + for unit = units + stream.log("debug", "analysis.unit_token", "Unit token accepted.", ... + Category="runtime.lifecycle", Audience="developer", ... + Attributes=struct("unit", unit)); + end + records = stream.records(); + retained = records(string({records.eventName}) == ... + "analysis.attribute_boundary"); + + testCase.verifyNumElements(retained, 1); + testCase.verifyEqual(numel(fieldnames(retained.attributes)), 12); + testCase.verifyEqual(retained.attributes.dimensions, ... + struct("samples", 2, "y", 3, "z", 4, "t", 5)); + testCase.verifyEqual(retained.attributes.sourceAlias, "source-1"); + clear cleanup + end + + function rejectsUnsafeAttributeShapesAndSemanticsBeforeRetention(testCase) + stream = labkit.app.internal.SessionEventStream( ... + loggingProbeDefinition()); + cleanup = onCleanup(@() stream.close()); + before = numel(stream.records()); + rejected = { ... + struct("count", [1, 2]), struct("count", NaN), ... + struct("count", Inf), struct("count", logical([true, false])), ... + struct("enum", {{"safe"}}), struct("count", table(1)), ... + struct("count", datetime("now")), struct("count", @sin), ... + struct("freeText", "safe-token"), struct("subject", 1), ... + struct("bindingId", "run"), struct("sourceAlias", "untrusted-token"), ... + struct("sourceAlias", "source-" + string(repmat('1', 1, 64))), ... + struct("enum", string(missing)), ... + struct("sampleCount", "one"), ... + struct("unit", "10mV"), struct("unit", "mV per s"), ... + struct("nested", struct("count", 1)), ... + struct("dimensions", struct()), ... + struct("dimensions", struct("x", 0)), ... + struct("dimensions", struct("x", [1, 2])), ... + dimensionsWithFiveAxes(), attributesWithSeventeenFields(), ... + attributesWithThirteenRootFieldsAndFourAxes(), ... + attributesAtCanonicalByteCount(1025)}; + + for index = 1:numel(rejected) + testCase.verifyError(@() stream.log("info", "analysis.rejected", ... + "Rejected attribute payload.", Category="runtime.lifecycle", ... + Audience="developer", Attributes=rejected{index}), ... + "labkit:app:contract:UnsafeLogData"); + end + testCase.verifyEqual(numel(stream.records()), before); + clear cleanup + end + + function rejectsUnsafeAttributesWithoutChangingRingHookOrJournal(testCase) + global labkitAttributePrivacyJournal labkitAttributePrivacyHookCount + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkit.app.internal.SessionJournal(loggingProbeDefinition(), ... + RootFolder=root, SessionId="session-attribute-privacy", ... + BufferRecordLimit=1); + labkitAttributePrivacyJournal = journal; + labkitAttributePrivacyHookCount = 0; + globalCleanup = onCleanup(@resetAttributePrivacyProjection); + journalCleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + SessionId="session-attribute-privacy", ... + ProjectionHook=@persistAttributePrivacyRecord); + streamCleanup = onCleanup(@() stream.close()); + labkitAttributePrivacyHookCount = 0; + beforeRecords = numel(stream.records()); + beforeJournal = journalText(journal.folder()); + + testCase.verifyError(@() stream.log("info", "analysis.rejected", ... + "Rejected attribute payload.", Category="runtime.lifecycle", ... + Audience="developer", Attributes=struct("sampleId", 1)), ... + "labkit:app:contract:UnsafeLogData"); + + testCase.verifyEqual(numel(stream.records()), beforeRecords); + testCase.verifyEqual(labkitAttributePrivacyHookCount, 0); + testCase.verifyEqual(journalText(journal.folder()), beforeJournal); + clear streamCleanup journalCleanup globalCleanup + end + + function retainsClosedNonrecursiveProjectionHealthNotifications(testCase) + global labkitProjectionHookCount labkitProjectionHealthNotifications + resetProjectionHealthFixture(); + cleanup = onCleanup(@resetProjectionHealthFixture); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + ProjectionHook=@countProjectionHook, ... + ProjectionHealthHook=@nextProjectionHealthNotification); + streamCleanup = onCleanup(@() stream.close()); + labkitProjectionHealthNotifications = struct( ... + "eventName", "journal.records_dropped", ... + "reason", "write-failure", "count", 2); + + stream.log("info", "analysis.projection", "Projection retained.", ... + Category="runtime.lifecycle", Audience="developer"); + stream.refreshProjectionHealth(); + records = stream.records(); + dropped = records(string({records.eventName}) == "journal.records_dropped"); + + testCase.verifyNumElements(dropped, 1); + testCase.verifyEqual(dropped.attributes.reason, "write-failure"); + testCase.verifyEqual(dropped.attributes.count, 2); + testCase.verifyEqual(labkitProjectionHookCount, 2); + clear streamCleanup cleanup + end + + function isolatesMalformedProjectionHealthNotifications(testCase) + invalid = { ... + struct("eventName", "analysis.injected", "reason", "write-failure", "count", 1), ... + struct("eventName", "journal.degraded", "reason", "write-failure", "count", NaN), ... + struct("eventName", "journal.records_dropped", "reason", "write-failure", "count", 0), ... + struct("eventName", "journal.records_dropped", "reason", "free text", "count", 1)}; + for index = 1:numel(invalid) + global labkitProjectionHealthNotifications + resetProjectionHealthFixture(); + cleanup = onCleanup(@resetProjectionHealthFixture); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + ProjectionHealthHook=@nextProjectionHealthNotification); + streamCleanup = onCleanup(@() stream.close()); + labkitProjectionHealthNotifications = invalid{index}; + + stream.refreshProjectionHealth(); + stream.log("info", "analysis.after_health_fault", ... + "Caller remains isolated.", Category="runtime.lifecycle", ... + Audience="developer"); + records = stream.records(); + degraded = records(string({records.eventName}) == "journal.degraded"); + + testCase.verifyNumElements(degraded, 1); + testCase.verifyEqual(degraded.attributes.reason, "health-unavailable"); + testCase.verifyFalse(any(string({records.eventName}) == "analysis.injected")); + clear streamCleanup cleanup + end + end + + function surfacesJournalProjectionFailureWithoutRecursing(testCase) + global labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex + resetProjectionHealthFixture(); + fixtureCleanup = onCleanup(@resetProjectionHealthFixture); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkit.app.internal.SessionJournal(loggingProbeDefinition(), ... + RootFolder=root, SessionId="session-projection-failure"); + journalCleanup = onCleanup(@() journal.close()); + projection = labkit.app.internal.SessionJournalProjection( ... + journal, @failProjectionDelivery); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + SessionId="session-projection-failure", ... + ProjectionHook=@projection.project, ... + ProjectionHealthHook=@projection.drainHealth); + streamCleanup = onCleanup(@() stream.close()); + labkitProjectionDeliveryFailures = [true, true]; + labkitProjectionDeliveryIndex = 0; + operation = stream.begin("runtime.callback", "callback.delivery", ... + "Delivering callback."); + + stream.log("info", "analysis.after_projection_failure", ... + "Caller remains isolated.", Category="runtime.lifecycle", ... + Audience="developer", Operation=operation); + records = stream.records(); + trigger = records(string({records.eventName}) == "callback.delivery.started"); + degraded = records(string({records.eventName}) == "journal.degraded"); + dropped = records(string({records.eventName}) == "journal.records_dropped"); + + testCase.verifyNumElements(degraded, 1); + testCase.verifyEqual(degraded.attributes.reason, "projection-failure"); + testCase.verifyEqual(degraded.operationId, trigger.operationId); + testCase.verifyEqual(degraded.parentOperationId, trigger.parentOperationId); + testCase.verifyEqual(degraded.rootActionId, trigger.rootActionId); + testCase.verifyNumElements(dropped, 2); + dropAttributes = [dropped.attributes]; + testCase.verifyEqual([dropAttributes.count], [1, 1]); + testCase.verifyEqual(dropped(1).operationId, trigger.operationId); + testCase.verifyEqual(dropped(1).parentOperationId, trigger.parentOperationId); + testCase.verifyEqual(dropped(1).rootActionId, trigger.rootActionId); + testCase.verifyNotEmpty(degraded.message); + testCase.verifyNotEmpty(dropped(1).message); + testCase.verifyNotEqual(degraded.message, dropped(1).message); + testCase.verifyTrue(any(string({records.eventName}) == ... + "analysis.after_projection_failure")); + testCase.verifyEmpty(dir(fullfile(journal.folder(), "events-*.jsonl"))); + stream.refreshProjectionHealth(); + records = stream.records(); + testCase.verifyNumElements(records( ... + string({records.eventName}) == "journal.records_dropped"), 2); + clear streamCleanup journalCleanup fixtureCleanup + end + + function reportsProjectionRecoveryAndLaterFailureAsNewTransition(testCase) + global labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex + resetProjectionHealthFixture(); + fixtureCleanup = onCleanup(@resetProjectionHealthFixture); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkit.app.internal.SessionJournal(loggingProbeDefinition(), ... + RootFolder=root, SessionId="session-projection-recovery"); + journalCleanup = onCleanup(@() journal.close()); + projection = labkit.app.internal.SessionJournalProjection( ... + journal, @failProjectionDelivery); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + SessionId="session-projection-recovery", ... + ProjectionHook=@projection.project, ... + ProjectionHealthHook=@projection.drainHealth); + streamCleanup = onCleanup(@() stream.close()); + labkitProjectionDeliveryFailures = [true, false, true]; + labkitProjectionDeliveryIndex = 0; + + stream.log("info", "analysis.projection_first", "First delivery.", ... + Category="runtime.lifecycle", Audience="developer"); + stream.log("info", "analysis.projection_recovery", "Recovered delivery.", ... + Category="runtime.lifecycle", Audience="developer"); + stream.log("info", "analysis.projection_second", "Second delivery.", ... + Category="runtime.lifecycle", Audience="developer"); + stream.refreshProjectionHealth(); + records = stream.records(); + degraded = records(string({records.eventName}) == "journal.degraded"); + dropped = records(string({records.eventName}) == "journal.records_dropped"); + dropAttributes = [dropped.attributes]; + + testCase.verifyNumElements(degraded, 2); + testCase.verifyNumElements(dropped, 2); + testCase.verifyEqual(sum([dropAttributes.count]), 2); + testCase.verifyTrue(all(string({dropAttributes.reason}) == "projection-failure")); + clear streamCleanup journalCleanup fixtureCleanup + end + + function isolatesProjectionHealthHookThrows(testCase) + projectionHealthThrowCounter("reset"); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + ProjectionHealthHook=@() projectionHealthThrowCounter("throw")); + cleanup = onCleanup(@() stream.close()); + records = stream.records(); + degraded = records(string({records.eventName}) == "journal.degraded"); + + stream.log("info", "analysis.after_health_hook_throw", ... + "Caller remains isolated.", Category="runtime.lifecycle", ... + Audience="developer"); + stream.close(); + records = stream.records(); + testCase.verifyNumElements(degraded, 1); + testCase.verifyEqual(degraded.attributes.reason, "health-unavailable"); + testCase.verifyTrue(any(string({records.eventName}) == ... + "analysis.after_health_hook_throw")); + testCase.verifyNumElements(records( ... + string({records.eventName}) == "journal.degraded"), 1); + testCase.verifyEqual(projectionHealthThrowCounter("count"), 1); + clear cleanup + end + + function reportsWrapperCloseFailureOnceWithoutARecordDrop(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + journal = labkit.app.internal.SessionJournal(loggingProbeDefinition(), ... + RootFolder=root, SessionId="session-projection-close-failure"); + cleanup = onCleanup(@() journal.close()); + projection = labkit.app.internal.SessionJournalProjection( ... + journal, @failProjectionCloseDelivery); + + projection.close(); + notifications = projection.drainHealth(); + repeated = projection.drainHealth(); + + testCase.verifyNumElements(notifications, 1); + testCase.verifyEqual(notifications.eventName, "journal.degraded"); + testCase.verifyEqual(notifications.reason, "projection-failure"); + testCase.verifyEqual(notifications.count, 0); + testCase.verifyEmpty(repeated); + clear cleanup + end + + function closeSequenceRefreshesHealthAfterProjectionFailure(testCase) + global labkitProjectionHealthNotifications + resetProjectionHealthFixture(); + cleanup = onCleanup(@resetProjectionHealthFixture); + stream = labkit.app.internal.SessionEventStream(loggingProbeDefinition(), ... + ProjectionHealthHook=@nextProjectionHealthNotification); + + stream.close(); + try + failClosingProjection(); + catch cause + testCase.verifyEqual(string(cause.identifier), ... + "labkit:test:ProjectionCloseFailure"); + end + stream.refreshProjectionHealth(); + records = stream.records(); + names = string({records.eventName}); + sessionClosedIndex = find(names == "session.closed", 1); + degraded = records(names == "journal.degraded"); + stream.close(); + stream.refreshProjectionHealth(); + + testCase.verifyNotEmpty(sessionClosedIndex); + testCase.verifyNumElements(degraded, 1); + testCase.verifyEqual(degraded.attributes.reason, "projection-failure"); + testCase.verifyGreaterThan(find(names == "journal.degraded", 1), ... + sessionClosedIndex); + afterSecondClose = stream.records(); + testCase.verifyNumElements(afterSecondClose( ... + string({afterSecondClose.eventName}) == "journal.degraded"), 1); + clear cleanup + end + end +end + +function recordTestConsumer(record) +global labkitSessionEventStreamConsumerRecords +labkitSessionEventStreamConsumerRecords(end + 1) = record.eventName; +end + +function resetTestConsumer() +global labkitSessionEventStreamConsumerRecords +labkitSessionEventStreamConsumerRecords = strings(0, 1); +end + +function countProjectionHook(~) +global labkitProjectionHookCount +labkitProjectionHookCount = labkitProjectionHookCount + 1; +end + +function notifications = nextProjectionHealthNotification() +global labkitProjectionHealthNotifications +notifications = labkitProjectionHealthNotifications; +labkitProjectionHealthNotifications = []; +end + +function resetProjectionHealthFixture() +global labkitProjectionHookCount labkitProjectionHealthNotifications ... + labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex +labkitProjectionHookCount = 0; +labkitProjectionHealthNotifications = []; +labkitProjectionDeliveryFailures = false(1, 0); +labkitProjectionDeliveryIndex = 0; +end + +function failProjectionDelivery(stage) +global labkitProjectionDeliveryFailures labkitProjectionDeliveryIndex +if string(stage) ~= "project" + return; +end +labkitProjectionDeliveryIndex = labkitProjectionDeliveryIndex + 1; +if labkitProjectionDeliveryIndex > numel(labkitProjectionDeliveryFailures) || ... + ~labkitProjectionDeliveryFailures(labkitProjectionDeliveryIndex) + return; +end +error("labkit:test:ProjectionDeliveryFailure", "Intentional projection delivery failure."); +end + +function value = projectionHealthThrowCounter(action) +persistent count +if isempty(count) + count = 0; +end +if action == "reset" + count = 0; + value = []; +elseif action == "count" + value = count; +elseif action == "throw" + count = count + 1; + error("labkit:test:ProjectionHealthFailure", "Intentional health reader failure."); +else + error("labkit:test:InvariantFailure", "Unknown projection health fixture action."); +end +end + +function failProjectionCloseDelivery(stage) +if string(stage) == "close" + error("labkit:test:ProjectionCloseFailure", "Intentional projection close failure."); +end +end + +function failClosingProjection() +global labkitProjectionHealthNotifications +labkitProjectionHealthNotifications = struct( ... + "eventName", "journal.degraded", "reason", "projection-failure", "count", 0); +error("labkit:test:ProjectionCloseFailure", "Intentional projection close failure."); +end + +function attributes = boundedSafeAttributes() +attributes = struct("enum", "normal", "unit", "mV/s", "reason", "fallback", ... + "runtimeAlias", "run", "sourceAlias", "source-1", ... + "dimensions", struct("samples", 2, "y", 3, "z", 4, "t", 5), ... + "validItemCount", 2, "ordinal", 1, "durationSeconds", 0.5, ... + "sampleCount", 1, "signalCount", 1, "fileCount", 1); +end + +function attributes = dimensionsWithFiveAxes() +attributes = struct("dimensions", struct("a", 1, "b", 1, "c", 1, "d", 1, "e", 1)); +end + +function attributes = attributesWithSeventeenFields() +attributes = struct(); +for index = 1:17 + attributes.("count" + string(index)) = index; +end +end + +function attributes = attributesWithThirteenRootFieldsAndFourAxes() +attributes = struct("dimensions", struct("x", 1, "y", 1, "z", 1, "t", 1)); +for index = 1:12 + attributes.("metric" + string(index)) = index; +end +end + +function attributes = attributesAtCanonicalByteCount(targetBytes) +attributes = struct(); +prefixes = "metric" + string(1:16); +for index = 1:numel(prefixes) + attributes.(char(prefixes(index))) = index; +end +remaining = targetBytes - canonicalAttributeBytes(attributes); +for index = 1:16 + prefix = prefixes(index); + padding = min(remaining, 64 - strlength(prefix)); + if padding == 0 + continue; + end + attributes = rmfield(attributes, char(prefix)); + key = prefix + string(repmat('x', 1, padding)); + attributes.(char(key)) = index; + remaining = targetBytes - canonicalAttributeBytes(attributes); +end +if remaining ~= 0 || canonicalAttributeBytes(attributes) ~= targetBytes + error("labkit:test:AttributeByteFixture", ... + "Could not construct the requested canonical attribute byte boundary."); +end +end + +function count = canonicalAttributeBytes(attributes) +count = numel(unicode2native(jsonencode(orderfields(attributes)), "UTF-8")); +end + +function persistAttributePrivacyRecord(record) +global labkitAttributePrivacyJournal labkitAttributePrivacyHookCount +labkitAttributePrivacyHookCount = labkitAttributePrivacyHookCount + 1; +labkitAttributePrivacyJournal.append(record); +end + +function resetAttributePrivacyProjection() +global labkitAttributePrivacyJournal labkitAttributePrivacyHookCount +labkitAttributePrivacyJournal = []; +labkitAttributePrivacyHookCount = 0; +end + +function text = journalText(folder) +segments = dir(fullfile(folder, "events-*.jsonl")); +text = strings(0, 1); +for index = 1:numel(segments) + text(end + 1, 1) = string(fileread(fullfile(segments(index).folder, segments(index).name))); +end +end + +function definition = loggingProbeDefinition() +definition = labkit.app.Definition( ... + "Entrypoint", "labkit_SessionEventStreamProbe_app", ... + "AppId", "probe.session-event-stream", "Title", "Session stream probe", ... + "Family", "Tests", "AppVersion", "1.0.0", "Updated", "2026-07-25", ... + "Requirements", [], "Workbench", labkit.app.layout.workbench({})); +end diff --git a/tests/specs/framework/app/SessionJournalSpec.m b/tests/specs/framework/app/SessionJournalSpec.m new file mode 100644 index 000000000..beac6cc4d --- /dev/null +++ b/tests/specs/framework/app/SessionJournalSpec.m @@ -0,0 +1,941 @@ +classdef SessionJournalSpec < matlab.unittest.TestCase + %SESSIONJOURNALSPEC Verify the private buffered canonical session store. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rejectsLegacyScalarOutcomeRecords(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-old-schema"); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-old-schema"); + streamCleanup = onCleanup(@() stream.close()); + legacy = stream.records(); + legacy = legacy(end); + legacy = rmfield(legacy, {'operationResult', 'stateDisposition'}); + legacy.outcome = "completed"; + + journal.append(legacy); + health = journal.healthSnapshot(); + + testCase.verifyEqual( ... + journal.manifest().degradation.dropReasons.invalidCanonicalRecord, 1); + testCase.verifyEqual(string(fieldnames(health)), [ ... + "state"; "available"; "droppedRecordCount"; ... + "invalidCanonicalRecordDropCount"; "writeFailureDropCount"; ... + "writeFailureCount"; "lastFailureReason"; "degradationReason"]); + testCase.verifyEqual(health.state, "healthy"); + testCase.verifyTrue(health.available); + testCase.verifyEqual(health.droppedRecordCount, 1); + testCase.verifyEqual(health.invalidCanonicalRecordDropCount, 1); + testCase.verifyEqual(health.writeFailureDropCount, 0); + clear streamCleanup cleanup + end + + function archiveRejectsLegacyScalarOutcomeJsonlRecords(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + [folder, expectedEvents] = writeJournalSession( ... + root, "session-old-archive-schema", "probe.session-journal"); + record = jsondecode(expectedEvents(1)); + legacy = rmfield(record, {'operationResult', 'stateDisposition'}); + legacy.outcome = "completed"; + legacyFields = ["schemaVersion", "sequence", "timestampUtc", ... + "elapsedSeconds", "severity", "audience", "category", ... + "eventName", "message", "attributes", "sessionId", "appId", ... + "operationId", "parentOperationId", "rootActionId", "outcome", ... + "durationSeconds", "exception"]; + legacy = orderfields(legacy, cellstr(legacyFields)); + appendText(onlySegment(folder), string(jsonencode(legacy)) + newline); + + snapshot = labkit.app.internal.SessionJournalArchive.snapshot( ... + root, "session-old-archive-schema"); + + testCase.verifyEqual(numel(snapshot.events), numel(expectedEvents)); + testCase.verifyEqual(snapshot.degradation.snapshotCorruptRecordCount, 1); + end + + function rejectsMismatchedTerminalPairsInJournalAndArchive(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-mismatched-pair"); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-mismatched-pair"); + streamCleanup = onCleanup(@() stream.close()); + mismatch = stream.records(); + mismatch = mismatch(end); + mismatch.operationResult = "failed"; + mismatch.stateDisposition = "committed"; + journal.append(mismatch); + + testCase.verifyEqual( ... + journal.manifest().degradation.dropReasons.invalidCanonicalRecord, 1); + clear streamCleanup cleanup + + [folder, expectedEvents] = writeJournalSession( ... + root, "session-mismatched-archive", "probe.session-journal"); + mismatch = jsondecode(expectedEvents(1)); + mismatch.operationResult = "failed"; + mismatch.stateDisposition = "committed"; + appendText(onlySegment(folder), string(jsonencode(mismatch)) + newline); + snapshot = labkit.app.internal.SessionJournalArchive.snapshot( ... + root, "session-mismatched-archive"); + + testCase.verifyEqual(numel(snapshot.events), numel(expectedEvents)); + testCase.verifyEqual(snapshot.degradation.snapshotCorruptRecordCount, 1); + end + + function defaultSessionIdentityDoesNotChangeRng(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + before = rng; + journal = labkit.app.internal.SessionJournal(app, RootFolder=root); + cleanup = onCleanup(@() journal.close()); + + testCase.verifyEqual(rng, before); + clear cleanup + end + + function buffersContextAndFlushesAroundWarnings(testCase) + global labkitSessionJournalStages + labkitSessionJournalStages = strings(0, 1); + resetObserver = onCleanup(@resetJournalObserver); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-buffer", ... + BufferRecordLimit=32, TestObserver=@recordJournalStage); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-buffer", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + journalFolder = journal.folder(); + + stream.log("debug", "analysis.context", "Context retained.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + testCase.verifyEmpty(dir(fullfile(journalFolder, "events-*.jsonl"))); + stream.log("warning", "analysis.degraded", "Analysis degraded.", ... + Category="app.probe.journal.analysisRun", Audience="user"); + segments = dir(fullfile(journalFolder, "events-*.jsonl")); + lines = readlines(fullfile(segments(1).folder, segments(1).name)); + lines = lines(strlength(lines) > 0); + + testCase.verifyEqual(numel(lines), 3); + writeStages = labkitSessionJournalStages( ... + labkitSessionJournalStages == "open" | ... + labkitSessionJournalStages == "flush"); + testCase.verifyEqual(writeStages, ... + ["open", "flush", "flush"]); + clear streamCleanup cleanup resetObserver + end + + function countsWarningDroppedAfterPreflushManifestFailure(testCase) + global labkitPreflushManifestFaultCount + labkitPreflushManifestFaultCount = 0; + resetFault = onCleanup(@resetPreflushManifestFault); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-warning-manifest-failure", ... + BufferRecordLimit=32, FaultInjector=@failPreflushManifest); + cleanup = onCleanup(@() journal.close()); + projection = labkit.app.internal.SessionJournalProjection(journal); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-warning-manifest-failure", ... + ProjectionHook=@projection.project, ... + ProjectionHealthHook=@projection.drainHealth); + streamCleanup = onCleanup(@() stream.close()); + + stream.log("debug", "analysis.context", "Context retained.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + stream.log("warning", "analysis.warning", "Warning retained.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + snapshot = journal.healthSnapshot(); + records = stream.records(); + names = string({records.eventName}); + dropped = records(names == "journal.records_dropped"); + stream.refreshProjectionHealth(); + refreshed = stream.records(); + persisted = readCanonicalEvents(journal.folder()); + + testCase.verifyEqual(snapshot.droppedRecordCount, 1); + testCase.verifyEqual(snapshot.writeFailureDropCount, 1); + testCase.verifyEqual(numel(dropped), 1); + testCase.verifyEqual(dropped.attributes.reason, "write-failure"); + testCase.verifyEqual(dropped.attributes.count, 1); + testCase.verifyNumElements(refreshed( ... + string({refreshed.eventName}) == "journal.records_dropped"), 1); + testCase.verifyTrue(any(contains(persisted, '"eventName":"analysis.context"'))); + testCase.verifyFalse(any(contains(persisted, '"eventName":"analysis.warning"'))); + clear streamCleanup cleanup resetFault + end + + function serializesExactlyTheCanonicalRecordFields(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-schema", BufferRecordLimit=1); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-schema", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + stream.log("info", "analysis.completed", "Analysis completed.", ... + Category="app.probe.journal.analysisRun", Audience="user", ... + Attributes=struct("validItemCount", 2)); + segments = dir(fullfile(journal.folder(), "events-*.jsonl")); + lines = readlines(fullfile(segments(1).folder, segments(1).name)); + lines = lines(strlength(lines) > 0); + stored = jsondecode(lines(end)); + + testCase.verifyEqual(string(fieldnames(stored)), [ ... + "schemaVersion"; "sequence"; "timestampUtc"; ... + "elapsedSeconds"; "severity"; "audience"; "category"; ... + "eventName"; "message"; "attributes"; "sessionId"; ... + "appId"; "operationId"; "parentOperationId"; ... + "rootActionId"; "operationResult"; "stateDisposition"; ... + "durationSeconds"; "exception"]); + testCase.verifyEqual(string(stored.eventName), "analysis.completed"); + clear streamCleanup cleanup + end + + function recordsActiveSessionStateUntilAnOrderlyClose(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-state"); + journalFolder = journal.folder(); + + testCase.verifyTrue(isfile(fullfile(journalFolder, "active.json"))); + testCase.verifyEqual(string(journal.manifest().state), "active"); + marker = readJson(journalFolder, "active.json"); + manifest = journal.manifest(); + testCase.verifyEqual(string(fieldnames(marker)), [ ... + "sessionId"; "appId"; "state"; "host"; "pid"; "nonce"; ... + "startedAtUtc"; "heartbeatAtUtc"; "leaseVersion"]); + testCase.verifyEqual(string(marker.sessionId), "session-state"); + testCase.verifyEqual(string(marker.appId), string(app.AppId)); + testCase.verifyEqual(string(marker.state), "active"); + testCase.verifyTrue(isstring(string(marker.host)) && isscalar(string(marker.host)) && ... + strlength(string(marker.host)) > 0); + testCase.verifyTrue(isnumeric(marker.pid) && isscalar(marker.pid) && ... + isfinite(marker.pid) && marker.pid == fix(marker.pid) && marker.pid >= -1); + testCase.verifyGreaterThan(strlength(string(marker.nonce)), 0); + startedAtUtc = datetime(marker.startedAtUtc, ... + InputFormat="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone="UTC"); + heartbeatAtUtc = datetime(marker.heartbeatAtUtc, ... + InputFormat="yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone="UTC"); + testCase.verifyFalse(isnat(startedAtUtc)); + testCase.verifyFalse(isnat(heartbeatAtUtc)); + testCase.verifyEqual(marker.leaseVersion, 1); + testCase.verifyEqual(string(manifest.lease.nonce), string(marker.nonce)); + testCase.verifyEqual(manifest.lease.leaseVersion, marker.leaseVersion); + journal.close(); + + testCase.verifyFalse(isfile(fullfile(journalFolder, "active.json"))); + testCase.verifyEqual(string(journal.manifest().state), "closed"); + end + + function boundsRetainedSessionSegmentsWithVisibleDegradation(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-retention", ... + SegmentByteLimit=256, SegmentLimit=2, SessionByteLimit=1024, ... + BufferRecordLimit=1); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-retention", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + + for index = 1:5 + stream.log("info", "analysis.step", "Analysis step recorded.", ... + Category="app.probe.journal.analysisRun", Audience="developer", ... + Attributes=struct("ordinal", index)); + end + segments = dir(fullfile(journal.folder(), "events-*.jsonl")); + manifest = journal.manifest(); + + testCase.verifyLessThanOrEqual(numel(segments), 2); + testCase.verifyGreaterThan(manifest.degradation.expiredSegmentCount, 0); + testCase.verifyLessThanOrEqual(manifest.retainedBytes, 1024); + clear streamCleanup cleanup + end + + function isolatesWriterFailureFromTheCanonicalCallerOutcome(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-failure", ... + FaultInjector=@failWrite, BufferRecordLimit=32); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-failure", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + scientificOutcome = "completed"; + + stream.log("warning", "analysis.degraded", "Analysis degraded.", ... + Category="app.probe.journal.analysisRun", Audience="user"); + manifest = journal.manifest(); + + testCase.verifyEqual(scientificOutcome, "completed"); + testCase.verifyEqual(numel(stream.records()), 2); + testCase.verifyEqual(manifest.degradation.writeFailureCount, 1); + clear streamCleanup cleanup + end + + function coalescesOnlyEquivalentLowSeverityRecords(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-coalescing", BufferRecordLimit=32); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-coalescing", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + stream.log("warning", "analysis.degraded", "Analysis degraded.", ... + Category="app.probe.journal.analysisRun", Audience="user"); + stream.log("warning", "analysis.degraded", "Analysis degraded.", ... + Category="app.probe.journal.analysisRun", Audience="user"); + manifest = journal.manifest(); + + testCase.verifyEqual(manifest.degradation.coalescedRecordCount, 1); + testCase.verifyEqual(string(manifest.degradation.coalescing.reason), ... + "repeated-low-severity"); + testCase.verifyEqual(manifest.degradation.coalescing.windowSeconds, 1); + testCase.verifyEqual(manifest.degradation.droppedRecordCount, 0); + clear streamCleanup cleanup + end + + function highFrequencyCoalescingDoesNotWriteManifestPerRecord(testCase) + global labkitSessionJournalStages + labkitSessionJournalStages = strings(0, 1); + resetObserver = onCleanup(@resetJournalObserver); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-coalescing-manifest", ... + BufferRecordLimit=128, TestObserver=@recordJournalStage); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-coalescing-manifest", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + + for index = 1:32 + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + end + + manifestStages = labkitSessionJournalStages( ... + labkitSessionJournalStages == "manifest"); + testCase.verifyNumElements(manifestStages, 1); + testCase.verifyEqual(journal.manifest().degradation.coalescedRecordCount, 31); + clear streamCleanup cleanup resetObserver + end + + function doesNotCoalesceLowSeverityExceptions(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-exception-coalescing", ... + BufferRecordLimit=32); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-exception-coalescing", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + exception = MException("probe:RepeatedFailure", ... + "Repeated diagnostic failure."); + + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer", ... + Exception=exception); + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer", ... + Exception=exception); + + testCase.verifyEqual( ... + journal.manifest().degradation.coalescedRecordCount, 0); + clear streamCleanup cleanup + end + + function preWarningWriteFailureDropsCurrentRecordWithoutFalseCoalescing(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-prewarning-failure", ... + FaultInjector=@failWrite, BufferRecordLimit=32); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, ... + SessionId="session-prewarning-failure", ProjectionHook=@journal.append); + streamCleanup = onCleanup(@() stream.close()); + + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + stream.log("warning", "analysis.degraded", "Analysis degraded.", ... + Category="app.probe.journal.analysisRun", Audience="user"); + stream.log("debug", "analysis.repeat", "Repeated diagnostic step.", ... + Category="app.probe.journal.analysisRun", Audience="developer"); + manifest = journal.manifest(); + + testCase.verifyEqual(manifest.degradation.coalescedRecordCount, 0); + testCase.verifyEqual(manifest.degradation.droppedRecordCount, 4); + testCase.verifyEqual(manifest.degradation.dropReasons.writeFailure, 4); + clear streamCleanup cleanup + end + + function inspectionOnlyAbandonsConfirmedStaleSessions(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + [currentFolder, currentEvents] = writeJournalSession( ... + root, "session-current", "probe.session-journal"); + nowUtc = "2030-01-01T00:10:00.000Z"; + markSessionActive(currentFolder, "2030-01-01T00:00:00.000Z", ... + "nonce-current", 41); + [staleFolder, ~] = writeJournalSession( ... + root, "session-stale", "probe.session-journal"); + markSessionActive(staleFolder, "2030-01-01T00:00:00.000Z", ... + "nonce-stale", 42); + + snapshot = labkit.app.internal.SessionJournalArchive.snapshot(root, ... + "session-current"); + exportFolder = fullfile(root, "safe-export"); + labkit.app.internal.SessionJournalArchive.exportSnapshot(root, ... + "session-current", exportFolder); + + currentManifest = readJson(currentFolder, "manifest.json"); + staleManifest = readJson(staleFolder, "manifest.json"); + testCase.verifyEqual(string(currentManifest.state), "active"); + testCase.verifyTrue(isfile(fullfile(currentFolder, "active.json"))); + testCase.verifyEqual(numel(snapshot.events), numel(currentEvents)); + testCase.verifyEqual(string(snapshot.events(1).eventName), ... + string(jsondecode(currentEvents(1)).eventName)); + testCase.verifyEqual(string(staleManifest.state), "active"); + testCase.verifyTrue(isfile(fullfile(staleFolder, "active.json"))); + testCase.verifyTrue(isfile(fullfile(exportFolder, "events.jsonl"))); + testCase.verifyTrue(isfile(fullfile(exportFolder, "manifest.json"))); + testCase.verifyTrue(isfile(fullfile(exportFolder, "timeline.txt"))); + testCase.verifyTrue(isfile(fullfile(exportFolder, "degradation.json"))); + redaction = readJson(exportFolder, "redaction.json"); + testCase.verifyEqual(string(redaction.exportProjection), ... + "canonical-safe-events-only"); + bundleText = join([string(fileread(fullfile(exportFolder, "events.jsonl"))); ... + string(fileread(fullfile(exportFolder, "timeline.txt")))], newline); + testCase.verifyFalse(contains(bundleText, string(root))); + + labkit.app.internal.SessionJournalArchive.inspect(root, ... + ProtectedSessionIds="session-current", LeaseClock=@() nowUtc, ... + LeaseProbe=@deadLeaseProbe); + staleManifest = readJson(staleFolder, "manifest.json"); + testCase.verifyEqual(string(staleManifest.state), "abandoned"); + testCase.verifyFalse(isfile(fullfile(staleFolder, "active.json"))); + testCase.verifyTrue(isfile(fullfile(currentFolder, "active.json"))); + end + + function classifiesLeaseOwnershipConservativelyAndNeverThrows(testCase) + startedAtUtc = "2030-01-01T00:00:00.000Z"; + marker = labkit.app.internal.SessionLease.create( ... + "session-lease", "probe.session-journal", startedAtUtc, ... + startedAtUtc, "nonce-lease", leaseOwnerProbe()); + manifest = activeLeaseManifest(marker); + freshProbe = leaseProcessProbe(41, "alive"); + + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, manifest, "2030-01-01T00:00:10.000Z", freshProbe, 60), "live"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, manifest, "2030-01-01T00:02:00.000Z", ... + leaseProcessProbe(41, "dead"), 60), "stale"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, manifest, "2030-01-01T00:02:00.000Z", freshProbe, 60), "uncertain"); + wrongTargetProbe = leaseProcessProbe(99, "dead"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, manifest, "2030-01-01T00:02:00.000Z", wrongTargetProbe, 60), "uncertain"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, manifest, "2030-01-01T00:00:10.000Z", ... + remoteLeaseProbe(41, "dead"), 60), "live"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, manifest, "2030-01-01T00:02:00.000Z", ... + remoteLeaseProbe(41, "dead"), 60), "uncertain"); + + nonceMismatch = manifest; + nonceMismatch.lease.nonce = "different-nonce"; + futureMarker = marker; + futureMarker.heartbeatAtUtc = "2030-01-01T00:03:00.000Z"; + malformed = marker; + malformed.host = ["fixture-host", "other-host"]; + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + struct(), manifest, "2030-01-01T00:02:00.000Z", freshProbe, 60), "uncertain"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + marker, nonceMismatch, "2030-01-01T00:02:00.000Z", freshProbe, 60), "uncertain"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + futureMarker, manifest, "2030-01-01T00:02:00.000Z", freshProbe, 60), "uncertain"); + testCase.verifyEqual(labkit.app.internal.SessionLease.classify( ... + malformed, manifest, "2030-01-01T00:02:00.000Z", freshProbe, 60), "uncertain"); + end + + function inspectionReportsLiveUncertainAndStaleLeaseCounts(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + liveFolder = writeJournalSession(root, "session-live", "probe.session-journal"); + staleFolder = writeJournalSession(root, "session-stale", "probe.session-journal"); + uncertainFolder = writeJournalSession(root, "session-uncertain", "probe.session-journal"); + markSessionActive(liveFolder, "2030-01-01T00:09:30.000Z", "nonce-live", 41); + markSessionActive(staleFolder, "2030-01-01T00:00:00.000Z", "nonce-stale", 42); + markSessionActive(uncertainFolder, "2030-01-01T00:00:00.000Z", "nonce-uncertain", 43); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root, ... + LeaseClock=@() "2030-01-01T00:10:00.000Z", ... + LeaseProbe=@mixedLeaseProbe); + + testCase.verifyEqual(inspection.liveSessionCount, 1); + testCase.verifyEqual(inspection.uncertainSessionCount, 1); + testCase.verifyEqual(inspection.staleSessionCount, 1); + testCase.verifyEqual(string(readJson(staleFolder, "manifest.json").state), "abandoned"); + testCase.verifyEqual(string(readJson(liveFolder, "manifest.json").state), "active"); + testCase.verifyEqual(string(readJson(uncertainFolder, "manifest.json").state), "active"); + end + + function inspectionTreatsMalformedMarkerFieldsAsUncertainWithoutMutation(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + folder = writeJournalSession(root, "session-malformed-marker", ... + "probe.session-journal"); + markSessionActive(folder, "2030-01-01T00:00:00.000Z", ... + "nonce-malformed", 41); + marker = readJson(folder, "active.json"); + marker.host = {"fixture-host", "other-host"}; + writeJson(fullfile(folder, "active.json"), marker); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root, ... + LeaseClock=@() "2030-01-01T00:10:00.000Z", ... + LeaseProbe=@deadLeaseProbe); + + testCase.verifyEqual(inspection.uncertainSessionCount, 1); + testCase.verifyEqual(inspection.recoveredSessionCount, 0); + testCase.verifyTrue(isfile(fullfile(folder, "active.json"))); + testCase.verifyEqual(string(readJson(folder, "manifest.json").state), "active"); + end + + function heartbeatsOnlyAfterTheConfiguredFlushInterval(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + resetLeaseClock(["2030-01-01T00:00:00.000Z"; ... + "2030-01-01T00:00:00.000Z"; "2030-01-01T00:00:05.000Z"; ... + "2030-01-01T00:00:31.000Z"; "2030-01-01T00:00:31.000Z"]); + clockCleanup = onCleanup(@resetLeaseClock); + app = journalProbeDefinition(); + journal = labkit.app.internal.SessionJournal(app, RootFolder=root, ... + SessionId="session-heartbeat", LeaseClock=@nextLeaseClock, ... + LeaseProbe=@() leaseOwnerProbe(), HeartbeatIntervalSeconds=30); + cleanup = onCleanup(@() journal.close()); + stream = labkit.app.internal.SessionEventStream(app, SessionId="session-heartbeat"); + streamCleanup = onCleanup(@() stream.close()); + record = stream.records(); + + journal.append(record(end)); + journal.flush(); + withinInterval = readJson(journal.folder(), "active.json"); + journal.append(record(end)); + journal.flush(); + afterInterval = readJson(journal.folder(), "active.json"); + + testCase.verifyEqual(string(journal.manifest().startedAtUtc), ... + "2030-01-01T00:00:00.000Z"); + testCase.verifyEqual(string(withinInterval.heartbeatAtUtc), ... + "2030-01-01T00:00:00.000Z"); + testCase.verifyEqual(string(afterInterval.heartbeatAtUtc), ... + "2030-01-01T00:00:31.000Z"); + clear streamCleanup cleanup clockCleanup + end + + function retentionNeverPrunesLiveOrUncertainSessionsUnderZeroLimits(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + liveFolder = writeJournalSession(root, "session-live-bound", "probe.retention-live"); + uncertainFolder = writeJournalSession(root, "session-uncertain-bound", "probe.retention-uncertain"); + markSessionActive(liveFolder, "2030-01-01T00:09:30.000Z", "nonce-live-bound", 41); + markSessionActive(uncertainFolder, "2030-01-01T00:00:00.000Z", ... + "nonce-uncertain-bound", 43); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root, ... + ClosedSessionLimitPerApp=0, AppByteLimit=0, GlobalByteLimit=0, ... + LeaseClock=@() "2030-01-01T00:10:00.000Z", ... + LeaseProbe=@mixedLeaseProbe); + + testCase.verifyTrue(isfolder(liveFolder)); + testCase.verifyTrue(isfolder(uncertainFolder)); + testCase.verifyEqual(inspection.liveSessionCount, 1); + testCase.verifyEqual(inspection.uncertainSessionCount, 1); + testCase.verifyEqual(sort(inspection.retention.unsatisfiedAppIds), ... + ["probe.retention-live"; "probe.retention-uncertain"]); + testCase.verifyTrue(inspection.retention.unsatisfiedGlobalByteLimit); + testCase.verifyGreaterThan(inspection.retention.retainedGlobalBytes, 0); + end + + function initializationAndClosePreserveObservableLeaseOrdering(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + resetManifestFault(); + manifestCleanup = onCleanup(@resetManifestFault); + journal = labkit.app.internal.SessionJournal(app, RootFolder=root, ... + SessionId="session-initialize-order", FaultInjector=@failActiveMarker); + initializeFolder = journal.folder(); + testCase.verifyTrue(isfile(fullfile(initializeFolder, "manifest.json"))); + testCase.verifyFalse(isfile(fullfile(initializeFolder, "active.json"))); + testCase.verifyEqual(string(readJson(initializeFolder, "manifest.json").state), "active"); + initializationInspection = labkit.app.internal.SessionJournalArchive.inspect(root); + testCase.verifyEqual(initializationInspection.uncertainSessionCount, 1); + testCase.verifyEqual(initializationInspection.recoveredSessionCount, 0); + testCase.verifyTrue(isfolder(initializeFolder)); + testCase.verifyEqual(string(readJson(initializeFolder, "manifest.json").state), "active"); + + resetManifestFault(); + journal = labkit.app.internal.SessionJournal(app, RootFolder=root, ... + SessionId="session-close-order", FaultInjector=@failSecondManifest); + closeFolder = journal.folder(); + journal.close(); + testCase.verifyTrue(isfile(fullfile(closeFolder, "active.json"))); + testCase.verifyEqual(string(readJson(closeFolder, "manifest.json").state), "active"); + clear manifestCleanup + end + + function rejectsMalformedLeaseNonceOption(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + app = journalProbeDefinition(); + + testCase.verifyError(@() labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId="session-invalid-nonce", LeaseNonce=[1, 2]), ... + "labkit:app:contract:InvalidValue"); + end + + function inspectionPreservesCleanClosedSession(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + folder = writeJournalSession(root, "session-closed", ... + "probe.session-journal"); + + labkit.app.internal.SessionJournalArchive.inspect(root); + manifest = readJson(folder, "manifest.json"); + + testCase.verifyEqual(string(manifest.state), "closed"); + testCase.verifyFalse(isfile(fullfile(folder, "active.json"))); + end + + function recoveryTruncatesOnlyCorruptTailAndReportsMiddleCorruption(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + [folder, expectedEvents] = writeJournalSession( ... + root, "session-recovery", "probe.session-journal"); + segment = onlySegment(folder); + appendText(segment, "{"); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root); + recoveredLines = nonemptyLines(segment); + snapshot = labkit.app.internal.SessionJournalArchive.snapshot(root, ... + "session-recovery"); + + testCase.verifyEqual(numel(recoveredLines), numel(expectedEvents)); + testCase.verifyEqual(recoveredLines, expectedEvents); + testCase.verifyEqual(numel(snapshot.events), numel(expectedEvents)); + testCase.verifyEqual(inspection.corruptTailCount, 1); + + appendText(segment, "{invalid-middle}" + newline + ... + expectedEvents(end)); + snapshot = labkit.app.internal.SessionJournalArchive.snapshot(root, ... + "session-recovery"); + + testCase.verifyEqual(snapshot.degradation.snapshotCorruptRecordCount, 1); + testCase.verifyEqual(numel(snapshot.events), numel(expectedEvents) + 1); + end + + function retentionPrunesAgeThenPerAppThenGlobalWithoutCurrentSession(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + ageFolder = writeJournalSession(root, "session-age", "probe.retention-a"); + appOldFolder = writeJournalSession(root, "session-app-old", "probe.retention-a"); + appNewFolder = writeJournalSession(root, "session-app-new", "probe.retention-a"); + otherFolder = writeJournalSession(root, "session-other", "probe.retention-b"); + currentFolder = writeJournalSession(root, "session-current", "probe.retention-b"); + markSessionTimestamp(ageFolder, "closed", utcOffset(-20)); + markSessionTimestamp(appOldFolder, "closed", utcOffset(-3)); + markSessionTimestamp(appNewFolder, "closed", utcOffset(-1)); + markSessionTimestamp(otherFolder, "closed", utcOffset(-2)); + markSessionActive(currentFolder); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root, ... + ClosedSessionAgeDays=14, ClosedSessionLimitPerApp=1, ... + AppByteLimit=1024 * 1024, GlobalByteLimit=1, ... + ProtectedSessionIds="session-current"); + + testCase.verifyFalse(isfolder(ageFolder)); + testCase.verifyFalse(isfolder(appOldFolder)); + testCase.verifyFalse(isfolder(appNewFolder)); + testCase.verifyFalse(isfolder(otherFolder)); + testCase.verifyTrue(isfolder(currentFolder)); + testCase.verifyEqual(inspection.retention.expiredSessionCount, 1); + testCase.verifyEqual(inspection.retention.perAppPrunedSessionCount, 1); + testCase.verifyEqual(inspection.retention.globalPrunedSessionCount, 2); + end + + function retentionReportsProtectedActiveBoundOverflow(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + folder = writeJournalSession(root, "session-current", "probe.retention"); + markSessionActive(folder); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root, ... + ProtectedSessionIds="session-current", AppByteLimit=1, ... + GlobalByteLimit=1); + + testCase.verifyTrue(isfolder(folder)); + testCase.verifyEqual(inspection.retention.unsatisfiedAppIds, ... + "probe.retention"); + testCase.verifyTrue(inspection.retention.unsatisfiedGlobalByteLimit); + testCase.verifyGreaterThan(inspection.retention.retainedGlobalBytes, 1); + end + + function retentionDoesNotMisattributeBoundsAfterAnotherAppPrunes(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + firstFolder = writeJournalSession(root, "session-first", "probe.retention-a"); + secondFolder = writeJournalSession(root, "session-second", "probe.retention-a"); + currentFolder = writeJournalSession(root, "session-current", "probe.retention-b"); + markSessionTimestamp(firstFolder, "closed", utcOffset(-2)); + markSessionTimestamp(secondFolder, "closed", utcOffset(-1)); + markSessionActive(currentFolder); + + inspection = labkit.app.internal.SessionJournalArchive.inspect(root, ... + ProtectedSessionIds="session-current", ClosedSessionLimitPerApp=1, ... + AppByteLimit=1, GlobalByteLimit=1024 * 1024); + + testCase.verifyFalse(isfolder(firstFolder)); + testCase.verifyFalse(isfolder(secondFolder)); + testCase.verifyTrue(isfolder(currentFolder)); + testCase.verifyEqual(inspection.retention.unsatisfiedAppIds, ... + "probe.retention-b"); + end + end +end + +function recordJournalStage(stage, ~) +global labkitSessionJournalStages +labkitSessionJournalStages(end + 1) = string(stage); +end + +function resetJournalObserver() +global labkitSessionJournalStages +labkitSessionJournalStages = strings(0, 1); +end + +function failWrite(stage) +if string(stage) == "write" + error("labkit:test:JournalWriteFailure", "Intentional journal write failure."); +end +end + +function failPreflushManifest(stage) +global labkitPreflushManifestFaultCount +if string(stage) ~= "manifest" + return; +end +labkitPreflushManifestFaultCount = labkitPreflushManifestFaultCount + 1; +if labkitPreflushManifestFaultCount >= 2 + error("labkit:test:JournalManifestFailure", "Intentional preflush manifest failure."); +end +end + +function resetPreflushManifestFault() +global labkitPreflushManifestFaultCount +labkitPreflushManifestFaultCount = 0; +end + +function [folder, events] = writeJournalSession(root, sessionId, appId) +app = journalProbeDefinition(appId); +journal = labkit.app.internal.SessionJournal(app, ... + RootFolder=root, SessionId=sessionId, BufferRecordLimit=1); +stream = labkit.app.internal.SessionEventStream(app, ... + SessionId=sessionId, ProjectionHook=@journal.append); +stream.log("info", "analysis.completed", "Analysis completed.", ... + Category="app.probe.journal.analysisRun", Audience="user"); +stream.close(); +journal.close(); +folder = journal.folder(); +events = readCanonicalEvents(folder); +end + +function marker = markSessionActive(folder, heartbeatAtUtc, nonce, pid) +if nargin < 2 + heartbeatAtUtc = utcOffset(0); +end +if nargin < 3 + nonce = "nonce-active"; +end +if nargin < 4 + pid = 41; +end +manifest = readJson(folder, "manifest.json"); +manifest.state = "active"; +manifest.closedAtUtc = ""; +manifest.lease = struct("nonce", string(nonce), "leaseVersion", 1); +writeJson(fullfile(folder, "manifest.json"), manifest); +marker = labkit.app.internal.SessionLease.create( ... + manifest.sessionId, manifest.appId, "2029-12-31T23:59:00.000Z", ... + heartbeatAtUtc, nonce, leaseOwnerProbe(pid)); +writeJson(fullfile(folder, "active.json"), marker); +end + +function probe = leaseOwnerProbe(pid) +if nargin < 1 + pid = 41; +end +probe = struct("host", "fixture-host", "pid", pid, "targetPid", pid, ... + "processState", "unknown"); +end + +function manifest = activeLeaseManifest(marker) +manifest = struct("sessionId", marker.sessionId, "appId", marker.appId, ... + "state", "active", "lease", struct("nonce", marker.nonce, ... + "leaseVersion", marker.leaseVersion)); +end + +function probe = leaseProcessProbe(targetPid, state) +probe = struct("host", "fixture-host", "pid", 99, "targetPid", targetPid, ... + "processState", string(state)); +end + +function probe = remoteLeaseProbe(targetPid, state) +probe = struct("host", "remote-host", "pid", 99, "targetPid", targetPid, ... + "processState", string(state)); +end + +function probe = deadLeaseProbe(targetPid, ~) +probe = leaseProcessProbe(targetPid, "dead"); +end + +function probe = mixedLeaseProbe(targetPid, ~) +if targetPid == 42 + state = "dead"; +elseif targetPid == 43 + state = "unknown"; +else + state = "alive"; +end +probe = leaseProcessProbe(targetPid, state); +end + +function failActiveMarker(stage) +if string(stage) == "activeMarker" + error("labkit:test:LeaseMarkerFailure", "Intentional active marker failure."); +end +end + +function resetManifestFault() +global labkitSessionJournalManifestCount +labkitSessionJournalManifestCount = 0; +end + +function resetLeaseClock(values) +global labkitSessionJournalLeaseClockValues labkitSessionJournalLeaseClockIndex +if nargin < 1 + labkitSessionJournalLeaseClockValues = strings(0, 1); +else + labkitSessionJournalLeaseClockValues = string(values(:)); +end +labkitSessionJournalLeaseClockIndex = 0; +end + +function value = nextLeaseClock() +global labkitSessionJournalLeaseClockValues labkitSessionJournalLeaseClockIndex +labkitSessionJournalLeaseClockIndex = labkitSessionJournalLeaseClockIndex + 1; +index = min(labkitSessionJournalLeaseClockIndex, ... + numel(labkitSessionJournalLeaseClockValues)); +value = labkitSessionJournalLeaseClockValues(index); +end + +function failSecondManifest(stage) +global labkitSessionJournalManifestCount +if string(stage) ~= "manifest" + return; +end +labkitSessionJournalManifestCount = labkitSessionJournalManifestCount + 1; +if labkitSessionJournalManifestCount >= 2 + error("labkit:test:LeaseManifestFailure", "Intentional closing manifest failure."); +end +end + +function markSessionTimestamp(folder, state, timestamp) +manifest = readJson(folder, "manifest.json"); +manifest.state = state; +manifest.closedAtUtc = timestamp; +manifest.updatedAtUtc = timestamp; +writeJson(fullfile(folder, "manifest.json"), manifest); +end + +function value = utcOffset(dayOffset) +value = string(datetime("now", TimeZone="UTC") + days(dayOffset), ... + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); +end + +function value = readJson(folder, filename) +value = jsondecode(fileread(fullfile(folder, filename))); +end + +function writeJson(filepath, value) +file = fopen(filepath, "w", "n", "UTF-8"); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", jsonencode(value)); +clear cleanup +end + +function segment = onlySegment(folder) +segments = dir(fullfile(folder, "events-*.jsonl")); +segment = fullfile(segments(1).folder, segments(1).name); +end + +function appendText(filepath, text) +file = fopen(filepath, "a", "n", "UTF-8"); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", text); +clear cleanup +end + +function lines = nonemptyLines(filepath) +lines = readlines(filepath); +lines = lines(strlength(lines) > 0); +end + +function events = readCanonicalEvents(folder) +events = nonemptyLines(onlySegment(folder)); +end + +function definition = journalProbeDefinition(appId) +if nargin < 1 + appId = "probe.session-journal"; +end +definition = labkit.app.Definition( ... + "Entrypoint", "labkit_SessionJournalProbe_app", ... + "AppId", appId, "Title", "Session journal probe", ... + "Family", "Tests", "AppVersion", "1.0.0", "Updated", "2026-07-25", ... + "Requirements", [], "Workbench", labkit.app.layout.workbench({})); +end diff --git a/tests/specs/framework/app/SessionLogProjectionSpec.m b/tests/specs/framework/app/SessionLogProjectionSpec.m new file mode 100644 index 000000000..3a4d458ec --- /dev/null +++ b/tests/specs/framework/app/SessionLogProjectionSpec.m @@ -0,0 +1,184 @@ +classdef SessionLogProjectionSpec < matlab.unittest.TestCase + % SESSIONLOGPROJECTIONSPEC Invariant: standard viewer filtering and clear-view semantics preserve canonical session history. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function filtersTheDefaultViewWithoutChangingCanonicalHistory(testCase) + runtime = projectionRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); + before = runtime.diagnosticEvents(); + projection = labkit.app.internal.SessionLogProjection( ... + runtime.diagnosticSnapshot()); + + defaultView = projection.view(); + testCase.verifyEqual( ... + sort(string(defaultView.rows.Level)), ... + ["INFO"; "WARNING"]); + testCase.verifyTrue(any( ... + defaultView.rows.Message == ... + "Synthetic analysis completed.")); + testCase.verifyTrue(any( ... + defaultView.rows.Message == ... + "Synthetic fallback remained usable.")); + + projection.setFilters( ... + Level="debug", Audience="all", ... + Category="app.probe.log-projection.analysis", ... + Search="branch"); + filtered = projection.view(); + testCase.verifyEqual(height(filtered.rows), 1); + testCase.verifyEqual( ... + filtered.rows.Message, ... + "Synthetic branch selected."); + detail = projection.detail(filtered.rows.Sequence); + testCase.verifyEqual( ... + detail.eventName, "analysis.branch_selected"); + + projection.clearView(); + testCase.verifyEmpty(projection.view().events); + testCase.verifyEqual(runtime.diagnosticEvents(), before); + + projection.setFilters( ... + Level="default", Audience="default", ... + Category="", RootAction="", Search=""); + runtime.invokeAction("run"); + projection.update(runtime.diagnosticSnapshot()); + afterClear = projection.view(); + testCase.verifyEqual(height(afterClear.rows), 2); + testCase.verifyGreaterThan( ... + min(afterClear.rows.Sequence), max([before.sequence])); + clear cleanup + end + + function togglesTraceCaptureAndIsolatesAFailingConsumer(testCase) + runtime = projectionRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + token = runtime.subscribeDiagnostics(@failConsumer); + + runtime.invokeAction("run"); + records = runtime.diagnosticEvents(); + names = string({records.eventName}); + testCase.verifyFalse(any(names == "analysis.trace_step")); + + runtime.setTraceCapture(true); + runtime.invokeAction("run"); + snapshot = runtime.diagnosticSnapshot(); + names = string({snapshot.events.eventName}); + testCase.verifyTrue(snapshot.traceEnabled); + testCase.verifyTrue(any(names == "trace.capture_enabled")); + testCase.verifyTrue(any(names == "analysis.trace_step")); + + runtime.unsubscribeDiagnostics(token); + runtime.setTraceCapture(false); + snapshot = runtime.diagnosticSnapshot(); + testCase.verifyFalse(snapshot.traceEnabled); + clear cleanup + end + + function reportsEveryCaptureAndRetentionLimitation(testCase) + runtime = projectionRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + snapshot = runtime.diagnosticSnapshot(); + snapshot.inMemoryTruncated = true; + snapshot.journalAvailable = false; + snapshot.journalState = "unavailable"; + snapshot.droppedRecordCount = 2; + snapshot.coalescedRecordCount = 3; + snapshot.expiredSegmentCount = 1; + snapshot.degradationReason = "write-failure"; + projection = ... + labkit.app.internal.SessionLogProjection(snapshot); + + notices = projection.view().notices; + testCase.verifyTrue(any(contains(notices, "TRACE"))); + testCase.verifyTrue(any(contains(notices, "in-memory"))); + testCase.verifyTrue(any(contains(notices, "coalesced"))); + testCase.verifyTrue(any(contains(notices, "expired"))); + testCase.verifyTrue(any(contains(notices, "dropped"))); + testCase.verifyTrue(any(contains(notices, "write-failure"))); + clear cleanup + end + + function streamsProjectionHealthToConsumersInSequenceOrder(testCase) + definition = projectionDefinition(); + stream = labkit.app.internal.SessionEventStream( ... + definition, ProjectionHealthHook=@oneHealthNotice); + cleanup = onCleanup(@() stream.close()); + projection = labkit.app.internal.SessionLogProjection( ... + completeSnapshot(stream.captureSnapshot())); + token = stream.subscribe(@projection.append); + + stream.log( ... + "info", "analysis.completed", ... + "Synthetic analysis completed.", ... + Category="app.probe.log-projection.analysis", ... + Audience="user"); + projection.setFilters(Level="trace", Audience="all"); + events = projection.view().events; + + testCase.verifyGreaterThan( ... + min(diff(double([events.sequence]))), 0); + stream.unsubscribe(token); + clear cleanup + end + end +end + +function runtime = projectionRuntime(testCase) +journalRoot = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +definition = projectionDefinition(); +runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), [], ... + JournalRoot=journalRoot); +end + +function definition = projectionDefinition() +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button( ... + "run", "Run", @emitRecords, ... + Tooltip="Generate synthetic diagnostic records.")}); +definition = labkit.app.Definition( ... + Entrypoint="labkit_LogProjectionProbe_app", ... + AppId="probe.log-projection", ... + Title="Log projection probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-26", ... + Requirements=[], Workbench=layout); +end + +function applicationState = emitRecords( ... + applicationState, callbackContext) +category = "app.probe.log-projection.analysis"; +callbackContext.log( ... + "trace", "analysis.trace_step", ... + "Synthetic trace step.", Category=category, Audience="developer"); +callbackContext.log( ... + "debug", "analysis.branch_selected", ... + "Synthetic branch selected.", Category=category, Audience="developer"); +callbackContext.log( ... + "info", "analysis.completed", ... + "Synthetic analysis completed.", Category=category, Audience="user"); +callbackContext.log( ... + "warning", "analysis.fallback_used", ... + "Synthetic fallback remained usable.", ... + Category=category, Audience="developer"); +end + +function failConsumer(~) +error("labkit:test:ConsumerFailure", "Synthetic consumer failure."); +end + +function notifications = oneHealthNotice() +notifications = struct( ... + "eventName", "journal.degraded", ... + "reason", "write-failure", "count", 0); +end + +function snapshot = completeSnapshot(snapshot) +snapshot.journalAvailable = true; +snapshot.journalState = "healthy"; +snapshot.droppedRecordCount = 0; +snapshot.coalescedRecordCount = 0; +snapshot.expiredSegmentCount = 0; +snapshot.degradationReason = ""; +end diff --git a/tests/specs/framework/app/SessionLogViewerSpec.m b/tests/specs/framework/app/SessionLogViewerSpec.m new file mode 100644 index 000000000..f7d52d98c --- /dev/null +++ b/tests/specs/framework/app/SessionLogViewerSpec.m @@ -0,0 +1,225 @@ +classdef SessionLogViewerSpec < matlab.unittest.TestCase + % SESSIONLOGVIEWERSPEC Regression: ordinary hidden GUI sessions expose one interactive standard log viewer without mutating canonical history. + + methods (Test, TestTags = {'Contract:source', 'Env:hidden-gui'}) + function toolsMenuOpensOneLiveViewerAndControlsTrace(testCase) + runtime = viewerRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + appFigure = runtime.figureHandle(); + openMenu = oneHandle( ... + appFigure, "labkitAppUtilitySessionLog"); + traceMenu = oneHandle( ... + appFigure, "labkitAppUtilityTraceCapture"); + + invoke(openMenu.MenuSelectedFcn, openMenu, []); + viewerFigure = oneHandle( ... + groot, "labkitSessionLogViewer"); + testCase.verifyEqual(string(viewerFigure.Visible), "off"); + tableHandle = oneHandle( ... + viewerFigure, "labkitSessionLogTable"); + originalMessageWidth = tableHandle.ColumnWidth{4}; + viewerFigure.Position(3) = viewerFigure.Position(3) + 120; + invoke(viewerFigure.SizeChangedFcn, viewerFigure, []); + testCase.verifyGreaterThan( ... + tableHandle.ColumnWidth{4}, originalMessageWidth); + invoke(openMenu.MenuSelectedFcn, openMenu, []); + testCase.verifyNumElements(findall( ... + groot, "Tag", "labkitSessionLogViewer"), 1); + invoke(viewerFigure.CloseRequestFcn, viewerFigure, []); + testCase.verifyEmpty(findall( ... + groot, "Tag", "labkitSessionLogViewer")); + invoke(openMenu.MenuSelectedFcn, openMenu, []); + testCase.verifyNumElements(findall( ... + groot, "Tag", "labkitSessionLogViewer"), 1); + + invoke(traceMenu.MenuSelectedFcn, traceMenu, []); + testCase.verifyEqual(string(traceMenu.Checked), "on"); + snapshot = runtime.diagnosticSnapshot(); + testCase.verifyTrue(snapshot.traceEnabled); + invoke(traceMenu.MenuSelectedFcn, traceMenu, []); + testCase.verifyEqual(string(traceMenu.Checked), "off"); + snapshot = runtime.diagnosticSnapshot(); + testCase.verifyFalse(snapshot.traceEnabled); + clear cleanup + end + + function inspectsEarlierDebugFiltersLongMessagesAndClearsOnlyView(testCase) + runtime = viewerRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); + historyCount = numel(runtime.diagnosticEvents()); + appFigure = runtime.figureHandle(); + openMenu = oneHandle( ... + appFigure, "labkitAppUtilitySessionLog"); + invoke(openMenu.MenuSelectedFcn, openMenu, []); + viewerFigure = oneHandle( ... + groot, "labkitSessionLogViewer"); + tableHandle = oneHandle( ... + viewerFigure, "labkitSessionLogTable"); + level = oneHandle( ... + viewerFigure, "labkitSessionLogLevel"); + audience = oneHandle( ... + viewerFigure, "labkitSessionLogAudience"); + + testCase.verifyEqual( ... + string(tableHandle.Data.Properties.VariableNames), ... + ["Time", "Level", "Area", "Message"]); + testCase.verifyTrue(any( ... + string(tableHandle.Data.Level) == "ERROR")); + level.Value = "debug"; + invoke(level.ValueChangedFcn, level, []); + audience.Value = "all"; + invoke(audience.ValueChangedFcn, audience, []); + testCase.verifyTrue(any( ... + string(tableHandle.Data.Message) == ... + "Synthetic branch selected.")); + testCase.verifyTrue(any(contains( ... + string(tableHandle.Data.Message), ... + "Synthetic long message"))); + + errorRow = find( ... + string(tableHandle.Data.Level) == "ERROR", 1); + invoke(tableHandle.CellSelectionCallback, ... + tableHandle, struct("Indices", [errorRow 1])); + detail = oneHandle( ... + viewerFigure, "labkitSessionLogDetail"); + testCase.verifyTrue(any(contains( ... + string(detail.Value), "analysis.failed"))); + testCase.verifyTrue(any(contains( ... + string(detail.Value), "labkit:test:SyntheticIncident"))); + + clearButton = oneHandle( ... + viewerFigure, "labkitSessionLogClear"); + invoke(clearButton.ButtonPushedFcn, clearButton, []); + testCase.verifyEqual(height(tableHandle.Data), 0); + testCase.verifyEqual( ... + numel(runtime.diagnosticEvents()), historyCount); + clear cleanup + end + + function retainsOneNativeTableAcrossALargeIncrementalBurst(testCase) + runtime = viewerRuntime(testCase); + cleanup = onCleanup(@() runtime.close()); + appFigure = runtime.figureHandle(); + openMenu = oneHandle( ... + appFigure, "labkitAppUtilitySessionLog"); + invoke(openMenu.MenuSelectedFcn, openMenu, []); + viewerFigure = oneHandle( ... + groot, "labkitSessionLogViewer"); + tableHandle = oneHandle( ... + viewerFigure, "labkitSessionLogTable"); + + runtime.invokeAction("burst"); + + testCase.verifyTrue(isvalid(tableHandle)); + testCase.verifyEqual( ... + oneHandle(viewerFigure, ... + "labkitSessionLogTable"), tableHandle); + testCase.verifyGreaterThanOrEqual( ... + height(tableHandle.Data), 300); + testCase.verifyLessThanOrEqual( ... + height(tableHandle.Data), 512); + clear cleanup + end + + function exportsTheLiveBundleFromToolsAndTheViewer(testCase) + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + destination = fullfile(folder, "live-diagnostics.zip"); + backend = struct( ... + "chooseOutputFile", @(~, ~) ... + labkit.app.dialog.Choice(destination)); + runtime = viewerRuntime(testCase, backend); + cleanup = onCleanup(@() runtime.close()); + runtime.invokeAction("run"); + appFigure = runtime.figureHandle(); + exportMenu = oneHandle( ... + appFigure, "labkitAppUtilityExportDiagnostics"); + + invoke(exportMenu.MenuSelectedFcn, exportMenu, []); + testCase.verifyTrue(isfile(destination)); + + openMenu = oneHandle( ... + appFigure, "labkitAppUtilitySessionLog"); + invoke(openMenu.MenuSelectedFcn, openMenu, []); + viewerFigure = oneHandle( ... + groot, "labkitSessionLogViewer"); + exportButton = oneHandle( ... + viewerFigure, "labkitSessionLogExport"); + invoke(exportButton.ButtonPushedFcn, exportButton, []); + testCase.verifyTrue(isfile(destination)); + records = runtime.diagnosticEvents(); + testCase.verifyGreaterThanOrEqual(sum( ... + string({records.eventName}) == ... + "diagnostics.bundle_exported.completed"), 2); + clear cleanup + end + end +end + +function runtime = viewerRuntime(testCase, backend) +if nargin < 2 + backend = struct(); +end +journalRoot = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button( ... + "run", "Run", @emitIncident, ... + Tooltip="Generate a synthetic incident."), ... + labkit.app.layout.button( ... + "burst", "Burst", @emitBurst, ... + Tooltip="Generate synthetic viewer load.")}); +definition = labkit.app.Definition( ... + Entrypoint="labkit_LogViewerProbe_app", ... + AppId="probe.log-viewer", ... + Title="Log viewer probe", Family="Tests", ... + AppVersion="1.0.0", Updated="2026-07-26", ... + Requirements=[], Workbench=layout); +runtime = labkit.app.internal.RuntimeFactory.createMatlab( ... + definition, [], backend, [], ... + JournalRoot=journalRoot); +end + +function applicationState = emitBurst( ... + applicationState, callbackContext) +for index = 1:300 + callbackContext.log( ... + "info", "analysis.progress", ... + "Synthetic progress update.", ... + Category="app.probe.log-viewer.analysis", ... + Audience="user", Attributes=struct("count", index)); +end +end + +function applicationState = emitIncident( ... + applicationState, callbackContext) +category = "app.probe.log-viewer.analysis"; +callbackContext.log( ... + "debug", "analysis.branch_selected", ... + "Synthetic branch selected.", ... + Category=category, Audience="developer"); +callbackContext.log( ... + "info", "analysis.long_message", ... + "Synthetic long message " + string(repmat('x', 1, 240)), ... + Category=category, Audience="user"); +exception = MException( ... + "labkit:test:SyntheticIncident", "Synthetic incident."); +callbackContext.log( ... + "error", "analysis.failed", ... + "Synthetic analysis failed.", ... + Category=category, Audience="user", Exception=exception); +end + +function handle = oneHandle(parent, tag) +handle = findall(parent, "Tag", tag); +assert(isscalar(handle), "Expected one handle tagged " + tag + "."); +end + +function invoke(callback, varargin) +if isa(callback, "function_handle") + callback(varargin{:}); +else + feval(callback, varargin{:}); +end +end diff --git a/tests/specs/framework/app/SessionLoggingContractSpec.m b/tests/specs/framework/app/SessionLoggingContractSpec.m new file mode 100644 index 000000000..599acecd5 --- /dev/null +++ b/tests/specs/framework/app/SessionLoggingContractSpec.m @@ -0,0 +1,35 @@ +classdef SessionLoggingContractSpec < matlab.unittest.TestCase + %SESSIONLOGGINGCONTRACTSPEC Freeze the v1 retained-event migration target. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function retainsOnlyTheMinimalV1EventSchema(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = loggingProbeDefinition(); + journal = labkittest.temporarySessionJournal(definition, root); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + records = runtime.diagnosticEvents(); + + testCase.verifyNotEmpty(records); + testCase.verifyEqual(string(fieldnames(records)), [ ... + "schemaVersion"; "sequence"; "timestampUtc"; ... + "elapsedSeconds"; "severity"; "audience"; "category"; ... + "eventName"; "message"; "attributes"; "sessionId"; ... + "appId"; "operationId"; "parentOperationId"; ... + "rootActionId"; "operationResult"; "stateDisposition"; ... + "durationSeconds"; "exception"]); + clear cleanup + end + end +end + +function definition = loggingProbeDefinition() +definition = labkit.app.Definition( ... + "Entrypoint", "labkit_SessionLoggingContractProbe_app", ... + "AppId", "probe.session-logging", "Title", "Session logging probe", ... + "Family", "Tests", "AppVersion", "1.0.0", "Updated", "2026-07-25", ... + "Requirements", [], "Workbench", labkit.app.layout.workbench({})); +end diff --git a/tests/specs/framework/app/SessionLoggingPrivacyContractSpec.m b/tests/specs/framework/app/SessionLoggingPrivacyContractSpec.m new file mode 100644 index 000000000..765cea050 --- /dev/null +++ b/tests/specs/framework/app/SessionLoggingPrivacyContractSpec.m @@ -0,0 +1,28 @@ +classdef SessionLoggingPrivacyContractSpec < matlab.unittest.TestCase + %SESSIONLOGGINGPRIVACYCONTRACTSPEC Freeze App-facing retained-data privacy rules. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function rejectsRawPathsBeforeInvokingAnyLoggingBackend(testCase) + context = labkit.app.internal.CallbackContextFactory.disconnected(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + syntheticPath = string(fullfile(folder, "input.csv")); + + testCase.verifyError(@() context.log("info", "source.loaded", ... + "Loaded " + syntheticPath + ".", ... + Category="sourceFiles"), "labkit:app:contract:UnsafeLogData"); + end + + function rejectsRawFilenamesInAttributesBeforeInvokingAnyLoggingBackend(testCase) + context = labkit.app.internal.CallbackContextFactory.disconnected(); + folder = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + syntheticPath = string(fullfile(folder, "input.csv")); + + testCase.verifyError(@() context.log("info", "source.loaded", ... + "Selected source loaded.", Category="sourceFiles", ... + Attributes=struct("sourcePath", syntheticPath)), ... + "labkit:app:contract:UnsafeLogData"); + end + end +end diff --git a/tests/specs/framework/app/SessionLoggingRuntimeSpec.m b/tests/specs/framework/app/SessionLoggingRuntimeSpec.m new file mode 100644 index 000000000..4dfc80f09 --- /dev/null +++ b/tests/specs/framework/app/SessionLoggingRuntimeSpec.m @@ -0,0 +1,379 @@ +classdef SessionLoggingRuntimeSpec < matlab.unittest.TestCase + %SESSIONLOGGINGRUNTIMESPEC Verify Runtime callback canonical event chains. + + methods (Test, TestTags = {'Contract:source', 'Env:headless'}) + function defaultFactorySessionIdentityDoesNotChangeRng(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe); + before = rng; + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), [], ... + JournalRoot=root); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyEqual(rng, before); + clear cleanup + end + + function rejectsJournalRootAlongsideAnExplicitJournal(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe); + journal = labkit.app.internal.SessionJournal(definition, RootFolder=root); + cleanup = onCleanup(@() journal.close()); + + testCase.verifyError(@() labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal, ... + JournalRoot=root), "labkit:app:runtime:InvariantFailure"); + clear cleanup + end + + function rejectsUnknownJournalOptionsBeforeTheSeamInvariant(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe); + journal = labkit.app.internal.SessionJournal(definition, RootFolder=root); + cleanup = onCleanup(@() journal.close()); + + testCase.verifyError(@() labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal, ... + UnknownJournalOption=root), "labkit:app:contract:UnknownArgument"); + clear cleanup + end + + function recordsOneCompleteCallbackChain(testCase) + runtime = runtimeWithJournal(testCase, "session-runtime-complete", ... + "run", @runLoggingProbe); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + records = runtime.diagnosticEvents(); + started = records(string({records.eventName}) == "callback.pressed.started"); + logged = records(string({records.eventName}) == "analysis.completed"); + completed = records(string({records.eventName}) == "callback.pressed.completed"); + + testCase.verifyNumElements(started, 1); + testCase.verifyNumElements(logged, 1); + testCase.verifyNumElements(completed, 1); + testCase.verifyEqual(logged.operationId, started.operationId); + testCase.verifyEqual(logged.rootActionId, started.rootActionId); + testCase.verifyEqual(completed.operationId, started.operationId); + testCase.verifyEqual(string(completed.operationResult), "completed"); + testCase.verifyEqual(string(completed.stateDisposition), "committed"); + testCase.verifyFalse(any(startsWith(string({records.eventName}), "journal."))); + runtime.close(); + records = runtime.diagnosticEvents(); + testCase.verifyFalse(any(startsWith(string({records.eventName}), "journal."))); + clear cleanup + end + + function recordsOneRollbackWithoutAFalseCompletedResult(testCase) + runtime = runtimeWithJournal(testCase, "session-runtime-fail", ... + "fail", @failLoggingProbe); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyError(@() runtime.invokeAction("fail"), ... + "labkit:app:runtime:ActionFailed"); + records = runtime.diagnosticEvents(); + failed = records(string({records.eventName}) == "callback.pressed.failed"); + completed = records(string({records.eventName}) == "callback.pressed.completed"); + + testCase.verifyNumElements(failed, 1); + testCase.verifyEmpty(completed); + testCase.verifyEqual(failed.operationResult, "failed"); + testCase.verifyEqual(failed.stateDisposition, "rolledBack"); + testCase.verifyEqual(failed.exception.identifier, "probe:ExpectedFailure"); + clear cleanup + end + + function persistsRollbackFailureChainOnClose(testCase) + [runtime, journal, root] = runtimeWithJournal(testCase, ... + "session-runtime-persisted-failure", "fail", @failLoggingProbe); + cleanup = onCleanup(@() runtime.close()); + + testCase.verifyError(@() runtime.invokeAction("fail"), ... + "labkit:app:runtime:ActionFailed"); + runtime.close(); + snapshot = labkit.app.internal.SessionJournalArchive.snapshot( ... + root, journal.sessionId()); + names = string({snapshot.events.eventName}); + started = snapshot.events(names == "callback.pressed.started"); + failed = snapshot.events(names == "callback.pressed.failed"); + completed = snapshot.events(names == "callback.pressed.completed"); + + testCase.verifyNumElements(started, 1); + testCase.verifyNumElements(failed, 1); + testCase.verifyEmpty(completed); + testCase.verifyEqual(failed.operationId, started.operationId); + testCase.verifyEqual(failed.parentOperationId, started.parentOperationId); + testCase.verifyEqual(failed.rootActionId, started.rootActionId); + testCase.verifyEqual(string(failed.operationResult), "failed"); + testCase.verifyEqual(string(failed.stateDisposition), "rolledBack"); + testCase.verifyEqual(string(failed.exception.identifier), "probe:ExpectedFailure"); + testCase.verifyEqual(string(failed.exception.message), "Exception captured."); + testCase.verifyEqual(string(snapshot.manifest.state), "closed"); + clear cleanup + end + + function persistsCorrelationCompleteCallbackHistoryOnClose(testCase) + [runtime, journal, root] = runtimeWithJournal(testCase, ... + "session-runtime-persisted", "run", @runLoggingProbe); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + runtime.close(); + snapshot = labkit.app.internal.SessionJournalArchive.snapshot( ... + root, journal.sessionId()); + names = string({snapshot.events.eventName}); + started = snapshot.events(names == "callback.pressed.started"); + completed = snapshot.events(names == "callback.pressed.completed"); + + testCase.verifyNumElements(started, 1); + testCase.verifyNumElements(completed, 1); + testCase.verifyEqual(started.rootActionId, completed.rootActionId); + testCase.verifyEqual(string(completed.operationResult), "completed"); + testCase.verifyEqual(string(completed.stateDisposition), "committed"); + testCase.verifyEqual(string(snapshot.manifest.state), "closed"); + clear cleanup + end + + function journalWriteFailureDoesNotChangeCallbackOutcome(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe); + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=root, SessionId="session-runtime-write-failure", ... + FaultInjector=@failJournalWrite); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + runtime.close(); + records = runtime.diagnosticEvents(); + manifest = journal.manifest(); + + testCase.verifyTrue(any(string({records.eventName}) == ... + "callback.pressed.completed")); + testCase.verifyEqual(runtime.State.project, struct()); + testCase.verifyEqual(manifest.degradation.writeFailureCount, 1); + testCase.verifyGreaterThan(manifest.degradation.droppedRecordCount, 0); + testCase.verifyGreaterThan( ... + manifest.degradation.dropReasons.writeFailure, 0); + clear cleanup + end + + function reportsUnavailableJournalDuringRuntimeConstruction(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe); + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=root, SessionId="session-runtime-initialize-failure", ... + FaultInjector=@failJournalInitialize); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + records = runtime.diagnosticEvents(); + degraded = records(string({records.eventName}) == "journal.degraded"); + dropped = records(string({records.eventName}) == "journal.records_dropped"); + health = journal.healthSnapshot(); + dropAttributes = [dropped.attributes]; + + testCase.verifyFalse(health.available); + testCase.verifyEqual(health.state, "unavailable"); + testCase.verifyNumElements(degraded, 1); + testCase.verifyNotEmpty(dropped); + testCase.verifyEqual(degraded.attributes.reason, "initialize-failure"); + testCase.verifyEqual(sum([dropAttributes.count]), health.droppedRecordCount); + clear cleanup + end + + function reportsMidCallbackJournalFailureWithoutChangingOutcome(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runWarningLoggingProbe); + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=root, SessionId="session-runtime-mid-callback-failure", ... + FaultInjector=@failJournalWrite, BufferRecordLimit=64); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); + cleanup = onCleanup(@() runtime.close()); + + runtime.invokeAction("run"); + records = runtime.diagnosticEvents(); + started = records(string({records.eventName}) == "callback.pressed.started"); + completed = records(string({records.eventName}) == "callback.pressed.completed"); + degraded = records(string({records.eventName}) == "journal.degraded"); + dropped = records(string({records.eventName}) == "journal.records_dropped"); + health = journal.healthSnapshot(); + dropAttributes = [dropped.attributes]; + + testCase.verifyNumElements(completed, 1); + testCase.verifyNumElements(started, 1); + testCase.verifyEqual(completed.stateDisposition, "committed"); + testCase.verifyEqual(runtime.State.project, struct()); + testCase.verifyNumElements(degraded, 1); + testCase.verifyNotEmpty(dropped); + testCase.verifyEqual(sum([dropAttributes.count]), health.writeFailureDropCount); + testCase.verifyTrue(all(string({dropAttributes.reason}) == "write-failure")); + healthRecords = [degraded; dropped]; + testCase.verifyTrue(all(string({healthRecords.rootActionId}) == started.rootActionId)); + operationIds = string({healthRecords.operationId}); + rootOperations = records( ... + string({records.rootActionId}) == started.rootActionId & ... + endsWith(string({records.eventName}), ".started")); + testCase.verifyTrue(all(ismember( ... + operationIds, string({rootOperations.operationId})))); + clear cleanup + end + + function recordsCloseTimeJournalFailureAfterSessionClosed(testCase) + global labkitRuntimeManifestFaultCount labkitRuntimeJournalStages + labkitRuntimeManifestFaultCount = 0; + labkitRuntimeJournalStages = strings(0, 1); + resetFault = onCleanup(@resetRuntimeManifestFault); + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe); + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=root, SessionId="session-runtime-close-failure", ... + FaultInjector=@failClosingRuntimeManifest, BufferRecordLimit=64, ... + TestObserver=@recordRuntimeJournalStage); + runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); + + testCase.verifyEqual(labkitRuntimeManifestFaultCount, 1); + testCase.verifyEmpty(labkitRuntimeJournalStages( ... + labkitRuntimeJournalStages == "flush")); + runtime.close(); + records = runtime.diagnosticEvents(); + names = string({records.eventName}); + sessionClosedIndex = find(names == "session.closed", 1); + degradedIndex = find(names == "journal.degraded", 1); + journalNames = journalEventNames(journal.folder()); + health = journal.healthSnapshot(); + + testCase.verifyNotEmpty(sessionClosedIndex); + testCase.verifyNotEmpty(degradedIndex); + testCase.verifyGreaterThan(degradedIndex, sessionClosedIndex); + testCase.verifyEqual(records(degradedIndex).attributes.reason, "manifest-failure"); + testCase.verifyEqual(health.state, "closed"); + testCase.verifyFalse(health.available); + testCase.verifyFalse(any(startsWith(journalNames, "journal."))); + testCase.verifyEqual(labkitRuntimeManifestFaultCount, 3); + testCase.verifyNumElements(labkitRuntimeJournalStages( ... + labkitRuntimeJournalStages == "flush"), 1); + clear resetFault + end + + + function closesJournalWhenRuntimeConstructionFails(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + definition = runtimeProbeDefinition("run", @runLoggingProbe, ... + @failRuntimeSession); + journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=root, SessionId="session-runtime-construction-failure"); + + testCase.verifyError(@() labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal), ... + "probe:SessionConstructionFailure"); + + testCase.verifyEqual(string(journal.manifest().state), "closed"); + testCase.verifyFalse(isfile(fullfile(journal.folder(), "active.json"))); + end + end +end + +function [runtime, journal, root] = runtimeWithJournal(testCase, sessionId, id, callback) +root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +definition = runtimeProbeDefinition(id, callback); +journal = labkit.app.internal.SessionJournal(definition, ... + RootFolder=root, SessionId=sessionId); +runtime = labkit.app.internal.RuntimeFactory.createHeadless( ... + definition, [], struct(), journal); +end + +function failJournalWrite(stage) +if string(stage) == "write" + error("labkit:test:JournalWriteFailure", "Intentional writer failure."); +end +end + +function failJournalInitialize(stage) +if string(stage) == "initialize" + error("labkit:test:JournalInitializeFailure", "Intentional initialization failure."); +end +end + +function state = runWarningLoggingProbe(state, callbackContext) +callbackContext.log("warning", "analysis.warning", "Warning retained.", ... + Category="analysisRun"); +end + +function failClosingRuntimeManifest(stage) +global labkitRuntimeManifestFaultCount +if string(stage) ~= "manifest" + return; +end +labkitRuntimeManifestFaultCount = labkitRuntimeManifestFaultCount + 1; +if labkitRuntimeManifestFaultCount >= 2 + error("labkit:test:JournalManifestFailure", "Intentional closing manifest failure."); +end +end + +function resetRuntimeManifestFault() +global labkitRuntimeManifestFaultCount labkitRuntimeJournalStages +labkitRuntimeManifestFaultCount = 0; +labkitRuntimeJournalStages = strings(0, 1); +end + +function recordRuntimeJournalStage(stage, ~) +global labkitRuntimeJournalStages +labkitRuntimeJournalStages(end + 1, 1) = string(stage); +end + +function names = journalEventNames(folder) +segments = dir(fullfile(folder, "events-*.jsonl")); +names = strings(0, 1); +for index = 1:numel(segments) + lines = readlines(fullfile(segments(index).folder, segments(index).name)); + lines = lines(strlength(lines) > 0); + for line = lines.' + record = jsondecode(line); + names(end + 1, 1) = string(record.eventName); + end +end +end + +function state = runLoggingProbe(state, callbackContext) +callbackContext.log("info", "analysis.completed", "Analysis completed.", ... + Category="analysisRun", Attributes=struct("validItemCount", 2)); +end + +function state = failLoggingProbe(state, ~) +error("probe:ExpectedFailure", "Expected rollback failure."); +end + +function definition = runtimeProbeDefinition(id, callback, createSession) +if nargin < 3 + createSession = []; +end +layout = labkit.app.layout.workbench({ ... + labkit.app.layout.button(id, "Run", callback, Tooltip="Run the probe.")}); +arguments = { ... + "Entrypoint", "labkit_SessionLoggingRuntimeProbe_app", ... + "AppId", "probe.session-logging-runtime", "Title", "Runtime logging probe", ... + "Family", "Tests", "AppVersion", "1.0.0", "Updated", "2026-07-25", ... + "Requirements", [], "Workbench", layout}; +if ~isempty(createSession) + arguments = [arguments, {"CreateSession", createSession}]; +end +definition = labkit.app.Definition(arguments{:}); +end + +function session = failRuntimeSession(~, ~) +error("probe:SessionConstructionFailure", "Intentional session construction failure."); +end diff --git a/tests/specs/framework/dta/DtaFacadeSpec.m b/tests/specs/framework/dta/DtaFacadeSpec.m index 87de819d6..7386219e4 100644 --- a/tests/specs/framework/dta/DtaFacadeSpec.m +++ b/tests/specs/framework/dta/DtaFacadeSpec.m @@ -32,9 +32,12 @@ function loadsCanonicalItemsAndReportsRecoverableFailures(testCase) testCase.verifyTrue(chronoStatus.ok, chronoStatus.message); testCase.verifyEqual(chronoItem.type, "chrono"); testCase.verifyTrue(all(isfield(chronoItem, {'t_s', 'Vf_V', 'Im_A'}))); + testCase.verifyFalse(any(isfield(chronoItem, {'t', 'Vf', 'Im'}))); testCase.verifyTrue(eisStatus.ok, eisStatus.message); testCase.verifyEqual(eisItem.type, "eis"); testCase.verifyTrue(all(isfield(eisItem, {'freq_Hz', 'Zreal_ohm', 'Zimag_ohm'}))); + testCase.verifyFalse(any(isfield(eisItem, ... + {'Freq', 'Zreal', 'Zimag', 'Pt', 'Time'}))); testCase.verifyTrue(cvctStatus.ok, cvctStatus.message); testCase.verifyEqual(cvctItem.type, "cvct"); testCase.verifyNotEmpty(cvctItem.curves); diff --git a/tests/specs/framework/dta/DtaPulseSpec.m b/tests/specs/framework/dta/DtaPulseSpec.m index 93f8769c9..d5002646a 100644 --- a/tests/specs/framework/dta/DtaPulseSpec.m +++ b/tests/specs/framework/dta/DtaPulseSpec.m @@ -16,14 +16,20 @@ function prefersValidMetadataAndFallsBackToCurrent(testCase) testCase.verifyTrue(fromMetadata.ok, metadataMessage); testCase.verifyEqual(fromMetadata.method, 'metadata-current'); - testCase.verifyEqual([fromMetadata.cath_start, fromMetadata.cath_end], ... + testCase.verifyEqual( ... + [fromMetadata.cath.start_s, fromMetadata.cath.end_s], ... [0, 0.10], 'AbsTol', 1e-12); - testCase.verifyEqual([fromMetadata.gap_start, fromMetadata.gap_end], ... + testCase.verifyEqual( ... + [fromMetadata.gap.start_s, fromMetadata.gap.end_s], ... [0.10, 0.14], 'AbsTol', 1e-12); testCase.verifyTrue(fromCurrent.ok, currentMessage); testCase.verifyEqual(fromCurrent.method, 'auto-from-Im'); - testCase.verifyEqual([fromCurrent.cath_start, fromCurrent.anod_start], ... + testCase.verifyEqual( ... + [fromCurrent.cath.start_s, fromCurrent.anod.start_s], ... [0.03, 0.14], 'AbsTol', 1e-12); + testCase.verifyFalse(isfield(fromMetadata, "cath_start")); + testCase.verifyTrue(all(isfield(fromMetadata, ... + ["pre", "cath", "gap", "anod", "post"]))); end function reportsWhenRequestedMetadataCannotProveAPulse(testCase) @@ -43,6 +49,21 @@ function reportsWhenRequestedMetadataCannotProveAPulse(testCase) testCase.verifyFalse(metadataOnly.ok); testCase.verifySubstring(metadataOnlyMessage, 'no ISTEP/TSTEP or VSTEP/TSTEP'); end + + function rejectsUnknownModesInsteadOfRunningTheDefaultBranch(testCase) + time = (0:0.01:0.25).'; + current = zeros(size(time)); + current(time >= 0.03 & time <= 0.10) = -1e-3; + current(time >= 0.14 & time <= 0.20) = 1e-3; + + [pulse, message] = labkit.dta.detectPulses( ... + time, current, validMetadata(), "not-a-mode"); + + testCase.verifyFalse(pulse.ok); + testCase.verifySubstring(string(message), ... + "Unsupported pulse detection mode"); + testCase.verifyEqual(string(pulse.method), "-"); + end end end diff --git a/tests/specs/framework/image/ImageFacadeSpec.m b/tests/specs/framework/image/ImageFacadeSpec.m index 2d706826f..01c318398 100644 --- a/tests/specs/framework/image/ImageFacadeSpec.m +++ b/tests/specs/framework/image/ImageFacadeSpec.m @@ -38,6 +38,7 @@ function preservesConversionResizeAndPathContracts(testCase) [preview, scale] = labkit.image.resizeToFit(image, "MaxHeight", 6); [budget, info] = labkit.image.previewBudget(image, "MaxPixels", 40, "Expansion", 4); + [native, nativeInfo] = labkit.image.previewBudget(image); rgb = labkit.image.ensureRgb(labkit.image.im2double(uint8(100 * ones(4, 5)))); paths = labkit.image.normalizePaths([" a.png "; ""; "b.JPG"]); @@ -47,6 +48,9 @@ function preservesConversionResizeAndPathContracts(testCase) testCase.verifyEqual(scale, 0.5, "AbsTol", 1e-12); testCase.verifyLessThan(size(budget, 1), size(image, 1)); testCase.verifyEqual(info.coordinateScale, 0.2, "AbsTol", 1e-12); + testCase.verifyEqual(native, image); + testCase.verifyEqual(nativeInfo.scaleFactor, 1); + testCase.verifyTrue(isinf(nativeInfo.maxPixels)); testCase.verifyEqual(paths, ["a.png"; "b.JPG"]); testCase.verifyTrue(labkit.image.isSupportedPath("sample.TIFF")); testCase.verifyFalse(labkit.image.isSupportedPath("sample.txt")); diff --git a/tests/specs/system/build/TestCatalogSpec.m b/tests/specs/system/build/TestCatalogSpec.m index 872425b2a..3557bbd3c 100644 --- a/tests/specs/system/build/TestCatalogSpec.m +++ b/tests/specs/system/build/TestCatalogSpec.m @@ -195,6 +195,38 @@ function locateMapsPublicFrameworkFacadeToItsOwner(testCase) "tests", "specs", "framework", "dta"))); end + function installedLauncherUsesItsFocusedSystemOwner(testCase) + source = "+labkit/+app/+internal/+launcher/dispatch.m"; + root = labkittest.setup(); + classification = labkittest.classifyPath(source); + location = labkittest.locate(source); + + testCase.verifyEqual(classification.Role, "installed-launcher"); + testCase.verifyEqual(classification.Owner, "system/launcher"); + testCase.verifyEqual(location.Owner, "system/launcher"); + testCase.verifyEqual(location.Contract, "system"); + testCase.verifyEqual(location.Environment, "headless"); + testCase.verifyEqual(string(location.Folder), ... + string(fullfile(root, "tests", "specs", "system", "launcher"))); + end + + function versionManagementToolUsesItsFocusedSystemOwner(testCase) + source = "tools/deployment/manageLabKitVersions.m"; + root = labkittest.setup(); + classification = labkittest.classifyPath(source); + location = labkittest.locate(source); + + testCase.verifyEqual(classification.Role, "deployment-tool"); + testCase.verifyEqual(classification.Owner, "system/deployment"); + testCase.verifyEqual(location.Owner, "system/deployment"); + testCase.verifyEqual(location.Contract, "system"); + testCase.verifyEqual(location.Environment, "headless"); + testCase.verifyEqual(string(location.Folder), ... + string(fullfile(root, "tests", "specs", "system", "deployment"))); + package = labkittest.classifyPath("tools/deployment/packageLabKitApp.m"); + testCase.verifyEqual(package.Kind, "unknown"); + end + function locateNormalizesWindowsStyleRepositoryPaths(testCase) location = labkittest.locate( ... "apps\electrochem\cic\+cic\+analysisRun\computeCIC.m"); @@ -247,6 +279,21 @@ function everyProductionSourceHasAnExplicitEvidenceLocation(testCase) end end + function everyProjectSchemaHasOwnedPersistenceEvidence(testCase) + root = labkittest.setup(); + files = dir(fullfile(root, "apps", "**", "projectSpec.m")); + + testCase.verifyNotEmpty(files); + for k = 1:numel(files) + relative = erase(string(fullfile( ... + files(k).folder, files(k).name)), string(root) + filesep); + plan = labkittest.plan("File", relative); + contracts = string({plan.Descriptors.Contracts}); + testCase.verifyTrue(any(contracts == "persistence"), ... + "No persistence evidence is selected for " + relative); + end + end + function appLaunchersUseTheDefinitionEvidenceClosure(testCase) locations = labkittest.locate( ... "apps/electrochem/cic/labkit_CIC_app.m"); @@ -363,6 +410,27 @@ function changedProfileCombinesSemanticClosuresWithoutDuplicates(testCase) testCase.verifyEqual(numel(unique(string({result.Descriptors.Id}))), 4); end + function scopedAgentPolicySelectsRepositoryEvidence(testCase) + fixture = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture); + specsRoot = fullfile(fixture.Folder, "specs"); + repositoryOwner = fullfile(specsRoot, "system", "repository"); + mkdir(repositoryOwner); + testCase.writeSpec(repositoryOwner, ... + "RepositoryPolicySpec", "system"); + + result = labkittest.plan("Profile", "changed", ... + "ChangedPaths", "+labkit/AGENTS.md", ... + "SpecsRoot", specsRoot); + + testCase.verifyEqual(result.Classifications.Role, ... + "repository-policy"); + testCase.verifyEqual(string({result.Descriptors.Owner}), ... + "system/repository"); + testCase.verifyEqual(result.Descriptors.Contracts, ... + "system"); + end + function unknownChangedPathFailsPlanningInsteadOfWidening(testCase) specsRoot = testCase.createEnvironmentFixture(); @@ -452,13 +520,16 @@ function definitionFileSelectsItsCompleteConformanceEvidence(testCase) "File", "apps/electrochem/cic/+cic/definition.m"); testCase.verifyFalse(result.Fallback); - testCase.verifyEqual(numel(result.Descriptors), 3); + testCase.verifyEqual(numel(result.Descriptors), 5); testCase.verifyEqual(string({result.Descriptors.Id}), [ ... "AppDefinitionConformanceSpec/declaresThePublicAppContract(App=cic)", ... "AppSmokeConformanceSpec/launchesThroughTheSupportedDefinition(App=cic)", ... + "AppSmokeConformanceSpec/generatesSyntheticInputsWithoutMutatingRunningApp(App=cic)", ... + "AppSmokeConformanceSpec/launchesEverySyntheticProjectNatively(App=cic)", ... "AppIsolationConformanceSpec/verifiesEveryPublicAppFromAResetPathBoundary"]); testCase.verifyEqual(string({result.Descriptors.Environment}), ... - ["headless", "hidden-gui", "path-isolated"]); + ["headless", "hidden-gui", "hidden-gui", ... + "hidden-gui", "path-isolated"]); end function isolatedProbeReportsLaterAppsAfterAnEarlierFailure(testCase) @@ -627,12 +698,18 @@ function writeSpec(~, owner, className, contract, environment) definition = [ ... "function app = definition()", ... prefix, ... - "app = struct( ...", ... - " 'AppId', """ + package + """, ...", ... - " 'AppVersion', ""1.0.0"", ...", ... - " 'Requirements', labkit.contract.requirements(""app"", "">=1 <2""), ...", ... - " 'BuildDebugSample', @(context) labkit.app.diagnostic.SamplePack( ...", ... - " Scenario=""fixture"", InitialProject=struct(), Artifacts={}));", ... + "app = labkit.app.Definition( ...", ... + " Entrypoint=""labkit_" + package + "_app"", ...", ... + " AppId=""" + package + """, Title=""" + package + """, ...", ... + " Family=""Tests"", AppVersion=""1.0.0"", Updated=""2026-07-25"", ...", ... + " Requirements=labkit.contract.requirements(), ...", ... + " Workbench=labkit.app.layout.workbench({}), ...", ... + " ProjectSchema=labkit.app.project.Schema(), ...", ... + " BuildSyntheticSample=@syntheticSample);", ... + "end", ... + "function sample = syntheticSample(~)", ... + "sample = labkit.app.synthetic.Pack( ...", ... + " Scenario=""fixture"", InitialProject=struct(), Artifacts={});", ... "end"]; testCase.writeTextFile(fullfile(packageFolder, "definition.m"), ... strjoin(definition, newline)); diff --git a/tests/specs/system/deployment/VersionManagementSpec.m b/tests/specs/system/deployment/VersionManagementSpec.m new file mode 100644 index 000000000..89f2049a1 --- /dev/null +++ b/tests/specs/system/deployment/VersionManagementSpec.m @@ -0,0 +1,592 @@ +classdef VersionManagementSpec < matlab.unittest.TestCase + methods (Test, TestTags = {'Contract:system', 'Env:headless'}) + function gitFilesAndDirectoriesRejectEveryInstallModeBeforeNetwork(testCase) + modes = ["main", "stable", "install"]; + for gitKind = ["file", "directory"] + root = fixtureRoot(testCase, "old"); + if gitKind == "file" + writeText(fullfile(root, ".git"), "gitdir: synthetic-worktree"); + else + mkdir(fullfile(root, ".git")); + end + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + for mode = modes + sourceArgs = {}; + if mode == "install", sourceArgs = {"Source", selectedSource()}; end + testCase.verifyError(@() manageLabKitVersions(root, mode, sourceArgs{:}), ... + "LabKit:Deployment:GitCheckout"); + testCase.verifyFalse(isappdata(groot, "versionToolWebreadCount")); + testCase.verifyFalse(isappdata(groot, "versionToolWebsaveCalled")); + end + delete(toolCleanup); delete(networkCleanup) + end + end + + function invalidRootRejectsBeforeAnyNetworkRequest(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + + testCase.verifyError(@() manageLabKitVersions(root, "main"), ... + "LabKit:Deployment:InvalidRoot"); + testCase.verifyFalse(isappdata(groot, "versionToolWebreadCount")); + testCase.verifyFalse(isappdata(groot, "versionToolWebsaveCalled")); + delete(toolCleanup); delete(networkCleanup) + end + + function selectedCandidateMigratesLocalDataAndRetainsSiblingBackup(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + toolCleanup = isolatedTool(""); + writeAllLocalSentinels(root); + cleanup = setHook(struct("CandidateRoot", candidate, "Confirm", true)); + + result = manageLabKitVersions(root, "install", "Source", selectedSource()); + + testCase.verifyTrue(result.updated); + testCase.verifyEqual(readMarker(root), "new"); + verifyAllLocalSentinels(testCase, root); + testCase.verifyTrue(result.backupRetained); + testCase.verifyEqual(readMarker(result.backupFolder), "old"); + delete(cleanup); delete(toolCleanup) + end + + function installationWithoutLocalDataDeletesTheSiblingBackup(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + toolCleanup = isolatedTool(""); + cleanup = setHook(struct("CandidateRoot", candidate, "Confirm", true)); + + result = manageLabKitVersions(root, "install", "Source", selectedSource()); + + testCase.verifyTrue(result.updated); + testCase.verifyFalse(result.backupRetained); + testCase.verifyEmpty(dir(fullfile(fileparts(root), "*.version-backup-*"))); + delete(cleanup); delete(toolCleanup) + end + + function replacementAndRollbackRestoreOnlyValidRootPathSubtrees(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + toolCleanup = isolatedTool(""); + mkdir(fullfile(root, "apps", "retained")); + mkdir(fullfile(root, "tools", "retired")); + mkdir(fullfile(candidate, "apps", "retained")); + addpath(root, "-begin"); + addpath(fullfile(root, "apps", "retained"), "-begin"); + addpath(fullfile(root, "tools", "retired"), "-begin"); + cleanup = setHook(struct("CandidateRoot", candidate, "Confirm", true)); + + manageLabKitVersions(root, "install", "Source", selectedSource()); + + entries = normalizedPathEntries(); + testCase.verifyTrue(any(entries == normalizedPath(root))); + testCase.verifyTrue(any(entries == normalizedPath(fullfile(root, "apps", "retained")))); + testCase.verifyFalse(any(entries == normalizedPath(fullfile(root, "tools", "retired")))); + testCase.verifyFalse(any(contains(entries, ".version-backup-"))); + delete(cleanup); + + rollbackRoot = fixtureRoot(testCase, "rollback-old"); + rollbackCandidate = fixtureCandidate(testCase, "rollback-new"); + mkdir(fullfile(rollbackRoot, "apps", "retained")); + mkdir(fullfile(rollbackRoot, "tools", "restored")); + mkdir(fullfile(rollbackCandidate, "apps", "retained")); + addpath(rollbackRoot, "-begin"); + addpath(fullfile(rollbackRoot, "apps", "retained"), "-begin"); + addpath(fullfile(rollbackRoot, "tools", "restored"), "-begin"); + cleanup = setHook(struct( ... + "CandidateRoot", rollbackCandidate, ... + "Confirm", true, ... + "FailAfterBackup", true)); + testCase.verifyError(@() manageLabKitVersions( ... + rollbackRoot, "install", "Source", selectedSource()), ... + "LabKit:Deployment:InjectedFailure"); + entries = normalizedPathEntries(); + testCase.verifyEqual(readMarker(rollbackRoot), "rollback-old"); + testCase.verifyTrue(any(entries == normalizedPath(rollbackRoot))); + testCase.verifyTrue(any(entries == normalizedPath( ... + fullfile(rollbackRoot, "apps", "retained")))); + testCase.verifyTrue(any(entries == normalizedPath( ... + fullfile(rollbackRoot, "tools", "restored")))); + testCase.verifyFalse(any(contains(entries, ".version-backup-"))); + delete(cleanup); delete(toolCleanup) + end + + function cancelDoesNotDownload(testCase) + root = fixtureRoot(testCase, "old"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + cleanup = setHook(struct("Confirm", false)); + + result = manageLabKitVersions(root, "main"); + + testCase.verifyFalse(result.updated); + testCase.verifyFalse(isappdata(groot, "versionToolWebsaveCalled")); + delete(cleanup); delete(toolCleanup); delete(networkCleanup) + end + + function selectedSourceRejectsExternalArchiveUrls(testCase) + root = fixtureRoot(testCase, "old"); + toolCleanup = isolatedTool(""); + source = selectedSource(); + source.Url = "https://invalid.example/synthetic.zip"; + + testCase.verifyError(@() manageLabKitVersions( ... + root, "install", "Source", source), ... + "LabKit:Deployment:InvalidSource"); + testCase.verifyEqual(readMarker(root), "old"); + delete(toolCleanup) + end + + function invalidAndNestedCandidatesAreRejected(testCase) + root = fixtureRoot(testCase, "old"); + toolCleanup = isolatedTool(""); + invalid = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + cleanup = setHook(struct("CandidateRoot", invalid, "Confirm", true)); + testCase.verifyError(@() manageLabKitVersions(root, "install", "Source", selectedSource()), "LabKit:Deployment:InvalidCandidate"); + delete(cleanup) + nested = fullfile(root, "candidate"); + createCandidate(nested, "nested"); + cleanup = setHook(struct("CandidateRoot", nested, "Confirm", true)); + testCase.verifyError(@() manageLabKitVersions(root, "install", "Source", selectedSource()), "LabKit:Deployment:InvalidCandidate"); + delete(cleanup) + outer = fixtureCandidate(testCase, "outer"); + nestedRoot = fullfile(outer, "nested-root"); + createRoot(nestedRoot, "nested-old"); + cleanup = setHook(struct("CandidateRoot", outer, "Confirm", true)); + testCase.verifyError(@() manageLabKitVersions(nestedRoot, "install", "Source", selectedSource()), "LabKit:Deployment:InvalidCandidate"); + delete(cleanup); delete(toolCleanup) + end + + function candidateConflictAndInjectedFailureRollbackWithoutStalePaths(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + toolCleanup = isolatedTool(""); + writeText(fullfile(root, "photos", "sentinel.txt"), "old-local"); + writeText(fullfile(candidate, "photos", "sentinel.txt"), "candidate-local"); + conflictCleanup = setHook(struct("CandidateRoot", candidate, "Confirm", true)); + testCase.verifyError(@() manageLabKitVersions(root, "install", "Source", selectedSource()), ... + "LabKit:Deployment:LocalDataConflict"); + testCase.verifyEqual(readMarker(root), "old"); + delete(conflictCleanup) + + failureCleanup = setHook(struct("CandidateRoot", candidate, "Confirm", true, "FailAfterBackup", true)); + testCase.verifyError(@() manageLabKitVersions(root, "install", "Source", selectedSource()), ... + "LabKit:Deployment:InjectedFailure"); + testCase.verifyEqual(readMarker(root), "old"); + testCase.verifyFalse(any(contains(string(strsplit(path, pathsep)), ".version-backup-"))); + delete(failureCleanup); delete(toolCleanup) + end + + function browseSelectionAndDoubleClickInstallUseTheSelectedSource(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", { ... + struct("tag_name", {"v1.0.0", "v1.1.0"}, "name", {"First", "Second"}, ... + "published_at", {"2026-01-01", "2026-02-01"}, "draft", {false, false}, "prerelease", {false, false}), ... + struct("name", {}), struct("sha", {})}); + modeCleanup = setAppdata("labkitVersionManagerGuiTestMode", "hidden"); + hookCleanup = setHook(struct("CandidateRoot", candidate, "Confirm", true)); + + fig = manageLabKitVersions(root); + sourceTable = findall(fig, "Type", "uitable"); + invokeTableSelection(sourceTable, 2); + invokeTableDoubleClick(sourceTable); + + testCase.verifyEqual(readMarker(root), "new"); + testCase.verifyTrue(isappdata(groot, "versionToolWebreadCount")); + values = textareaValues(fig); + diagnostic = "row2=" + strjoin(string(sourceTable.Data(2, :)), " | ") + ... + newline + "text=" + strjoin(values, newline); + testCase.verifyTrue(any(contains(values, "v1.1.0")), diagnostic); + expectedRoot = string(root); + if ispc + expectedRoot = lower(expectedRoot); + values = lower(values); + end + expectedFolderLine = "Install folder: " + expectedRoot; + if ispc + expectedFolderLine = lower(expectedFolderLine); + end + testCase.verifyTrue(any(contains(values, expectedFolderLine))); + delete(fig); delete(hookCleanup); delete(modeCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function currentInstallInfoReadsProductionMetadataAndDegradesSafely(testCase) + root = fixtureRoot(testCase, "old"); + candidate = fixtureCandidate(testCase, "new"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", {struct("tag_name", {}) , struct("name", {}), struct("sha", {})}); + modeCleanup = setAppdata("labkitVersionManagerGuiTestMode", "hidden"); + + fig = manageLabKitVersions(candidate); + values = textareaValues(fig); + testCase.verifyTrue(any(contains(values, "Current launcher: LabKit App Launcher v1.7.0 (2026-07-26)"))); + delete(fig) + fig = manageLabKitVersions(root); + values = textareaValues(fig); + testCase.verifyTrue(any(contains(values, "Current launcher: LabKit App Launcher vunavailable (unavailable)"))); + delete(fig); delete(modeCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function discoveryFiltersAndOrdersIndependentSourceGroups(testCase) + root = fixtureCandidate(testCase, "new"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", { ... + struct("tag_name", {"v2.0.0", "v2.0.0-rc1", "v1.9.0"}, ... + "name", {"Release two", "Release candidate", "Draft"}, ... + "published_at", {"2026-02-01", "2026-01-31", "2026-01-30"}, ... + "draft", {false, false, true}, "prerelease", {false, true, false}), ... + struct("name", {"v1.8.0", "build-42"}), ... + struct("sha", {"abcdef123456", "123456789abc"}, ... + "commit", {struct("message", "First commit", "author", struct("date", "2026-01-01")), ... + struct("message", "Second commit", "author", struct("date", "2025-12-31"))})}); + modeCleanup = setAppdata("labkitVersionManagerGuiTestMode", "hidden"); + + fig = manageLabKitVersions(root); + sourceTable = findall(fig, "Type", "uitable"); + rows = string(sourceTable.Data); + + testCase.verifyEqual(rows(:, 1), ["Release"; "Tag"; "Tag"; "Commit"; "Commit"]); + testCase.verifyEqual(rows(1, 2), "v2.0.0"); + testCase.verifyFalse(any(contains(rows(:, 2), "rc1"))); + testCase.verifyFalse(any(contains(rows(:, 2), "v1.9.0"))); + delete(fig); delete(modeCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function discoveryRetainsOtherGroupsWhenOneEndpointFails(testCase) + root = fixtureCandidate(testCase, "new"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", { ... + struct("Error", "release endpoint unavailable"), ... + struct("name", "v1.8.0"), ... + struct("sha", "abcdef123456", "commit", struct("message", "Commit", "author", struct("date", "2026-01-01")))}); + modeCleanup = setAppdata("labkitVersionManagerGuiTestMode", "hidden"); + + fig = manageLabKitVersions(root); + sourceTable = findall(fig, "Type", "uitable"); + rows = string(sourceTable.Data); + + testCase.verifyEqual(rows(:, 1), ["Tag"; "Commit"]); + testCase.verifyEqual(rows(1, 2), "v1.8.0"); + delete(fig); delete(modeCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function mainAndSelectedModesRequestTheirExactZipUrlsAfterConfirmation(testCase) + root = fixtureRoot(testCase, "old"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + hookCleanup = setHook(struct("Confirm", true)); + + testCase.verifyError(@() manageLabKitVersions(root, "main"), "fixture:Download"); + mainArguments = getappdata(groot, "versionToolWebsaveArguments"); + testCase.verifyTrue(contains(string(mainArguments{2}), "refs/heads/main.zip")); + rmappdata(groot, "versionToolWebsaveArguments"); + selected = selectedSource(); + testCase.verifyError(@() manageLabKitVersions(root, "install", "Source", selected), "fixture:Download"); + selectedArguments = getappdata(groot, "versionToolWebsaveArguments"); + testCase.verifyEqual(string(selectedArguments{2}), selected.Url); + delete(hookCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function stableModeUsesLatestStableReleaseBeforeTags(testCase) + root = fixtureRoot(testCase, "old"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", { ... + struct("tag_name", "v2.3.4", "name", "Stable", "draft", false, ... + "prerelease", false, "published_at", "2026-01-01")}); + hookCleanup = setHook(struct("Confirm", true)); + + testCase.verifyError(@() manageLabKitVersions(root, "stable"), "fixture:Download"); + + arguments = getappdata(groot, "versionToolWebsaveArguments"); + testCase.verifyTrue(contains(string(arguments{2}), "refs/tags/v2.3.4.zip")); + testCase.verifyEqual(getappdata(groot, "versionToolWebreadCount"), 1); + delete(hookCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function stableModeFallsBackOnlyToStrictSemverTags(testCase) + root = fixtureRoot(testCase, "old"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", { ... + struct(), struct("name", {"v3.0.0-rc1", "build-42", "v2.3.5"})}); + hookCleanup = setHook(struct("Confirm", true)); + + testCase.verifyError(@() manageLabKitVersions(root, "stable"), "fixture:Download"); + + arguments = getappdata(groot, "versionToolWebsaveArguments"); + testCase.verifyTrue(contains(string(arguments{2}), "refs/tags/v2.3.5.zip")); + testCase.verifyEqual(getappdata(groot, "versionToolWebreadCount"), 2); + delete(hookCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + function stableModeRejectsPagesWithoutAStableTag(testCase) + root = fixtureRoot(testCase, "old"); + [networkFolder, networkCleanup] = networkStubs(testCase); + toolCleanup = isolatedTool(networkFolder); + responseCleanup = setAppdata("versionToolWebreadResponses", { ... + struct(), struct("name", {"v2.0.0-rc1", "build-42", "v2.3"})}); + hookCleanup = setHook(struct("Confirm", true)); + + testCase.verifyError(@() manageLabKitVersions(root, "stable"), ... + "LabKit:Deployment:StableSourceUnavailable"); + testCase.verifyFalse(isappdata(groot, "versionToolWebsaveCalled")); + delete(hookCleanup); delete(responseCleanup); delete(toolCleanup); delete(networkCleanup) + end + + end +end + +function cleanup = isolatedTool(networkFolder) +previousPath = path; +root = labkittest.setup(); +if strlength(string(networkFolder)) > 0 + addpath(networkFolder, "-begin"); +end +addpath(fullfile(root, "tools", "deployment"), "-begin"); +clear manageLabKitVersions webread websave +cleanup = onCleanup(@() restoreTool(previousPath)); +end + +function restoreTool(previousPath) +clear manageLabKitVersions webread websave +path(previousPath); +rehash +end + +function root = fixtureRoot(testCase, marker) +container = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +root = fullfile(container, "install-root"); +createRoot(root, marker); +end + +function createRoot(root, marker) +if exist(root, "dir") ~= 7 + mkdir(root); +end +copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); +mkdir(fullfile(root, "+labkit")); +mkdir(fullfile(root, "apps")); +mkdir(fullfile(root, "tools")); +writeText(fullfile(root, "marker.txt"), marker); +end + +function root = fixtureCandidate(testCase, marker) +container = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +root = fullfile(container, "candidate-root"); +mkdir(root); +createCandidate(root, marker); +end + +function createCandidate(root, marker) +if exist(root, "dir") ~= 7 + mkdir(root); +end +copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); +mkdir(fullfile(root, "+labkit", "+app", "+internal", "+launcher")); +mkdir(fullfile(root, "apps")); +writeText(fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"), strjoin([ + "function dispatch(varargin)" + "info = struct(""name"", ""labkit_launcher"", ..." + " ""displayName"", ""LabKit App Launcher"", ..." + " ""version"", ""1.7.0"", ""updated"", ""2026-07-26"");" + "end"], newline)); +writeText(fullfile(root, "marker.txt"), marker); +end + +function source = selectedSource() +source = struct( ... + "Kind", "Tag", ... + "Label", "Synthetic tag", ... + "Url", "https://github.com/Pluze/LabKit-MATLAB-Workbench/" + ... + "archive/refs/tags/v1.2.3.zip", ... + "Name", "v1.2.3", ... + "Date", "", ... + "Summary", "Synthetic source"); +end + +function cleanup = setHook(value) +key = "labkitVersionManagerTestHook"; +hadValue = isappdata(groot, key); +prior = []; +if hadValue + prior = getappdata(groot, key); +end +setappdata(groot, key, value); +cleanup = onCleanup(@() restoreAppdata(key, hadValue, prior)); +end + +function [folder, cleanup] = networkStubs(testCase) +folder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +writeText(fullfile(folder, "webread.m"), strjoin([ + "function value = webread(varargin)" + "count = 0;" + "if isappdata(groot, 'versionToolWebreadCount')" + " count = getappdata(groot, 'versionToolWebreadCount');" + "end" + "setappdata(groot, 'versionToolWebreadCount', count + 1);" + "if isappdata(groot, 'versionToolWebreadResponses')" + " values = getappdata(groot, 'versionToolWebreadResponses');" + " if count + 1 <= numel(values)" + " value = values{count + 1};" + " if isstruct(value) && isscalar(value) && isfield(value, 'Error')" + " error('fixture:EndpointFailure', '%s', char(value.Error));" + " end" + " return" + " end" + "end" + "error('fixture:Network', 'network should not run');" + "end"], newline)); +writeText(fullfile(folder, "websave.m"), strjoin([ + "function value = websave(varargin)" + "setappdata(groot, 'versionToolWebsaveCalled', true);" + "setappdata(groot, 'versionToolWebsaveArguments', varargin);" + "error('fixture:Download', 'download should not run');" + "end"], newline)); +cleanup = preserveNetworkState(); +end + +function cleanup = preserveNetworkState() +keys = ["versionToolWebreadCount", "versionToolWebsaveCalled", "versionToolWebreadResponses", "versionToolWebsaveArguments"]; +values = cell(size(keys)); had = false(size(keys)); +for index = 1:numel(keys) + had(index) = isappdata(groot, keys(index)); + if had(index) + values{index} = getappdata(groot, keys(index)); + end + if isappdata(groot, keys(index)) + rmappdata(groot, keys(index)); + end +end +cleanup = onCleanup(@() restoreNetworkState(keys, had, values)); +end + +function restoreNetworkState(keys, had, values) +for index = 1:numel(keys) + if had(index) + setappdata(groot, keys(index), values{index}); + elseif isappdata(groot, keys(index)) + rmappdata(groot, keys(index)); + end +end +end + +function restoreAppdata(key, had, value) +if had + setappdata(groot, key, value); +elseif isappdata(groot, key) + rmappdata(groot, key); +end +end + +function cleanup = setAppdata(key, value) +had = isappdata(groot, key); +prior = []; +if had + prior = getappdata(groot, key); +end +setappdata(groot, key, value); +cleanup = onCleanup(@() restoreAppdata(key, had, prior)); +end + +function writeText(filepath, contents) +folder = fileparts(filepath); +if exist(folder, "dir") ~= 7 + mkdir(folder); +end +file = fopen(filepath, "w", "n", "UTF-8"); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", contents); +delete(cleanup) +end + +function marker = readMarker(root) +marker = string(strtrim(fileread(fullfile(root, "marker.txt")))); +end + +function entries = normalizedPathEntries() +entries = string(strsplit(path, pathsep)); +for index = 1:numel(entries) + if strlength(entries(index)) > 0 + entries(index) = normalizedPath(entries(index)); + end +end +end + +function value = normalizedPath(value) +pathValue = java.nio.file.Paths.get(char(value), javaArray("java.lang.String", 0)); +value = string(pathValue.toAbsolutePath().normalize().toString()); +if ispc + value = lower(value); +end +end + +function values = textareaValues(fig) +textAreas = findall(fig, "Type", "uitextarea"); +values = strings(numel(textAreas), 1); +for index = 1:numel(textAreas) + values(index) = strjoin(string(textAreas(index).Value), newline); +end +end + +function invokeTableSelection(sourceTable, row) +if isprop(sourceTable, "SelectionChangedFcn") && ... + ~isempty(sourceTable.SelectionChangedFcn) + callback = sourceTable.SelectionChangedFcn; + callback(sourceTable, struct("Selection", [row 1])); +else + callback = sourceTable.CellSelectionCallback; + callback(sourceTable, struct("Indices", [row 1])); +end +end + +function invokeTableDoubleClick(sourceTable) +if isprop(sourceTable, "DoubleClickedFcn") && ... + ~isempty(sourceTable.DoubleClickedFcn) + callback = sourceTable.DoubleClickedFcn; +else + callback = sourceTable.CellDoubleClickedFcn; +end +callback(sourceTable, []); +end + +function writeAllLocalSentinels(root) +for relative = preservedLocalPaths()' + if relative == "LabKit.prj" + writeText(fullfile(root, relative), "synthetic-local"); + else + writeText(fullfile(root, relative, "sentinel.txt"), "synthetic-local"); + end +end +end + +function verifyAllLocalSentinels(testCase, root) +for relative = preservedLocalPaths()' + if relative == "LabKit.prj" + filepath = fullfile(root, relative); + else + filepath = fullfile(root, relative, "sentinel.txt"); + end + testCase.verifyEqual(string(strtrim(fileread(filepath))), "synthetic-local"); +end +end + +function paths = preservedLocalPaths() +paths = [ + "private_apps" + "artifacts" + string(fullfile("resources", "project")) + "photos" + "derived" + "profile_results" + "LabKit.prj" + ]; +end diff --git a/tests/specs/system/launcher/LauncherBootstrapSpec.m b/tests/specs/system/launcher/LauncherBootstrapSpec.m new file mode 100644 index 000000000..ced76a79c --- /dev/null +++ b/tests/specs/system/launcher/LauncherBootstrapSpec.m @@ -0,0 +1,629 @@ +classdef LauncherBootstrapSpec < matlab.unittest.TestCase + % LAUNCHERBOOTSTRAPSPEC Repair-root delegation and recovery affordance contracts. + + methods (Test, TestTags = {'Contract:system', 'Env:headless'}) + function rootOnlyCopyOpensHiddenRepairWithAndWithoutAnOutput(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); + cleanup = isolatedRootLauncher(root); + stateCleanup = setHiddenRepairMode(); + + labkit_launcher; + repairFigure = findall(groot, "Type", "figure", "Tag", "labkitRepair"); + testCase.verifyNumElements(repairFigure, 1); + delete(repairFigure); + + repairFigure = labkit_launcher; + testCase.verifyTrue(isvalid(repairFigure)); + testCase.verifyEqual(string(repairFigure.Tag), "labkitRepair"); + testCase.verifyEqual(string(which("labkit_launcher")), ... + string(fullfile(root, "labkit_launcher.m"))); + delete(repairFigure); + delete(stateCleanup); delete(cleanup) + end + + function missingInstalledEntryRejectsOutputModes(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); + cleanup = isolatedRootLauncher(root); + + testCase.verifyError(@() labkit_launcher("list"), ... + "labkit_launcher:InstalledEntryUnavailable"); + testCase.verifyError(@() labkit_launcher("version"), ... + "labkit_launcher:InstalledEntryUnavailable"); + testCase.verifyError(@() labkit_launcher("documentation", "labkit_Probe_app"), ... + "labkit_launcher:InstalledEntryUnavailable"); + delete(cleanup) + end + + function healthyRootDelegatesThroughTheInstalledEntry(testCase) + [~, cleanup] = installedLauncherFixture(testCase); + stateCleanup = preserveAppdata("labkitLauncherDelegateFixture"); + setappdata(groot, "labkitLauncherDelegateFixture", struct()); + + listing = labkit_launcher("list"); + + testCase.verifyEqual(string(listing.Command), "labkit_Probe_app"); + delete(stateCleanup); delete(cleanup) + end + + function structuralStartupFailureOpensRepairAndPreservesItsCause(testCase) + [~, cleanup] = installedLauncherFixture(testCase); + stateCleanup = setDelegateFailure("MATLAB:UndefinedFunction", "fixture dispatch symbol is unavailable"); + + repairFigure = labkit_launcher; + text = string(findall(repairFigure, "Type", "uitextarea").Value); + + testCase.verifyTrue(any(contains(text, "MATLAB:UndefinedFunction"))); + testCase.verifyTrue(any(contains(text, "fixture dispatch symbol is unavailable"))); + testCase.verifyTrue(any(contains(text, "Repair / Reinstall"))); + delete(repairFigure); delete(stateCleanup); delete(cleanup) + end + + function missingInstalledClassStartupFailureOpensRepair(testCase) + [~, cleanup] = installedLauncherFixture(testCase); + stateCleanup = setDelegateFailure("MATLAB:undefinedVarOrClass", "fixture installed class is unavailable"); + + repairFigure = labkit_launcher; + text = string(findall(repairFigure, "Type", "uitextarea").Value); + + testCase.verifyTrue(any(contains(text, "MATLAB:undefinedVarOrClass"))); + testCase.verifyTrue(any(contains(text, "Repair / Reinstall"))); + delete(repairFigure); delete(stateCleanup); delete(cleanup) + end + + function ordinaryStartupFailuresDoNotBecomeRepairFailures(testCase) + [~, cleanup] = installedLauncherFixture(testCase); + stateCleanup = setDelegateFailure("MATLAB:load:couldNotReadFile", "fixture input cannot be read"); + + testCase.verifyError(@() labkit_launcher, "MATLAB:load:couldNotReadFile"); + testCase.verifyEmpty(findall(groot, "Type", "figure", "Tag", "labkitRepair")); + delete(stateCleanup); + stateCleanup = setDelegateFailure("fixture:InvalidInput", "fixture input is invalid"); + testCase.verifyError(@() labkit_launcher, "fixture:InvalidInput"); + testCase.verifyEmpty(findall(groot, "Type", "figure", "Tag", "labkitRepair")); + delete(stateCleanup); delete(cleanup) + end + + function structuralOutputModePreservesTheOriginalFailure(testCase) + [~, cleanup] = installedLauncherFixture(testCase); + stateCleanup = setDelegateFailure("MATLAB:UndefinedFunction", "fixture dispatch symbol is unavailable"); + + testCase.verifyError(@() labkit_launcher("list"), "MATLAB:UndefinedFunction"); + testCase.verifyEmpty(findall(groot, "Type", "figure", "Tag", "labkitRepair")); + delete(stateCleanup); delete(cleanup) + end + + function fixtureLauncherIgnoresAnEarlierShadowDispatchPackage(testCase) + [root, cleanup] = installedLauncherFixture(testCase); + shadow = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeText(fullfile(shadow, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"), ... + dispatcherStub("labkit_Shadow_app")); + fixtureLauncher = @labkit_launcher; + testCase.verifyEqual(string(functions(fixtureLauncher).file), ... + string(fullfile(root, "labkit_launcher.m"))); + addpath(shadow, "-begin"); + rehash; + clear labkit.app.internal.launcher.dispatch + + listing = fixtureLauncher("list"); + + testCase.verifyEqual(string(listing.Command), "labkit_Probe_app"); + delete(cleanup) + end + + function repairRootDoesNotContainRuntimeLoggingOrStaticDispatchCalls(testCase) + source = string(fileread(fullfile(labkittest.setup(), "labkit_launcher.m"))); + + testCase.verifyFalse(contains(source, "SessionEvent", "IgnoreCase", true)); + testCase.verifyFalse(contains(source, "SessionJournal", "IgnoreCase", true)); + testCase.verifyFalse(contains(source, "journal", "IgnoreCase", true)); + testCase.verifyFalse(contains(source, "bootstrap logging", "IgnoreCase", true)); + testCase.verifyFalse(contains(source, "Version Manager", "IgnoreCase", true)); + testCase.verifyFalse(contains(source, ".labkit-managed-files.txt", "IgnoreCase", true)); + testCase.verifyTrue(contains(source, ... + 'str2func("labkit.app.internal.launcher.dispatch")')); + testCase.verifyFalse(contains(source, "labkit.app.internal.launcher.dispatch(")); + testCase.verifyTrue(contains(source, ... + 'strlength(string(folder)) > 0 && exist(folder, "dir") == 7')); + testCase.verifyTrue(contains(source, ... + 'repairFailureMessage(cause), detail, backup')); + end + + function repairUiRejectsAnInvalidCandidateWithoutChangingTheTarget(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + candidate = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + + clickRepair(repairFigure); + status = repairStatus(repairFigure); + + testCase.verifyTrue(contains(status, "labkit_launcher:InvalidCandidate")); + testCase.verifyEqual(readMarker(root), "old-marker"); + delete(hookCleanup); delete(cleanup) + end + + function repairUiRejectsACandidateContainedByTheTarget(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + candidate = fullfile(root, "candidate"); + createValidCandidate(candidate, "candidate-marker", false); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:InvalidCandidate")); + testCase.verifyEqual(readMarker(root), "old-marker"); + delete(hookCleanup); delete(cleanup) + end + + function repairUiRejectsATargetContainedByTheCandidate(testCase) + candidate = validRepairCandidate(testCase, "candidate-marker", false); + root = fullfile(candidate, "nested-install"); + createDamagedRepairRoot(root, "old-marker", false); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:InvalidCandidate")); + testCase.verifyEqual(readMarker(root), "old-marker"); + delete(hookCleanup); delete(cleanup) + end + + function rootOnlyCopyRepairIsSafelyRejected(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); + [repairFigure, cleanup] = openRepairFixture(root); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:InvalidRepairRoot")); + testCase.verifyEqual(exist(fullfile(root, "labkit_launcher.m"), "file"), 2); + delete(cleanup) + end + + function repairUiReplacesAValidDamagedInstallationAndRestoresWorkingFolder(testCase) + root = damagedRepairRoot(testCase, "old-marker", true); + candidate = validRepairCandidate(testCase, "new-marker", true); + mkdir(fullfile(root, "apps", "probe")); + mkdir(fullfile(root, "tools", "local-only")); + mkdir(fullfile(candidate, "apps", "probe")); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + addpath(fullfile(root, "apps", "probe"), "-begin"); + addpath(fullfile(root, "tools", "local-only"), "-begin"); + originalFolder = pwd; + folderCleanup = onCleanup(@() cd(originalFolder)); + cd(fullfile(root, "session")); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), "Restart LabKit")); + testCase.verifyEqual(readMarker(root), "new-marker"); + testCase.verifyEqual(normalizedPath(pwd), normalizedPath(fullfile(root, "session"))); + testCase.verifyEqual(exist(fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"), "file"), 2); + testCase.verifyEmpty(dir(fullfile(fileparts(root), "*.repair-backup-*"))); + entries = normalizedPathEntries(); + testCase.verifyTrue(any(entries == normalizedPath(root))); + testCase.verifyTrue(any(entries == normalizedPath(fullfile(root, "apps", "probe")))); + testCase.verifyFalse(any(entries == normalizedPath(fullfile(root, "tools", "local-only")))); + testCase.verifyFalse(any(contains(entries, ".repair-backup-"))); + delete(folderCleanup); delete(hookCleanup); delete(cleanup) + end + + function repairMigratesLocalDataAndRetainsARecoveryBackup(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + candidate = validRepairCandidate(testCase, "new-marker", false); + writePreservedLocalSentinels(root); + writeText(fullfile(root, ".labkit-managed-files.txt"), "obsolete-ledger"); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + + clickRepair(repairFigure); + + verifyPreservedLocalSentinels(testCase, root); + testCase.verifyEqual(exist(fullfile(root, ".labkit-managed-files.txt"), "file"), 0); + backups = dir(fullfile(fileparts(root), "*.repair-backup-*")); + testCase.verifyNumElements(backups, 1); + backup = fullfile(backups(1).folder, backups(1).name); + verifyPreservedLocalSentinels(testCase, backup); + testCase.verifyEqual(readMarker(backup), "old-marker"); + status = repairStatus(repairFigure); + testCase.verifyTrue(contains(status, "Recovery backup retained at")); + testCase.verifyTrue(contains(status, string(backup))); + delete(hookCleanup); delete(cleanup) + end + + function candidateLocalDataConflictRollsBackTheCompleteInstallation(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + candidate = validRepairCandidate(testCase, "new-marker", false); + writeText(fullfile(root, "photos", "sentinel.txt"), "old-local-data"); + writeText(fullfile(candidate, "photos", "sentinel.txt"), "candidate-content"); + writeText(fullfile(candidate, "candidate-only.txt"), "candidate-only"); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate)); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:LocalDataConflict")); + testCase.verifyEqual(readMarker(root), "old-marker"); + testCase.verifyEqual(string(strtrim(fileread( ... + fullfile(root, "photos", "sentinel.txt")))), "old-local-data"); + testCase.verifyEqual(exist(fullfile(root, "candidate-only.txt"), "file"), 0); + testCase.verifyEqual(string(strtrim(fileread( ... + fullfile(candidate, "photos", "sentinel.txt")))), "candidate-content"); + testCase.verifyEmpty(dir(fullfile(fileparts(root), "*.repair-backup-*"))); + delete(hookCleanup); delete(cleanup) + end + + function injectedFailureAfterBackupRestoresTheOriginalInstallation(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + candidate = validRepairCandidate(testCase, "new-marker", false); + mkdir(fullfile(root, "tools", "rollback-path")); + [repairFigure, cleanup] = openRepairFixture(root); + hookCleanup = setRepairHook(struct("CandidateRoot", candidate, "FailAfterBackup", true)); + addpath(fullfile(root, "tools", "rollback-path"), "-begin"); + startingFolder = pwd; + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), "labkit_launcher:InjectedFailure")); + testCase.verifyEqual(readMarker(root), "old-marker"); + testCase.verifyEqual(readMarker(candidate), "new-marker"); + testCase.verifyEqual(normalizedPath(pwd), normalizedPath(startingFolder)); + testCase.verifyEmpty(dir(fullfile(fileparts(root), "*.repair-backup-*"))); + entries = normalizedPathEntries(); + testCase.verifyTrue(any(entries == normalizedPath(root))); + testCase.verifyTrue(any(entries == normalizedPath(fullfile(root, "tools", "rollback-path")))); + testCase.verifyFalse(any(contains(entries, ".repair-backup-"))); + delete(hookCleanup); delete(cleanup) + end + + function gitCheckoutRepairRejectsBeforeAnyNetworkRequest(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + mkdir(fullfile(root, ".git")); + networkFolder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeNetworkStubs(networkFolder); + [repairFigure, cleanup] = openRepairFixture(root); + networkCleanup = isolatedNetworkStubs(networkFolder); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), "labkit_launcher:GitCheckout")); + testCase.verifyFalse(isappdata(groot, "fixtureWebreadCount")); + delete(networkCleanup); delete(cleanup) + end + + function linkedWorktreeRepairRejectsBeforeAnyNetworkRequest(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + writeText(fullfile(root, ".git"), "gitdir: synthetic-worktree"); + networkFolder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeNetworkStubs(networkFolder); + [repairFigure, cleanup] = openRepairFixture(root); + networkCleanup = isolatedNetworkStubs(networkFolder); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), "labkit_launcher:GitCheckout")); + testCase.verifyFalse(isappdata(groot, "fixtureWebreadCount")); + testCase.verifyFalse(isappdata(groot, "fixtureWebsaveCalled")); + delete(networkCleanup); delete(cleanup) + end + + function stableTagFallbackSkipsPrereleasesAndUsesTheFirstStableTag(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + networkFolder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeNetworkStubs(networkFolder); + [repairFigure, cleanup] = openRepairFixture(root); + networkCleanup = isolatedNetworkStubs(networkFolder); + sequenceCleanup = setWebreadSequence({struct(), struct("name", ... + {"v2.0.0-rc1", "v1.4.2", "preview"})}); + + clickRepair(repairFigure); + + testCase.verifyEqual(getappdata(groot, "fixtureWebreadCount"), 2); + testCase.verifyTrue(isappdata(groot, "fixtureWebsaveCalled")); + arguments = getappdata(groot, "fixtureWebsaveArguments"); + testCase.verifyTrue(contains(string(arguments{2}), "refs/tags/v1.4.2.zip")); + testCase.verifyTrue(contains(repairStatus(repairFigure), "fixture:UnexpectedDownload")); + delete(sequenceCleanup); delete(networkCleanup); delete(cleanup) + end + + function stableTagFallbackRejectsPagesWithoutASemverRelease(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + networkFolder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeNetworkStubs(networkFolder); + [repairFigure, cleanup] = openRepairFixture(root); + networkCleanup = isolatedNetworkStubs(networkFolder); + sequenceCleanup = setWebreadSequence({struct(), struct("name", ... + {"v2.0.0-rc1", "release-candidate", "v1.2"})}); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:StableSourceUnavailable")); + testCase.verifyEqual(getappdata(groot, "fixtureWebreadCount"), 2); + testCase.verifyFalse(isappdata(groot, "fixtureWebsaveCalled")); + delete(sequenceCleanup); delete(networkCleanup); delete(cleanup) + end + + function networkFailureIsActionableAndDoesNotAttemptDownload(testCase) + root = damagedRepairRoot(testCase, "old-marker", false); + networkFolder = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeNetworkStubs(networkFolder); + [repairFigure, cleanup] = openRepairFixture(root); + networkCleanup = isolatedNetworkStubs(networkFolder); + + clickRepair(repairFigure); + + testCase.verifyTrue(contains(repairStatus(repairFigure), ... + "labkit_launcher:StableSourceUnavailable")); + testCase.verifyEqual(getappdata(groot, "fixtureWebreadCount"), 2); + testCase.verifyFalse(isappdata(groot, "fixtureWebsaveCalled")); + delete(networkCleanup); delete(cleanup) + end + end +end + +function cleanup = isolatedRootLauncher(root) +previousPath = path; +addpath(root, "-begin"); +clear labkit_launcher +cleanup = onCleanup(@() restoreRootLauncher(previousPath)); +end + +function restoreRootLauncher(previousPath) +close(findall(groot, "Type", "figure", "Tag", "labkitRepair")); +clear labkit_launcher labkit.app.internal.launcher.dispatch +path(previousPath); +rehash +end + +function [root, cleanup] = installedLauncherFixture(testCase) +root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); +writeText(fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"), ... + dispatcherStub("labkit_Probe_app")); +cleanup = isolatedRootLauncher(root); +end + +function cleanup = setHiddenRepairMode() +cleanup = preserveAppdata("labkitLauncherGuiTestMode"); +setappdata(groot, "labkitLauncherGuiTestMode", "hidden"); +end + +function cleanup = setDelegateFailure(identifier, message) +cleanup = preserveAppdata("labkitLauncherDelegateFixture"); +setappdata(groot, "labkitLauncherDelegateFixture", ... + struct("Identifier", string(identifier), "Message", string(message))); +end + +function cleanup = preserveAppdata(key) +hadValue = isappdata(groot, key); +priorValue = []; +if hadValue + priorValue = getappdata(groot, key); +end +cleanup = onCleanup(@() restoreAppdata(key, hadValue, priorValue)); +end + +function restoreAppdata(key, hadValue, priorValue) +if hadValue + setappdata(groot, key, priorValue); +elseif isappdata(groot, key) + rmappdata(groot, key); +end +end + +function writeText(filepath, contents) +folder = fileparts(filepath); +if exist(folder, "dir") ~= 7 + mkdir(folder); +end +file = fopen(filepath, "w", "n", "UTF-8"); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", contents); +delete(cleanup) +end + +function contents = dispatcherStub(command) +contents = strjoin([ + "function varargout = dispatch(~, varargin)" + "control = struct();" + "if isappdata(groot, 'labkitLauncherDelegateFixture'), control = getappdata(groot, 'labkitLauncherDelegateFixture'); end" + "if isfield(control, 'Identifier') && strlength(string(control.Identifier)) > 0" + " error(char(control.Identifier), '%s', char(control.Message));" + "end" + "result = table(string('" + string(command) + "'), 'VariableNames', {'Command'});" + "if nargout > 0, varargout{1} = result; end" + "end"], newline); +end + +function root = damagedRepairRoot(testCase, marker, includeSessionFolder) +container = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +root = fullfile(container, "repair-root"); +mkdir(root); +createDamagedRepairRoot(root, marker, includeSessionFolder); +end + +function createDamagedRepairRoot(root, marker, includeSessionFolder) +if exist(root, "dir") ~= 7 + mkdir(root); +end +copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); +mkdir(fullfile(root, "+labkit")); +mkdir(fullfile(root, "apps")); +mkdir(fullfile(root, "tools")); +writeText(fullfile(root, "install-marker.txt"), marker); +if includeSessionFolder + mkdir(fullfile(root, "session")); +end +end + +function root = validRepairCandidate(testCase, marker, includeSessionFolder) +container = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +root = fullfile(container, "candidate-root"); +mkdir(root); +createValidCandidate(root, marker, includeSessionFolder); +end + +function createValidCandidate(root, marker, includeSessionFolder) +if exist(root, "dir") ~= 7 + mkdir(root); +end +copyfile(fullfile(labkittest.setup(), "labkit_launcher.m"), root); +mkdir(fullfile(root, "+labkit")); +mkdir(fullfile(root, "apps")); +writeText(fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"), ... + dispatcherStub("labkit_Candidate_app")); +writeText(fullfile(root, "install-marker.txt"), marker); +if includeSessionFolder + mkdir(fullfile(root, "session")); +end +end + +function [repairFigure, cleanup] = openRepairFixture(root) +pathCleanup = isolatedRootLauncher(root); +modeCleanup = setHiddenRepairMode(); +repairFigure = labkit_launcher; +cleanup = onCleanup(@() restoreRepairFixture(repairFigure, modeCleanup, pathCleanup)); +end + +function restoreRepairFixture(repairFigure, modeCleanup, pathCleanup) +if isvalid(repairFigure) + delete(repairFigure); +end +delete(modeCleanup); +delete(pathCleanup); +end + +function cleanup = setRepairHook(value) +cleanup = preserveAppdata("labkitLauncherRepairTestHook"); +setappdata(groot, "labkitLauncherRepairTestHook", value); +end + +function clickRepair(repairFigure) +button = findall(repairFigure, "Type", "uibutton", ... + "Text", "Repair / Reinstall Latest Stable Release"); +button.ButtonPushedFcn(button, []); +end + +function text = repairStatus(repairFigure) +label = findall(repairFigure, "Type", "uilabel"); +text = string(label.Text); +end + +function value = readMarker(root) +value = string(strtrim(fileread(fullfile(root, "install-marker.txt")))); +end + +function writeNetworkStubs(folder) +writeText(fullfile(folder, "webread.m"), strjoin([ + "function value = webread(varargin)" + "count = 0; if isappdata(groot, 'fixtureWebreadCount'), count = getappdata(groot, 'fixtureWebreadCount'); end" + "setappdata(groot, 'fixtureWebreadCount', count + 1);" + "if isappdata(groot, 'fixtureWebreadResponseSequence')" + " sequence = getappdata(groot, 'fixtureWebreadResponseSequence');" + " if count + 1 <= numel(sequence), value = sequence{count + 1}; return; end" + "end" + "error('fixture:NoNetwork', 'network unavailable');" + "end"], newline)); +writeText(fullfile(folder, "websave.m"), strjoin([ + "function value = websave(varargin)" + "setappdata(groot, 'fixtureWebsaveCalled', true);" + "setappdata(groot, 'fixtureWebsaveArguments', varargin);" + "error('fixture:UnexpectedDownload', 'download should not occur');" + "end"], newline)); +end + +function cleanup = setWebreadSequence(sequence) +cleanup = preserveAppdata("fixtureWebreadResponseSequence"); +setappdata(groot, "fixtureWebreadResponseSequence", sequence); +end + +function cleanup = isolatedNetworkStubs(folder) +previousPath = path; +hadReadCount = isappdata(groot, "fixtureWebreadCount"); readCount = []; +hadWebsave = isappdata(groot, "fixtureWebsaveCalled"); websaveValue = []; +hadWebsaveArguments = isappdata(groot, "fixtureWebsaveArguments"); websaveArguments = []; +hadSequence = isappdata(groot, "fixtureWebreadResponseSequence"); sequence = []; +if hadReadCount, readCount = getappdata(groot, "fixtureWebreadCount"); end +if hadWebsave, websaveValue = getappdata(groot, "fixtureWebsaveCalled"); end +if hadWebsaveArguments, websaveArguments = getappdata(groot, "fixtureWebsaveArguments"); end +if hadSequence, sequence = getappdata(groot, "fixtureWebreadResponseSequence"); end +if isappdata(groot, "fixtureWebreadCount"), rmappdata(groot, "fixtureWebreadCount"); end +if isappdata(groot, "fixtureWebsaveCalled"), rmappdata(groot, "fixtureWebsaveCalled"); end +if isappdata(groot, "fixtureWebsaveArguments"), rmappdata(groot, "fixtureWebsaveArguments"); end +if isappdata(groot, "fixtureWebreadResponseSequence"), rmappdata(groot, "fixtureWebreadResponseSequence"); end +addpath(folder, "-begin"); +clear webread websave +cleanup = onCleanup(@() restoreNetworkStubs(previousPath, hadReadCount, readCount, hadWebsave, websaveValue, ... + hadWebsaveArguments, websaveArguments, hadSequence, sequence)); +end + +function restoreNetworkStubs(previousPath, hadReadCount, readCount, hadWebsave, websaveValue, ... + hadWebsaveArguments, websaveArguments, hadSequence, sequence) +clear webread websave +path(previousPath); +if hadReadCount, setappdata(groot, "fixtureWebreadCount", readCount); elseif isappdata(groot, "fixtureWebreadCount"), rmappdata(groot, "fixtureWebreadCount"); end +if hadWebsave, setappdata(groot, "fixtureWebsaveCalled", websaveValue); elseif isappdata(groot, "fixtureWebsaveCalled"), rmappdata(groot, "fixtureWebsaveCalled"); end +if hadWebsaveArguments, setappdata(groot, "fixtureWebsaveArguments", websaveArguments); elseif isappdata(groot, "fixtureWebsaveArguments"), rmappdata(groot, "fixtureWebsaveArguments"); end +if hadSequence, setappdata(groot, "fixtureWebreadResponseSequence", sequence); elseif isappdata(groot, "fixtureWebreadResponseSequence"), rmappdata(groot, "fixtureWebreadResponseSequence"); end +end + +function value = normalizedPath(value) +pathValue = java.nio.file.Paths.get(char(value), javaArray("java.lang.String", 0)); +value = string(pathValue.toAbsolutePath().normalize().toString()); +if ispc + value = lower(value); +end +end + +function entries = normalizedPathEntries() +entries = string(strsplit(path, pathsep)); +for index = 1:numel(entries) + if strlength(entries(index)) > 0 + entries(index) = normalizedPath(entries(index)); + end +end +end + +function writePreservedLocalSentinels(root) +directories = [ + "private_apps" + "artifacts" + string(fullfile("resources", "project")) + "photos" + "derived" + "profile_results" + ]; +for index = 1:numel(directories) + writeText(fullfile(root, directories(index), "sentinel.txt"), ... + "synthetic-preserved"); +end +writeText(fullfile(root, "LabKit.prj"), "synthetic-project"); +end + +function verifyPreservedLocalSentinels(testCase, root) +directories = [ + "private_apps" + "artifacts" + string(fullfile("resources", "project")) + "photos" + "derived" + "profile_results" + ]; +for index = 1:numel(directories) + testCase.verifyEqual(string(strtrim(fileread( ... + fullfile(root, directories(index), "sentinel.txt")))), ... + "synthetic-preserved"); +end +testCase.verifyEqual(string(strtrim(fileread(fullfile(root, "LabKit.prj")))), ... + "synthetic-project"); +end diff --git a/tests/specs/system/launcher/LauncherDispatchSpec.m b/tests/specs/system/launcher/LauncherDispatchSpec.m new file mode 100644 index 000000000..dec2e9884 --- /dev/null +++ b/tests/specs/system/launcher/LauncherDispatchSpec.m @@ -0,0 +1,560 @@ +classdef LauncherDispatchSpec < matlab.unittest.TestCase + methods (Test, TestTags = {'Contract:system', 'Env:headless'}) + function publicCatalogHasExpectedMetadata(testCase) + root = labkittest.setup(); + catalog = labkit.app.internal.launcher.dispatch(root, "list"); + expectedPublic = publicEntryCommands(root); + publicCatalog = catalog(catalog.Visibility == "public", :); + + testCase.verifyEqual(string(catalog.Properties.VariableNames), ... + ["Command", "DisplayName", "Family", "Visibility", "Folder", ... + "RelativePath", "Description", "Version", "Updated"]); + testCase.verifyEqual(sort(publicCatalog.Command), sort(expectedPublic)); + testCase.verifyTrue(all(strlength(publicCatalog.DisplayName) > 0)); + testCase.verifyTrue(all(strlength(publicCatalog.Folder) > 0)); + testCase.verifyTrue(all(strlength(publicCatalog.Description) > 0)); + testCase.verifyTrue(all(strlength(publicCatalog.Version) > 0)); + testCase.verifyTrue(all(strlength(publicCatalog.Updated) > 0)); + privateCatalog = catalog(catalog.Visibility == "private", :); + if ~isempty(privateCatalog) + testCase.verifyTrue(all(strlength(privateCatalog.Command) > 0)); + testCase.verifyTrue(all(strlength(privateCatalog.DisplayName) > 0)); + end + end + + function discoversPrivateRootsWithoutPrefixConfusion(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + external = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeEntry(root, fullfile("apps", "alpha"), "labkit_Public_app"); + writeEntry(root, fullfile("private_apps", "apps", "local"), "labkit_Local_app"); + writeEntry(external, fullfile("apps", "external"), "labkit_External_app"); + previous = getenv("LABKIT_PRIVATE_APP_ROOTS"); + cleanup = onCleanup(@() setenv("LABKIT_PRIVATE_APP_ROOTS", previous)); + setenv("LABKIT_PRIVATE_APP_ROOTS", external); + + catalog = labkit.app.internal.launcher.dispatch(root, "list"); + + testCase.verifyEqual(string(catalog.Command), ... + ["labkit_Public_app"; "labkit_External_app"; "labkit_Local_app"]); + visibility = containers.Map(cellstr(catalog.Command), cellstr(catalog.Visibility)); + testCase.verifyEqual(string(visibility("labkit_Public_app")), "public"); + testCase.verifyEqual(string(visibility("labkit_Local_app")), "private"); + testCase.verifyEqual(string(visibility("labkit_External_app")), "private"); + clear cleanup + end + + function sourceEntriesWinOverPcodeAndPcodeOnlyRemainsDiscoverable(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + folder = fullfile(root, "apps", "source", "probe"); + writeEntry(root, fullfile("apps", "source", "probe"), "labkit_Probe_app"); + fclose(fopen(fullfile(folder, "labkit_Probe_app.p"), "w")); + pOnly = fullfile(root, "apps", "source", "p_only"); mkdir(pOnly); + fclose(fopen(fullfile(pOnly, "labkit_Ponly_app.p"), "w")); + catalog = labkit.app.internal.launcher.dispatch(root, "list"); + probe = catalog(catalog.Command == "labkit_Probe_app", :); + testCase.verifyEqual(height(probe), 1); + testCase.verifyEqual(string(probe.Version), "1.0.0"); + testCase.verifyEqual(sum(catalog.Command == "labkit_Ponly_app"), 1); + end + + function documentationMapsManualToGeneratedPage(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeEntry(root, fullfile("apps", "image_tools", "marker"), "labkit_Marker_app"); + manual = fullfile(root, "docs", "apps", "image_tools", "marker", "README.md"); + generated = fullfile(root, "site", "apps", "image_tools", "marker.html"); + writeText(manual, "# Marker"); + writeText(generated, ""); + + page = labkit.app.internal.launcher.dispatch(root, "documentation", "labkit_Marker_app"); + + testCase.verifyEqual(string(page), string(generated)); + end + + function documentationRejectsPrivateMissingAndUnbuiltPages(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeEntry(root, fullfile("apps", "image_tools", "marker"), "labkit_Marker_app"); + writeEntry(root, fullfile("private_apps", "apps", "private", "secret"), "labkit_Secret_app"); + writeText(fullfile(root, "docs", "apps", "image_tools", "marker", "README.md"), "# Marker"); + + testCase.verifyError(@() labkit.app.internal.launcher.dispatch(root, "documentation", "labkit_Secret_app"), ... + "labkit:app:internal:launcher:DocumentationUnavailable"); + testCase.verifyError(@() labkit.app.internal.launcher.dispatch(root, "documentation", "labkit_Marker_app"), ... + "labkit:app:internal:launcher:DocumentationUnavailable"); + testCase.verifyError(@() labkit.app.internal.launcher.dispatch(root, "documentation", "labkit_Missing_app"), ... + "labkit:app:internal:launcher:DocumentationUnavailable"); + end + + function launcherPreservesVisualSelectionAndDoubleClickContracts(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + commands = ["labkit_Alpha_app", "labkit_Beta_app"]; + writeEntry(root, fullfile("apps", "fixture", "alpha"), commands(1)); + writeEntry(root, fullfile("apps", "fixture", "beta"), commands(2)); + for command = commands + filepath = fullfile(root, "apps", "fixture", ... + erase(erase(lower(command), "labkit_"), "_app"), command + ".m"); + writeText(filepath, "function " + command + ... + "; setappdata(groot,'fixtureLaunchedCommand',mfilename); end"); + end + cleanup = launcherGuiFixture(commands); + + fig = labkit.app.internal.launcher.dispatch(root); + appTable = findall(fig, "Type", "uitable"); + buttons = string({findall(fig, "Type", "uibutton").Text}); + panels = string({findall(fig, "Type", "uipanel").Title}); + + screen = double(get(groot, "ScreenSize")); + testCase.verifyLessThanOrEqual(fig.Position(3), screen(3)); + testCase.verifyLessThanOrEqual(fig.Position(4), screen(4)); + if screen(3) >= 1360 + testCase.verifyEqual(fig.Position(3), 1280); + else + testCase.verifyGreaterThanOrEqual( ... + fig.Position(3), max(1, screen(3) - 100)); + end + if screen(4) >= 840 + testCase.verifyEqual(fig.Position(4), 720); + else + testCase.verifyGreaterThanOrEqual( ... + fig.Position(4), max(1, screen(4) - 140)); + end + testCase.verifyEqual(fig.Color, [0.97 0.98 0.99]); + testCase.verifyTrue(all(ismember([ ... + "Launcher", "Applications", "Run Apps", ... + "Versions and Install", "Development and Maintenance", ... + "Package and Publish"], panels))); + testCase.verifyEqual(string(appTable.ColumnName), [ ... + "Package"; "App"; "Family"; "Version"; "Access"; "Updated"]); + testCase.verifyEqual(appTable.ColumnEditable, ... + [true false false false false false]); + testCase.verifyEqual(appTable.FontSize, 12); + testCase.verifyTrue(all(cellfun( ... + @(value) isnumeric(value) && isscalar(value), ... + appTable.ColumnWidth))); + initialWidths = cell2mat(appTable.ColumnWidth); + testCase.verifyGreaterThan(initialWidths(2), initialWidths(1)); + fig.Position(3) = max(800, fig.Position(3) - 160); + fig.SizeChangedFcn(fig, []); + drawnow; + resizedWidths = cell2mat(appTable.ColumnWidth); + testCase.verifyLessThanOrEqual( ... + sum(resizedWidths), sum(initialWidths)); + testCase.verifyGreaterThanOrEqual(resizedWidths, ... + [62 180 120 70 72 90]); + testCase.verifyTrue(all(ismember([ ... + "Open Selected App", "Refresh App List", ... + "Documentation and History", "Latest", "Release", "Versions", ... + "Update Documentation", "Run Code Analyzer", ... + "Profile Selected App", "Clean Artifacts", ... + "Package Checked", "Checked P-code"], buttons))); + testCase.verifyFalse(any(buttons == "Open Debug")); + + invokeTableSelection(appTable, 2); + testCase.verifyTrue(any(contains(launcherText(fig), commands(2)))); + invokeTableDoubleClick(appTable, 2); + testCase.verifyEqual(string(getappdata( ... + groot, "fixtureLaunchedCommand")), commands(2)); + testCase.verifyTrue(any(contains(launcherText(fig), ... + "Opened " + commands(2)))); + view = getappdata(fig, "labkitLauncherView"); + testCase.verifyEqual(view.controls.appTable.table, appTable); + delete(fig); delete(cleanup) + end + + function versionButtonsUseTheIndependentVersionTool(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + writeEntry(root, fullfile("apps", "fixture", "probe"), ... + "labkit_Probe_app"); + writeVersionToolStub(root); + cleanup = launcherGuiFixture("manageLabKitVersions"); + + fig = labkit.app.internal.launcher.dispatch(root); + for buttonText = ["Latest", "Release", "Versions"] + button = findall(fig, "Type", "uibutton", "Text", buttonText); + button.ButtonPushedFcn(button, []); + end + + calls = getappdata(groot, "fixtureVersionCalls"); + testCase.verifyNumElements(calls, 3); + testCase.verifyEqual(string(cellfun( ... + @(call) call{2}, calls, "UniformOutput", false)), ... + ["main", "stable", "browse"]); + for index = 1:numel(calls) + testCase.verifyEqual(string(calls{index}{1}), string(root)); + testCase.verifyEqual(string(calls{index}{3}), "ProgressFcn"); + testCase.verifyClass(calls{index}{4}, "function_handle"); + end + testCase.verifyTrue(any(contains(launcherText(fig), ... + "Opened LabKit Version Manager."))); + delete(fig); delete(cleanup) + end + + function packageCheckboxesDriveMultiAppSourceAndPcodeCalls(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + commands = ["labkit_Alpha_app", "labkit_Beta_app"]; + writeEntry(root, fullfile("apps", "fixture", "alpha"), commands(1)); + writeEntry(root, fullfile("apps", "fixture", "beta"), commands(2)); + writeToolStub(root, "deployment", "packageLabKitApp"); + cleanup = launcherGuiFixture("packageLabKitApp"); + + fig = labkit.app.internal.launcher.dispatch(root); + appTable = findall(fig, "Type", "uitable"); + invokePackageEdit(appTable, 1, true); + invokePackageEdit(appTable, 2, true); + for buttonText = ["Package Checked", "Checked P-code"] + button = findall(fig, "Type", "uibutton", "Text", buttonText); + button.ButtonPushedFcn(button, []); + end + + calls = getappdata(groot, "fixtureToolCalls"); + testCase.verifyNumElements(calls, 2); + for index = 1:2 + args = calls{index}.args; + testCase.verifyEqual(sort(string(args{1})), sort(commands)); + testCase.verifyEmpty(args{2}); + expectedFormat = ["source", "pcode"]; + testCase.verifyEqual(string(args{3}), "Root"); + testCase.verifyEqual(string(args{4}), string(root)); + testCase.verifyEqual(string(args{5}), "CodeFormat"); + testCase.verifyEqual(string(args{6}), expectedFormat(index)); + testCase.verifyEqual(string(args{7}), "ProgressFcn"); + testCase.verifyClass(args{8}, "function_handle"); + end + delete(fig); delete(cleanup) + end + + function structuralStartupOffersRepairButOrdinaryErrorsDoNot(testCase) + cases = {"MATLAB:UndefinedFunction", true, "labkit_StructuralProbe_app"; "MATLAB:load:couldNotReadFile", false, "labkit_LoadProbe_app"; "fixture:InvalidInput", false, "labkit_InputProbe_app"}; + for index = 1:size(cases, 1) + [root, cleanup] = hiddenLauncherFixture(testCase, cases{index, 1}, cases{index, 3}); + fig = labkit.app.internal.launcher.dispatch(root); + button = findall(fig, "Type", "uibutton", "Text", "Open Selected App"); + button.ButtonPushedFcn(button, []); + status = findall(fig, "Type", "uitextarea"); + text = string(status.Value); + testCase.verifyTrue(any(contains(text, cases{index, 1}))); + testCase.verifyEqual(any(contains(text, ... + "labkit_launcher(""repair"")")), cases{index, 2}); + delete(fig); delete(cleanup) + end + end + + function figureStudioHookForwardsTheExactAxesSentinel(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + folder = fullfile(root, "apps", "labkit_core", "figure_studio"); + mkdir(folder); + writeText(fullfile(folder, "labkit_FigureStudio_app.m"), ... + "function labkit_FigureStudio_app(mode, ax); setappdata(groot,'fixtureFigureStudioArgs',struct('mode',mode,'axes',ax)); end"); + previousPath = path; + existing = which("labkit_FigureStudio_app"); + if strlength(string(existing)) > 0, rmpath(fileparts(existing)); end + clear labkit_FigureStudio_app + if isappdata(groot, "fixtureFigureStudioArgs"), rmappdata(groot, "fixtureFigureStudioArgs"); end + hadHook = isappdata(groot, "labkitFigureStudioLauncher"); + priorHook = []; + hadMode = isappdata(groot, "labkitLauncherGuiTestMode"); priorMode = []; + hadPayload = isappdata(groot, "fixtureFigureStudioArgs"); priorPayload = []; + if hadHook, priorHook = getappdata(groot, "labkitFigureStudioLauncher"); end + if hadMode, priorMode = getappdata(groot, "labkitLauncherGuiTestMode"); end + if hadPayload, priorPayload = getappdata(groot, "fixtureFigureStudioArgs"); end + setappdata(groot, "labkitLauncherGuiTestMode", "hidden"); + cleanup = onCleanup(@() restoreFigureStudioFixture(previousPath, hadHook, priorHook, hadMode, priorMode, hadPayload, priorPayload)); + fig = labkit.app.internal.launcher.dispatch(root); + sentinel = axes(figure("Visible", "off")); + hook = getappdata(groot, "labkitFigureStudioLauncher"); + hook(sentinel); + args = getappdata(groot, "fixtureFigureStudioArgs"); + testCase.verifyEqual(string(args.mode), "axes"); + testCase.verifyEqual(args.axes, sentinel); + delete(ancestor(sentinel, "figure")); delete(fig); delete(cleanup) + end + + function toolButtonsAdaptExactPublicContracts(testCase) + root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + command = "labkit_ToolProbe_app"; + writeEntry(root, fullfile("apps", "tools", "probe"), command); + tools = { + "maintenance", "cleanLabKitArtifacts" + "docs", "renderLabKitDocs" + "codecheck", "runCodecheckReport" + "profiling", "profileLabKitTarget" + }; + for index = 1:size(tools, 1) + writeToolStub(root, tools{index, 1}, tools{index, 2}); + end + cleanup = launcherGuiFixture(string(tools(:, 2))); + dottedMaintenance = fullfile(root, "tools", "maintenance", "."); + addpath(dottedMaintenance, "-begin"); + fig = labkit.app.internal.launcher.dispatch(root); + buttons = [ ... + "Clean Artifacts", "Update Documentation", ... + "Run Code Analyzer", "Profile Selected App"]; + expected = [ ... + "cleanLabKitArtifacts", "renderLabKitDocs", ... + "runCodecheckReport", "profileLabKitTarget"]; + for index = 1:numel(buttons) + clear cleanLabKitArtifacts renderLabKitDocs ... + runCodecheckReport profileLabKitTarget + if isappdata(groot, "fixtureToolCall") + rmappdata(groot, "fixtureToolCall"); + end + button = findall(fig, "Type", "uibutton", "Text", buttons(index)); + button.ButtonPushedFcn(button, []); + call = getappdata(groot, "fixtureToolCall"); + testCase.verifyEqual(string(call.name), expected(index)); + actual = call.args; + switch index + case 1 + testCase.verifyEqual(string(actual{1}), string(root)); + testCase.verifyEqual(string(actual{2}), "ProgressFcn"); + testCase.verifyClass(actual{3}, "function_handle"); + toolFolder = fullfile(root, "tools", "maintenance"); + case 2 + testCase.verifyEqual(string(actual), string({ ... + fullfile(root, "docs"), fullfile(root, "site")})); + toolFolder = fullfile(root, "tools", "docs"); + case 3 + testCase.verifyEqual(string(actual{1}), string(root)); + testCase.verifyEqual(string(actual{2}), "ProgressFcn"); + testCase.verifyClass(actual{3}, "function_handle"); + toolFolder = fullfile(root, "tools", "codecheck"); + otherwise + testCase.verifyEqual(string(actual{1}), command); + testCase.verifyEmpty(actual{2}); + testCase.verifyEqual(string(actual{3}), "OpenReport"); + testCase.verifyFalse(actual{4}); + testCase.verifyEqual(string(actual{5}), "WaitForGuiClose"); + testCase.verifyFalse(actual{6}); + toolFolder = fullfile(root, "tools", "profiling"); + end + if index == 1 + testCase.verifyEqual(sum(normalizedPathEntries() == normalizePath(toolFolder)), 1); + else + testCase.verifyFalse(any(strcmp(strsplit(path, pathsep), toolFolder))); + end + testCase.verifyFalse(any(contains(launcherText(fig), ... + "failed", "IgnoreCase", true))); + end + delete(fig); delete(cleanup) + end + end +end + +function writeEntry(root, relativeFolder, command) +folder = fullfile(root, relativeFolder); +mkdir(folder); +writeText(fullfile(folder, command + ".m"), "function " + command + "; end"); +[~, appId] = fileparts(folder); +definitionFolder = fullfile(folder, "+" + appId); +mkdir(definitionFolder); +writeText(fullfile(definitionFolder, "definition.m"), ... + "function value = definition; value = struct(""AppVersion"", ""1.0.0"", ""Updated"", ""2026-01-01""); end"); +end + +function writeText(filepath, contents) +folder = fileparts(filepath); +if exist(folder, "dir") ~= 7 + mkdir(folder); +end +file = fopen(filepath, "w", "n", "UTF-8"); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", contents); +delete(cleanup) +end + +function restoreFigureStudioFixture(previousPath, hadHook, priorHook, hadMode, priorMode, hadPayload, priorPayload) +path(previousPath); +clear labkit_FigureStudio_app +if hadPayload, setappdata(groot, "fixtureFigureStudioArgs", priorPayload); elseif isappdata(groot, "fixtureFigureStudioArgs"), rmappdata(groot, "fixtureFigureStudioArgs"); end +if hadHook, setappdata(groot, "labkitFigureStudioLauncher", priorHook); elseif isappdata(groot, "labkitFigureStudioLauncher"), rmappdata(groot, "labkitFigureStudioLauncher"); end +if hadMode, setappdata(groot, "labkitLauncherGuiTestMode", priorMode); elseif isappdata(groot, "labkitLauncherGuiTestMode"), rmappdata(groot, "labkitLauncherGuiTestMode"); end +end + +function writeToolStub(root, area, name) +folder = fullfile(root, "tools", area); mkdir(folder); +contents = [ + "function varargout = " + name + "(varargin)" + "call = struct(""name"", string(mfilename), ""args"", {varargin});" + "setappdata(groot, ""fixtureToolCall"", call);" + "calls = {};" + "if isappdata(groot, ""fixtureToolCalls"")" + " calls = getappdata(groot, ""fixtureToolCalls"");" + "end" + "calls{end + 1} = call;" + "setappdata(groot, ""fixtureToolCalls"", calls);" + "if nargout > 0" + " switch string(mfilename)" + " case ""cleanLabKitArtifacts""" + " result = struct(""removedCount"", 0);" + " case ""packageLabKitApp""" + " result = struct(""zipFile"", ""synthetic-package.zip"");" + " otherwise" + " result = struct();" + " end" + " varargout = cell(1, nargout);" + " varargout{1} = result;" + "end" + "end" + ]; +writeText(fullfile(folder, name + ".m"), strjoin(contents, newline)); +end + +function writeVersionToolStub(root) +folder = fullfile(root, "tools", "deployment"); +mkdir(folder); +contents = [ + "function result = manageLabKitVersions(varargin)" + "calls = {};" + "if isappdata(groot, ""fixtureVersionCalls"")" + " calls = getappdata(groot, ""fixtureVersionCalls"");" + "end" + "calls{end + 1} = varargin;" + "setappdata(groot, ""fixtureVersionCalls"", calls);" + "result = struct(""message"", ""Version action complete."");" + "end" + ]; +writeText(fullfile(folder, "manageLabKitVersions.m"), ... + strjoin(contents, newline)); +end + +function commands = publicEntryCommands(root) +sourceEntries = dir(fullfile(root, "apps", "**", "labkit_*_app.m")); +pcodeEntries = dir(fullfile(root, "apps", "**", "labkit_*_app.p")); +names = [string({sourceEntries.name}), string({pcodeEntries.name})]; +commands = unique(erase(names, [".m", ".p"]), "stable").'; +end + +function cleanup = launcherGuiFixture(functionNames) +previousPath = path; +functionNames = string(functionNames(:)); +keys = [ + "labkitLauncherGuiTestMode" + "labkitFigureStudioLauncher" + "fixtureLaunchedCommand" + "fixtureVersionCalls" + "fixtureToolCall" + "fixtureToolCalls" + ]; +hadValues = false(size(keys)); +priorValues = cell(size(keys)); +for index = 1:numel(keys) + hadValues(index) = isappdata(groot, keys(index)); + if hadValues(index) + priorValues{index} = getappdata(groot, keys(index)); + end +end +setappdata(groot, "labkitLauncherGuiTestMode", "hidden"); +clearNamedFunctions(functionNames); +cleanup = onCleanup(@() restoreLauncherGuiFixture( ... + previousPath, functionNames, keys, hadValues, priorValues)); +end + +function restoreLauncherGuiFixture( ... + previousPath, functionNames, keys, hadValues, priorValues) +path(previousPath); +clearNamedFunctions(functionNames); +for index = 1:numel(keys) + if hadValues(index) + setappdata(groot, keys(index), priorValues{index}); + elseif isappdata(groot, keys(index)) + rmappdata(groot, keys(index)); + end +end +end + +function clearNamedFunctions(functionNames) +for name = functionNames.' + clear(char(name)); +end +end + +function values = launcherText(fig) +textAreas = findall(fig, "Type", "uitextarea"); +chunks = cell(numel(textAreas), 1); +for index = 1:numel(textAreas) + chunks{index} = string(textAreas(index).Value(:)); +end +if isempty(chunks) + values = strings(0, 1); +else + values = vertcat(chunks{:}); +end +end + +function invokeTableSelection(tableHandle, row) +if isprop(tableHandle, "SelectionChangedFcn") && ... + ~isempty(tableHandle.SelectionChangedFcn) + invokeCallback(tableHandle.SelectionChangedFcn, tableHandle, ... + struct("Selection", [row 1])); +else + invokeCallback(tableHandle.CellSelectionCallback, tableHandle, ... + struct("Indices", [row 1])); +end +end + +function invokeTableDoubleClick(tableHandle, row) +if isprop(tableHandle, "DoubleClickedFcn") && ... + ~isempty(tableHandle.DoubleClickedFcn) + callback = tableHandle.DoubleClickedFcn; +elseif isprop(tableHandle, "CellDoubleClickedFcn") && ... + ~isempty(tableHandle.CellDoubleClickedFcn) + callback = tableHandle.CellDoubleClickedFcn; +else + error("fixture:MissingDoubleClickCallback", ... + "The launcher app table has no double-click callback."); +end +invokeCallback(callback, tableHandle, struct( ... + "Selection", [row 1], "Indices", [row 1])); +end + +function invokePackageEdit(tableHandle, row, value) +tableHandle.Data{row, 1} = value; +invokeCallback(tableHandle.CellEditCallback, tableHandle, ... + struct("Indices", [row 1], "NewData", value)); +end + +function invokeCallback(callback, source, event) +if isa(callback, "function_handle") + callback(source, event); +else + feval(callback{1}, source, event, callback{2:end}); +end +end + +function entries = normalizedPathEntries() +entries = string(strsplit(path, pathsep)); +entries = entries(strlength(entries) > 0); +for index = 1:numel(entries) + entries(index) = normalizePath(entries(index)); +end +end + +function value = normalizePath(value) +pathValue = java.nio.file.Paths.get(char(value), javaArray("java.lang.String", 0)); +value = string(pathValue.toAbsolutePath().normalize().toString()); +if ispc, value = lower(value); end +end + +function [root, cleanup] = hiddenLauncherFixture(testCase, identifier, command) +root = testCase.applyFixture(matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +folder = fullfile(root, "apps", "fixture", "probe"); +mkdir(folder); mkdir(fullfile(folder, "+probe")); +writeText(fullfile(folder, command + ".m"), ... + "function " + command + "; error('" + identifier + "','" + identifier + "'); end"); +writeText(fullfile(folder, "+probe", "definition.m"), ... + "function value = definition; value = struct(""AppVersion"", ""1.0.0"", ""Updated"", ""2026-01-01""); end"); +previousPath = path; hadMode = isappdata(groot, "labkitLauncherGuiTestMode"); priorMode = []; +hadHook = isappdata(groot, "labkitFigureStudioLauncher"); priorHook = []; +if hadMode, priorMode = getappdata(groot, "labkitLauncherGuiTestMode"); end +if hadHook, priorHook = getappdata(groot, "labkitFigureStudioLauncher"); end +setappdata(groot, "labkitLauncherGuiTestMode", "hidden"); +cleanup = onCleanup(@() restoreHiddenLauncherFixture(previousPath, command, hadMode, priorMode, hadHook, priorHook)); +end + +function restoreHiddenLauncherFixture(previousPath, command, hadMode, priorMode, hadHook, priorHook) +path(previousPath); clear(command) +if hadMode, setappdata(groot, "labkitLauncherGuiTestMode", priorMode); elseif isappdata(groot, "labkitLauncherGuiTestMode"), rmappdata(groot, "labkitLauncherGuiTestMode"); end +if hadHook, setappdata(groot, "labkitFigureStudioLauncher", priorHook); elseif isappdata(groot, "labkitFigureStudioLauncher"), rmappdata(groot, "labkitFigureStudioLauncher"); end +end diff --git a/tests/specs/system/maintenance/CleanLabKitArtifactsSpec.m b/tests/specs/system/maintenance/CleanLabKitArtifactsSpec.m new file mode 100644 index 000000000..1ac076f37 --- /dev/null +++ b/tests/specs/system/maintenance/CleanLabKitArtifactsSpec.m @@ -0,0 +1,125 @@ +classdef CleanLabKitArtifactsSpec < matlab.unittest.TestCase + % CLEANLABKITARTIFACTSSPEC Regression: generated artifacts cleanup stays bounded and idempotent. + + methods (Test, TestTags = {'Contract:system', 'Env:headless'}) + function removesOnlyArtifactsAndReportsProgress(testCase) + [root, cleanup] = temporaryLabKitRoot(testCase); + artifactFile = fullfile(root, "artifacts", "reports", "report.txt"); + preservedFile = fullfile(root, "apps", "preserved.txt"); + writeFile(artifactFile, "generated"); + writeFile(preservedFile, "preserved"); + progressValues = zeros(0, 1); + progressMessages = strings(0, 1); + + result = cleanLabKitArtifacts(root, ProgressFcn=@recordProgress); + + testCase.verifyEqual(string(fieldnames(result)), [ ... + "root"; "removedCount"; "removedTargets"; "errors"]); + testCase.verifyEqual(result.removedCount, 1); + testCase.verifyEqual(result.removedTargets, "artifacts"); + testCase.verifyEmpty(result.errors); + testCase.verifyFalse(isfolder(fullfile(root, "artifacts"))); + testCase.verifyTrue(isfile(preservedFile)); + testCase.verifyEqual(progressValues(1), 0.05); + testCase.verifyEqual(progressValues(end), 1.00); + testCase.verifyTrue(all(diff(progressValues) >= 0)); + testCase.verifyNotEmpty(progressMessages); + + repeated = cleanLabKitArtifacts(root); + testCase.verifyEqual(repeated.removedCount, 0); + testCase.verifyEmpty(repeated.removedTargets); + testCase.verifyEmpty(repeated.errors); + clear cleanup + + function recordProgress(message, value) + progressMessages(end + 1, 1) = string(message); + progressValues(end + 1, 1) = value; + end + end + + function rejectsUnsafeRoots(testCase) + root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + cleanup = addMaintenanceToolPath(); + + testCase.verifyError(@() cleanLabKitArtifacts(root), ... + "cleanLabKitArtifacts:InvalidRoot"); + testCase.verifyError(@() cleanLabKitArtifacts(" "), ... + "cleanLabKitArtifacts:InvalidRoot"); + testCase.verifyError(@() cleanLabKitArtifacts(filesep), ... + "cleanLabKitArtifacts:InvalidRoot"); + clear cleanup + end + + function rejectsAnExistingTargetOutsideTheValidatedRoot(testCase) + [root, cleanup] = temporaryLabKitRoot(testCase); + outside = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; + outsideFile = fullfile(outside, "preserved.txt"); + writeFile(outsideFile, "outside"); + linkCleanup = createDirectoryLink(fullfile(root, "artifacts"), outside); + + testCase.verifyError(@() cleanLabKitArtifacts(root), ... + "cleanLabKitArtifacts:UnsafeTarget"); + testCase.verifyTrue(isfile(outsideFile)); + clear linkCleanup cleanup + end + + function rejectsAnExistingTargetLinkedInsideTheValidatedRoot(testCase) + [root, cleanup] = temporaryLabKitRoot(testCase); + preserved = fullfile(root, "apps", "preserved"); + preservedFile = fullfile(preserved, "sentinel.txt"); + writeFile(preservedFile, "preserved"); + linkCleanup = createDirectoryLink(fullfile(root, "artifacts"), preserved); + + testCase.verifyError(@() cleanLabKitArtifacts(root), ... + "cleanLabKitArtifacts:UnsafeTarget"); + testCase.verifyTrue(isfile(preservedFile)); + clear linkCleanup cleanup + end + end +end + +function [root, cleanup] = temporaryLabKitRoot(testCase) +root = testCase.applyFixture( ... + matlab.unittest.fixtures.TemporaryFolderFixture).Folder; +writeFile(fullfile(root, "labkit_launcher.m"), "function labkit_launcher; end"); +cleanup = addMaintenanceToolPath(); +end + +function cleanup = addMaintenanceToolPath() +toolFolder = fullfile(labkittest.setup(), "tools", "maintenance"); +addpath(toolFolder, "-begin"); +cleanup = onCleanup(@() rmpath(toolFolder)); +end + +function cleanup = createDirectoryLink(linkPath, targetPath) +if ispc + command = sprintf('cmd /c mklink /J "%s" "%s"', linkPath, targetPath); +else + command = sprintf('ln -s "%s" "%s"', targetPath, linkPath); +end +[status, message] = system(command); +assert(status == 0, message); +cleanup = onCleanup(@() removeDirectoryLink(linkPath)); +end + +function removeDirectoryLink(linkPath) +if ispc + removed = java.io.File(linkPath).delete(); + assert(removed, "Could not remove temporary directory junction."); +else + delete(linkPath); +end +end + +function writeFile(filepath, contents) +folder = fileparts(filepath); +if exist(folder, "dir") ~= 7 + mkdir(folder); +end +file = fopen(filepath, "w", "n", "UTF-8"); +cleanup = onCleanup(@() fclose(file)); +fprintf(file, "%s", contents); +clear cleanup +end diff --git a/tests/specs/system/repository/TestArchitectureSpec.m b/tests/specs/system/repository/TestArchitectureSpec.m index b81bc400c..c8005be52 100644 --- a/tests/specs/system/repository/TestArchitectureSpec.m +++ b/tests/specs/system/repository/TestArchitectureSpec.m @@ -2,6 +2,14 @@ %TESTARCHITECTURESPEC Specify one active owner/contract test architecture. methods (Test, TestTags = {'Contract:system', 'Env:headless'}) + function generatedApiExcludesInternalPackages(testCase) + root = labkittest.setup(); + + testCase.verifyFalse(isfolder(fullfile( ... + root, "site", "reference", "api", "labkit", ... + "app", "internal"))); + end + function activeEntryPointsDescribeOnlyTheCatalogModel(testCase) root = labkittest.setup(); build = text(root, "buildfile.m"); @@ -70,9 +78,97 @@ function repositoryTextDoesNotContainUserPathsOrTimestampTokens(testCase) "Tracked text contains a sample timestamp token: " + file); end end + + function testsPassAnExplicitJournalOrJournalRootToEveryRuntimeFactory(testCase) + root = labkittest.setup(); + files = dir(fullfile(root, "tests", "**", "*.m")); + violations = strings(1, 0); + for index = 1:numel(files) + file = fullfile(files(index).folder, files(index).name); + calls = labkittest.runtimeFactoryCalls(fileread(file)); + for call = calls + if ~hasExplicitJournalOrRoot(call) + relative = erase(string(file), string(root) + filesep); + violations(end + 1) = relative + ":" + ... + string(call.Line) + " must pass a nonempty fourth journal " + ... + "or fourth [] with a nonempty JournalRoot."; + end + end + end + testCase.verifyEmpty(violations, strjoin(violations, newline)); + end + + function runtimeFactoryParserAcceptsFourthArgumentJournal(testCase) + source = strjoin([ ... + "% RuntimeFactory.createMatlab(app) is a comment.", ... + "literal = ""RuntimeFactory.createHeadless(app)"";", ... + "runtime = labkit.app.internal.RuntimeFactory.createHeadless( ...", ... + " app, [], struct(), journal);"], newline); + + calls = labkittest.runtimeFactoryCalls(source); + + testCase.verifyNumElements(calls, 1); + testCase.verifyEqual(calls.Method, "createHeadless"); + testCase.verifyNumElements(calls.Arguments, 4); + testCase.verifyTrue(hasExplicitJournalOrRoot(calls)); + end + + function runtimeFactoryParserHandlesTransposeAndCharLiterals(testCase) + source = strjoin([ ... + "values = [1 2];", ... + "values.';", ... + "literal = 'RuntimeFactory.createMatlab(app)';", ... + "runtime = labkit.app.internal.RuntimeFactory.createHeadless( ...", ... + " app, [], struct(""alert"", @(~, ~) []), ...", ... + " journal);"], newline); + + calls = labkittest.runtimeFactoryCalls(source); + + testCase.verifyNumElements(calls, 1); + testCase.verifyEqual(calls.Method, "createHeadless"); + testCase.verifyNumElements(calls.Arguments, 4); + testCase.verifyTrue(hasExplicitJournalOrRoot(calls)); + end + + function runtimeFactoryParserAcceptsExplicitJournalWithAdditionalArguments(testCase) + source = strjoin([ ... + "runtime = labkit.app.internal.RuntimeFactory.createHeadless( ...", ... + " app, [], struct(), journal, ...", ... + " JournalRoot=temporaryRoot);"], newline); + + calls = labkittest.runtimeFactoryCalls(source); + + testCase.verifyNumElements(calls, 1); + testCase.verifyEqual(calls.Arguments(4), "journal"); + testCase.verifyTrue(hasExplicitJournalOrRoot(calls)); + end + + function runtimeFactoryParserAcceptsExplicitJournalRootWithEmptyJournal(testCase) + source = strjoin([ ... + "runtime = labkit.app.internal.RuntimeFactory.createHeadless( ...", ... + " app, [], struct(), [], ...", ... + " JournalRoot=temporaryRoot);"], newline); + + calls = labkittest.runtimeFactoryCalls(source); + + testCase.verifyNumElements(calls, 1); + testCase.verifyEqual(calls.Arguments(4), "[]"); + testCase.verifyTrue(startsWith(strtrim(calls.Arguments(5)), "...")); + testCase.verifyEqual(calls.JournalRoot, "temporaryRoot"); + testCase.verifyTrue(hasExplicitJournalOrRoot(calls)); + end end end +function tf = hasExplicitJournalOrRoot(call) +callArguments = call.Arguments; +hasJournal = numel(callArguments) >= 4 && ... + strlength(callArguments(4)) > 0 && callArguments(4) ~= "[]"; +hasJournalRoot = any(numel(callArguments) == [5, 6]) && ... + callArguments(4) == "[]" && strlength(call.JournalRoot) > 0; +tf = hasJournal || hasJournalRoot; +end + function value = text(root, relative) value = string(fileread(fullfile(root, relative))); end diff --git a/tools/deployment/manageLabKitVersions.m b/tools/deployment/manageLabKitVersions.m new file mode 100644 index 000000000..5b1e650d6 --- /dev/null +++ b/tools/deployment/manageLabKitVersions.m @@ -0,0 +1,887 @@ +function varargout = manageLabKitVersions(root, mode, varargin) +%MANAGELABKITVERSIONS Browse and install selected LabKit GitHub versions. +% +% MANAGELABKITVERSIONS opens the Version Manager for the LabKit installation +% that contains this tool. +% +% MANAGELABKITVERSIONS(ROOT) opens the Version Manager for ROOT. The window +% lists recent non-draft releases, tags, and main-branch commits. Refresh +% obtains current candidates. Double-click or Install Selected asks for +% confirmation before downloading and replacing the installation. +% +% RESULT = MANAGELABKITVERSIONS(ROOT, "main") installs the GitHub main ZIP. +% RESULT = MANAGELABKITVERSIONS(ROOT, "stable") installs the latest stable +% release, falling back to the first strict v.. tag. +% RESULT = MANAGELABKITVERSIONS(ROOT, "install", Source=SOURCE) installs a +% selected source. SOURCE is a scalar struct with nonempty scalar-text Kind, +% Label, Url, and Name fields. Url must identify a ZIP in this repository's +% GitHub archive. Optional Date and Summary scalar-text fields are displayed. +% +% MANAGELABKITVERSIONS(..., ProgressFcn=FCN) reports best-effort progress by +% calling FCN(message, fraction), where message is a string scalar and +% fraction is between 0 and 1. The default [] reports no programmatic +% progress. Progress callback failures never interrupt discovery or install. +% +% ROOT is a nonempty scalar path. MODE is "browse" (default), "main", +% "stable", or "install". Browse returns the Version Manager figure when an +% output is requested. Install modes return a scalar RESULT struct with root, +% source, updated, message, backupFolder, backupRetained, and +% preservedItemCount. Canceling confirmation returns updated=false. +% +% The installation is replaced through a sibling backup and rollback path. +% Existing private_apps, artifacts, resources/project, photos, derived, +% profile_results, and LabKit.prj are copied into the replacement only when +% the candidate does not contain the same local entry. Such a migration keeps +% the sibling backup as a recovery location. Git checkouts (.git file or +% directory), filesystem roots, malformed candidates, nested candidates, and +% local-data conflicts are rejected. Download, extraction, and file errors +% throw stable errors; a failed confirmation is not an error. +% +% Errors: +% LabKit:Deployment:InvalidMode MODE is unsupported. +% LabKit:Deployment:InvalidSource SOURCE is malformed or untrusted. +% LabKit:Deployment:UnsafeRoot ROOT is a filesystem root. +% LabKit:Deployment:GitCheckout ROOT is a Git checkout. +% LabKit:Deployment:InvalidRoot ROOT is not a LabKit installation. +% LabKit:Deployment:InvalidCandidate The ZIP/candidate is not safe. +% LabKit:Deployment:RollbackFailed Replacement and rollback both failed. +% +% Typical Call: +% addpath(fullfile("tools", "deployment")) +% manager = manageLabKitVersions(pwd) +% result = manageLabKitVersions(pwd, "stable") +% progress = @(message, fraction) fprintf("%s %.0f%%\n", ... +% message, 100*fraction); +% result = manageLabKitVersions(pwd, "main", ProgressFcn=progress) +% +% See also WEBREAD, WEBSAVE, UNZIP. + + if nargin < 1 || isempty(root) + root = repoRoot(); + end + if nargin < 2 || isempty(mode) + mode = "browse"; + end + opts = parseOptions(varargin{:}); + root = string(normalizedPath(root)); + if ~isTextScalar(mode) || ismissing(string(mode)) + error("LabKit:Deployment:InvalidMode", ... + "Mode must be browse, main, stable, or install."); + end + mode = lower(strtrim(string(mode))); + if ~ismember(mode, ["browse", "main", "stable", "install"]) + error("LabKit:Deployment:InvalidMode", ... + "Mode must be browse, main, stable, or install."); + end + if mode == "browse" + fig = openManager(root, opts); + if nargout > 0, varargout = {fig}; end + return; + end + assertUpdateRoot(root); + switch mode + case "main" + source = mainSource(); + case "stable" + source = stableSource(); + case "install" + if isempty(opts.Source) + error("LabKit:Deployment:InvalidSource", ... + "Install mode requires a selected source."); + end + source = validateSource(opts.Source); + end + result = installSource(root, source, opts); + if nargout > 0 + varargout = {result}; + end +end + +function opts = parseOptions(varargin) + parser = inputParser; + parser.FunctionName = "manageLabKitVersions"; + parser.addParameter("ProgressFcn", [], @(value) isempty(value) || isa(value, "function_handle")); + parser.addParameter("Source", [], @(value) isempty(value) || isstruct(value)); + parser.parse(varargin{:}); + opts = parser.Results; +end + +function fig = openManager(root, opts) + figArgs = {"Name", "LabKit Version Manager", "Position", [210 170 900 520], "Color", [0.97 0.98 0.99]}; + if isappdata(groot, "labkitVersionManagerGuiTestMode") && ... + string(getappdata(groot, "labkitVersionManagerGuiTestMode")) == "hidden" + figArgs = [figArgs, {"Visible", "off"}]; + end + fig = uifigure(figArgs{:}); + layout = uigridlayout(fig, [4 1]); + layout.RowHeight = {86, "1x", 36, 76}; + layout.Padding = [8 8 8 8]; + layout.RowSpacing = 8; + current = uitextarea(layout, "Editable", "off", ... + "Value", currentInstallLines(root)); + current.Layout.Row = 1; + sourceTable = uitable(layout, ... + "ColumnName", {"Type", "Version or commit", "Date", "Summary"}, ... + "RowName", {}, "FontSize", 14); + sourceTable.ColumnWidth = {100, 170, 170, "auto"}; + sourceTable.Layout.Row = 2; + buttons = uigridlayout(layout, [1 4]); + buttons.Layout.Row = 3; + buttons.ColumnWidth = {"1x", "1x", "1x", "1x"}; + buttons.Padding = [0 0 0 0]; + buttons.ColumnSpacing = 8; + refresh = uibutton(buttons, "Text", "Refresh"); + refresh.Layout.Column = 1; + install = uibutton(buttons, "Text", "Install Selected", "Enable", "off"); + install.Layout.Column = 2; + closeButton = uibutton(buttons, "Text", "Close", ... + "ButtonPushedFcn", @(~, ~) close(fig)); + closeButton.Layout.Column = 4; + if isprop(refresh, "Tooltip") + refresh.Tooltip = "Fetch recent GitHub releases, tags, and main commits."; + install.Tooltip = "Download and apply the selected LabKit version."; + closeButton.Tooltip = "Close version manager."; + end + status = uitextarea(layout, "Editable", "off", ... + "Value", "Choose a recent release, tag, or main-branch commit."); + status.Layout.Row = 4; + state = struct("sources", emptySources(), "selectedRow", 1, "busy", false); + refresh.ButtonPushedFcn = @onRefresh; + install.ButtonPushedFcn = @onInstall; + configureTable(sourceTable, @onSelect, @onInstall); + onRefresh(); + function onRefresh(~, ~) + if state.busy + return; + end + setBusy(true, "Fetching recent LabKit versions from GitHub..."); + notify(opts.ProgressFcn, "Fetching recent LabKit versions from GitHub...", 0.05); + try + state.sources = discoverSources(); + state.selectedRow = 1; + sourceTable.Data = sourceRows(state.sources); + if isempty(state.sources) + status.Value = ... + "No release, tag, or commit options were returned by GitHub."; + else + status.Value = "Loaded " + numel(state.sources) + ... + " version option(s)."; + end + catch cause + state.sources = emptySources(); + sourceTable.Data = cell(0, 4); + status.Value = "Version lookup failed: " + failureText(cause); + end + notify(opts.ProgressFcn, status.Value, 1.00); + setBusy(false, status.Value); + end + function onSelect(~, event) + row = eventRow(event); + if ~isnan(row) + state.selectedRow = row; + end + end + function onInstall(~, ~) + if state.busy || isempty(state.sources) + return; + end + setBusy(true, "Preparing selected version..."); + selected = state.sources(min(max(state.selectedRow, 1), numel(state.sources))); + try + result = installSource(root, selected, opts); + status.Value = result.message; + current.Value = currentInstallLines(root); + catch cause + status.Value = "Version install failed: " + failureText(cause); + end + setBusy(false, status.Value); + end + function setBusy(value, message) + state.busy = value; + refresh.Enable = matlab.lang.OnOffSwitchState(~value); + closeButton.Enable = matlab.lang.OnOffSwitchState(~value); + install.Enable = matlab.lang.OnOffSwitchState( ... + ~value && ~isempty(state.sources)); + status.Value = string(message); + drawnow limitrate; + end +end + +function configureTable(tableHandle, selectionCallback, doubleClickCallback) + if isprop(tableHandle, "SelectionChangedFcn") + tableHandle.SelectionChangedFcn = selectionCallback; + else + tableHandle.CellSelectionCallback = selectionCallback; + end + if isprop(tableHandle, "SelectionType") + tableHandle.SelectionType = "row"; + end + if isprop(tableHandle, "DoubleClickedFcn") + tableHandle.DoubleClickedFcn = doubleClickCallback; + elseif isprop(tableHandle, "CellDoubleClickedFcn") + tableHandle.CellDoubleClickedFcn = doubleClickCallback; + end +end + +function row = eventRow(event) + row = NaN; + if isprop(event, "Indices") && ~isempty(event.Indices) + row = event.Indices(1, 1); + elseif isprop(event, "Selection") && ~isempty(event.Selection) + row = event.Selection(1, 1); + elseif isstruct(event) && isfield(event, "Indices") && ... + ~isempty(event.Indices) + row = event.Indices(1, 1); + elseif isstruct(event) && isfield(event, "Selection") && ... + ~isempty(event.Selection) + row = event.Selection(1, 1); + end +end + +function sources = discoverSources() + sources = [ ... + safeSources(@() recentReleases(5)), ... + safeSources(@() recentTags(5)), ... + safeSources(@() recentCommits(8)) ... + ]; +end + +function sources = safeSources(fetch) + try + sources = fetch(); + catch + sources = emptySources(); + end +end + +function sources = recentReleases(limit) + raw = githubRead("releases?per_page=" + limit); + template = makeSource("", "", "", "", "", ""); + sources = repmat(template, 1, limit); + count = 0; + for index = 1:numel(raw) + if logicalField(raw(index), "draft") || ... + logicalField(raw(index), "prerelease") + continue; + end + tag = textField(raw(index), "tag_name"); + if strlength(tag) == 0 + continue; + end + name = textField(raw(index), "name"); + if strlength(name) == 0 + name = tag; + end + count = count + 1; + sources(count) = tagSource("Release", tag, ... + name + " (" + tag + ")", textField(raw(index), "published_at")); + if count == limit + break; + end + end + sources = sources(1:count); +end + +function sources = recentTags(limit) + raw = githubRead("tags?per_page=" + limit); + template = makeSource("", "", "", "", "", ""); + sources = repmat(template, 1, limit); + count = 0; + for index = 1:min(numel(raw), limit) + tag = textField(raw(index), "name"); + if strlength(tag) == 0 + continue; + end + count = count + 1; + sources(count) = tagSource("Tag", tag, "Tag " + tag, ""); + end + sources = sources(1:count); +end + +function sources = recentCommits(limit) + raw = githubRead("commits?sha=main&per_page=" + limit); + template = makeSource("", "", "", "", "", ""); + sources = repmat(template, 1, limit); + count = 0; + for index = 1:min(numel(raw), limit) + sha = textField(raw(index), "sha"); + if strlength(sha) < 7 + continue; + end + short = extractBefore(sha, 8); + message = nestedText(raw(index), ["commit", "message"]); + message = extractBefore(message + newline, newline); + count = count + 1; + sources(count) = makeSource("Commit", "main commit " + short, ... + "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/" + ... + sha + ".zip", short, ... + nestedText(raw(index), ["commit", "author", "date"]), message); + end + sources = sources(1:count); +end + +function source = stableSource() + try + latest = githubRead("releases/latest"); + if isstruct(latest) && ~logicalField(latest, "draft") && ... + ~logicalField(latest, "prerelease") + tag = textField(latest, "tag_name"); + if strlength(tag) > 0 + name = textField(latest, "name"); + if strlength(name) == 0 + name = tag; + end + source = tagSource("Release", tag, name + " (" + tag + ")", ... + textField(latest, "published_at")); + return; + end + end + catch + end + try + tags = recentTags(20); + catch + error("LabKit:Deployment:StableSourceUnavailable", ... + "Could not find a stable GitHub release or tag."); + end + for index = 1:numel(tags) + if isempty(regexp(char(tags(index).Name), ... + '^v[0-9]+\.[0-9]+\.[0-9]+$', 'once')) + continue; + end + source = tags(index); + source.Kind = "Stable"; + source.Label = "GitHub stable tag " + source.Name; + return; + end + error("LabKit:Deployment:StableSourceUnavailable", "Could not find a stable GitHub release or tag."); +end + +function source = mainSource() + source = makeSource("Main", "GitHub main", ... + "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/" + ... + "refs/heads/main.zip", "main", "", "Main branch"); +end + +function result = installSource(root, source, opts) + assertUpdateRoot(root); + source = validateSource(source); + hook = testHook(); + if ~confirmInstall(root, source.Label, hook) + result = resultFor(root, source, false, "Update canceled.", ... + "", false, 0); + return; + end + notify(opts.ProgressFcn, "Preparing update workspace...", .10); + temporary = string(tempname); + cleanup = onCleanup(@() removeFolder(temporary)); + mkdir(temporary); + if strlength(hook.CandidateRoot) > 0 + candidate = hook.CandidateRoot; + else + zipPath = fullfile(temporary, "labkit.zip"); + notify(opts.ProgressFcn, "Downloading " + source.Label + "...", .25); + websave(zipPath, source.Url); + extractFolder = fullfile(temporary, "candidate"); + unzip(zipPath, extractFolder); + candidate = findCandidate(extractFolder); + end + assertCandidate(candidate); + assertSeparate(candidate, root); + notify(opts.ProgressFcn, "Replacing LabKit installation...", .70); + replacement = replaceInstallation(root, candidate, hook.FailAfterBackup); + notify(opts.ProgressFcn, "Update complete.", 1); + message = "Installed " + source.Label + ". Restart LabKit if it was open."; + if replacement.backupRetained + if replacement.preservedItemCount > 0 + message = message + " Local data was migrated; recovery backup " + ... + "retained at " + replacement.backupFolder + "."; + else + message = message + " Backup cleanup was incomplete; recovery " + ... + "files remain at " + replacement.backupFolder + "."; + end + end + result = resultFor(root, source, true, message, ... + replacement.backupFolder, replacement.backupRetained, ... + replacement.preservedItemCount); + delete(cleanup) +end + +function replacement = replaceInstallation(root, candidate, failAfterBackup) + parent = fileparts(root); + [~, name] = fileparts(root); + backup = fullfile(parent, name + ".version-backup-" + string(java.util.UUID.randomUUID())); + original = pwd; + relative = relativeWithin(root, original); + inside = samePath(root, original) || descendant(original, root); + state = capturePath(root); + if inside + cd(parent); + end + detachPath(state); + cleanup = onCleanup(@() restoreContext(original, root, parent, relative, inside, state)); + [moved, message] = movefile(root, backup, "f"); + if ~moved + error("LabKit:Deployment:ReplaceFailed", "Could not preserve current installation: %s", message); + end + try + if failAfterBackup + error("LabKit:Deployment:InjectedFailure", "Injected failure after backup."); + end + [copied, message] = copyfile(candidate, root); + if ~copied + error("LabKit:Deployment:ReplaceFailed", "Could not copy candidate: %s", message); + end + assertCandidate(root); + preservation = preserveLocal(backup, root); + catch cause + rollback(root, backup, cause); + end + replacement = struct( ... + "backupFolder", "", ... + "backupRetained", false, ... + "preservedItemCount", preservation.itemCount); + if preservation.itemCount > 0 + replacement.backupFolder = string(backup); + replacement.backupRetained = true; + else + [removed, ~] = rmdir(backup, "s"); + if ~removed + replacement.backupFolder = string(backup); + replacement.backupRetained = true; + end + end + delete(cleanup) +end + +function rollback(root, backup, cause) + if exist(root, "dir") == 7 + [removed, message] = rmdir(root, "s"); + if ~removed + rollbackFailure(cause, backup, "Partial replacement could not be removed: " + string(message)); + end + end + [restored, message] = copyfile(backup, root); + if ~restored + rollbackFailure(cause, backup, "Backup could not be restored: " + string(message)); + end + try + assertUpdateRoot(root); + catch validationCause + rollbackFailure(cause, backup, "Restored installation failed validation: " + failureText(validationCause)); + end + [removed, message] = rmdir(backup, "s"); + if ~removed + rollbackFailure(cause, backup, "Backup cleanup failed: " + string(message)); + end + rethrow(cause) +end + +function preservation = preserveLocal(backup, root) + entries = [ + "private_apps" + "artifacts" + string(fullfile("resources", "project")) + "photos" + "derived" + "profile_results" + "LabKit.prj" + ]; + present = false(size(entries)); + for index = 1:numel(entries) + source = fullfile(backup, entries(index)); + if entryExists(source) + present(index) = true; + if entryExists(fullfile(root, entries(index))) + error("LabKit:Deployment:LocalDataConflict", ... + "Candidate conflicts with local data: %s", entries(index)); + end + end + end + for index = find(present)' + target = fullfile(root, entries(index)); + if exist(fileparts(target), "dir") ~= 7 + [created, createMessage] = mkdir(fileparts(target)); + if ~created + error("LabKit:Deployment:LocalDataCopyFailed", ... + "Could not prepare %s: %s", entries(index), createMessage); + end + end + [copied, message] = copyfile(fullfile(backup, entries(index)), target); + if ~copied + error("LabKit:Deployment:LocalDataCopyFailed", ... + "Could not preserve %s: %s", entries(index), message); + end + end + preservation = struct("itemCount", sum(present)); +end + +function assertUpdateRoot(root) + if samePath(root, fileparts(root)) + error("LabKit:Deployment:UnsafeRoot", "Refusing to replace a filesystem root."); + end + if exist(fullfile(root, ".git"), "file") == 2 || exist(fullfile(root, ".git"), "dir") == 7 + error("LabKit:Deployment:GitCheckout", "Git checkouts must be updated with git."); + end + validRoot = exist(fullfile(root, "labkit_launcher.m"), "file") == 2 && ... + exist(fullfile(root, "+labkit"), "dir") == 7 && ... + exist(fullfile(root, "apps"), "dir") == 7; + if ~validRoot + error("LabKit:Deployment:InvalidRoot", "Root is not a LabKit installation safe to replace."); + end +end + +function assertCandidate(root) + valid = exist(fullfile(root, "labkit_launcher.m"), "file") == 2 && ... + exist(fullfile(root, "+labkit"), "dir") == 7 && ... + exist(fullfile(root, "apps"), "dir") == 7 && ... + exist(fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"), "file") == 2; + if ~valid + error("LabKit:Deployment:InvalidCandidate", "Candidate is not a minimal LabKit root."); + end +end + +function assertSeparate(candidate, root) + if samePath(candidate, root) || descendant(candidate, root) || descendant(root, candidate) + error("LabKit:Deployment:InvalidCandidate", "Candidate and root must be separate, non-nested folders."); + end +end + +function sources = emptySources() + sources = struct( ... + "Kind", {}, ... + "Label", {}, ... + "Url", {}, ... + "Name", {}, ... + "Date", {}, ... + "Summary", {}); +end + +function value = makeSource(kind, label, url, name, date, summary) + value = struct( ... + "Kind", string(kind), ... + "Label", string(label), ... + "Url", string(url), ... + "Name", string(name), ... + "Date", string(date), ... + "Summary", string(summary)); +end + +function value = tagSource(kind, tag, summary, date) + value = makeSource(kind, ... + "GitHub " + lower(string(kind)) + " " + tag, ... + "https://github.com/Pluze/LabKit-MATLAB-Workbench/archive/" + ... + "refs/tags/" + tag + ".zip", tag, date, summary); +end +function value = validateSource(value) + required = ["Kind", "Label", "Url", "Name"]; + if ~isstruct(value) || ~isscalar(value) || ~all(isfield(value, required)) + error("LabKit:Deployment:InvalidSource", "Selected source is malformed."); + end + for field = required + candidate = value.(field); + if ~(ischar(candidate) || (isstring(candidate) && isscalar(candidate))) || ... + ismissing(string(candidate)) || strlength(strtrim(string(candidate))) == 0 + error("LabKit:Deployment:InvalidSource", "Selected source field %s must be nonempty scalar text.", field); + end + end + url = string(value.Url); + if ~startsWith(url, repositoryArchivePrefix()) || ~endsWith(url, ".zip") + error("LabKit:Deployment:InvalidSource", ... + "Selected source URL must be a LabKit GitHub archive ZIP."); + end + value = makeSource(value.Kind, value.Label, value.Url, value.Name, ... + textField(value, "Date"), textField(value, "Summary")); +end +function rows = sourceRows(sources) + rows = cell(numel(sources), 4); + for index = 1:numel(sources) + rows(index, :) = cellstr([ ... + sources(index).Kind + sources(index).Name + sources(index).Date + sources(index).Summary + ]'); + end +end +function lines = currentInstallLines(root) + info = currentLauncherInfo(root); + lines = [ ... + "Current launcher: " + info.displayName + " v" + info.version + " (" + info.updated + ")" + "Install folder: " + root + "Selected ZIP updates keep a sibling backup when local data is migrated." + ]; +end + +function info = currentLauncherInfo(root) + info = struct( ... + "displayName", "LabKit App Launcher", ... + "version", "unavailable", ... + "updated", "unavailable"); + filepath = fullfile(root, "+labkit", "+app", "+internal", "+launcher", "dispatch.m"); + if exist(filepath, "file") ~= 2 + return; + end + try + source = string(fileread(filepath)); + displayName = regexp(source, 'displayName", "([^"]+)"', 'tokens', 'once'); + version = regexp(source, 'version", "([^"]+)"', 'tokens', 'once'); + updated = regexp(source, 'updated", "([^"]+)"', 'tokens', 'once'); + if ~isempty(displayName) + info.displayName = string(displayName{1}); + end + if ~isempty(version) + info.version = string(version{1}); + end + if ~isempty(updated) + info.updated = string(updated{1}); + end + catch + end +end + +function raw = githubRead(suffix) + url = "https://api.github.com/repos/Pluze/" + ... + "LabKit-MATLAB-Workbench/" + suffix; + options = weboptions( ... + "Timeout", 20, ... + "UserAgent", "MATLAB LabKit Deployment"); + raw = webread(char(url), options); +end +function value = textField(item, field) + value = ""; + if ~isstruct(item) || ~isfield(item, field) + return; + end + candidate = item.(field); + if ischar(candidate) || (isstring(candidate) && isscalar(candidate)) + value = string(candidate); + end +end + +function value = nestedText(item, fields) + value = ""; + for field = fields + if ~isstruct(item) || ~isfield(item, field) + return; + end + item = item.(field); + end + if ischar(item) || (isstring(item) && isscalar(item)) + value = string(item); + end +end + +function value = logicalField(item, field) + value = false; + if isstruct(item) && isfield(item, field) && isscalar(item.(field)) + value = logical(item.(field)); + end +end +function candidate = findCandidate(folder) + entries = dir(folder); + for index = 1:numel(entries) + if ~entries(index).isdir || startsWith(entries(index).name, ".") + continue; + end + candidate = fullfile(entries(index).folder, entries(index).name); + if exist(fullfile(candidate, "labkit_launcher.m"), "file") == 2 + return; + end + end + error("LabKit:Deployment:InvalidCandidate", ... + "ZIP does not contain a LabKit root."); +end + +function hook = testHook() + hook = struct( ... + "CandidateRoot", "", ... + "FailAfterBackup", false, ... + "Confirm", []); + if ~isappdata(groot, "labkitVersionManagerTestHook") + return; + end + value = getappdata(groot, "labkitVersionManagerTestHook"); + if ~isstruct(value) + return; + end + if isfield(value, "CandidateRoot") + hook.CandidateRoot = string(value.CandidateRoot); + end + if isfield(value, "FailAfterBackup") + hook.FailAfterBackup = logical(value.FailAfterBackup); + end + if isfield(value, "Confirm") && ... + islogical(value.Confirm) && isscalar(value.Confirm) + hook.Confirm = logical(value.Confirm); + end +end +function tf = confirmInstall(root, label, hook) + if ~isempty(hook.Confirm) + tf = hook.Confirm; + return; + end + try + answer = questdlg("Install " + label + " into " + root + "?", ... + "Install LabKit Version", "Install", "Cancel", "Cancel"); + tf = strcmp(answer, "Install"); + catch + tf = false; + end +end +function state = capturePath(root) + entries = string(strsplit(path, pathsep)); + matches = false(size(entries)); + relative = strings(size(entries)); + for index = 1:numel(entries) + if samePath(entries(index), root) + matches(index) = true; + elseif descendant(entries(index), root) + matches(index) = true; + relative(index) = relativeWithin(root, entries(index)); + end + end + state = struct("entries", entries, "matches", matches, "relative", relative); +end + +function detachPath(state) + entries = state.entries(~state.matches); + path(char(strjoin(entries(strlength(entries) > 0), pathsep))); + rehash; +end + +function restoreContext(original, root, parent, relative, inside, state) + entries = state.entries; + valid = true; + try + assertUpdateRoot(root); + catch + valid = false; + end + for index = find(state.matches) + candidate = ""; + if valid + if strlength(state.relative(index)) == 0 + candidate = root; + else + candidate = fullfile(root, state.relative(index)); + end + end + if strlength(candidate) > 0 && exist(candidate, "dir") == 7 + entries(index) = candidate; + else + entries(index) = ""; + end + end + path(char(strjoin(entries(strlength(entries) > 0), pathsep))); + rehash; + if ~inside && exist(original, "dir") == 7 + cd(original); + elseif strlength(relative) > 0 && exist(fullfile(root, relative), "dir") == 7 + cd(fullfile(root, relative)); + elseif exist(root, "dir") == 7 + cd(root); + elseif exist(parent, "dir") == 7 + cd(parent); + end +end +function value = normalizedPath(value) + if ~isTextScalar(value) || ismissing(string(value)) || ... + strlength(string(value)) == 0 + error("LabKit:Deployment:InvalidPath", ... + "Filesystem paths must be nonempty scalar text."); + end + emptyStrings = javaArray("java.lang.String", 0); + pathObject = java.nio.file.Paths.get(char(value), emptyStrings); + value = string(pathObject.toAbsolutePath().normalize().toString()); + if ispc + value = lower(value); + end +end + +function tf = samePath(left, right) + if ~isTextScalar(left) || ~isTextScalar(right) || ... + strlength(string(left)) == 0 || strlength(string(right)) == 0 + tf = false; + return; + end + tf = strcmp(normalizedPath(left), normalizedPath(right)); +end + +function tf = descendant(candidate, root) + if ~isTextScalar(candidate) || ~isTextScalar(root) || ... + strlength(string(candidate)) == 0 || strlength(string(root)) == 0 + tf = false; + return; + end + candidate = normalizedPath(candidate); + root = normalizedPath(root); + tf = startsWith(candidate, root + string(filesep)); +end + +function value = relativeWithin(root, folder) + value = ""; + if descendant(folder, root) + value = extractAfter( ... + normalizedPath(folder), strlength(normalizedPath(root)) + 1); + end +end + +function tf = entryExists(value) + tf = isTextScalar(value) && strlength(string(value)) > 0 && ... + (exist(value, "file") == 2 || exist(value, "dir") == 7); +end + +function rollbackFailure(cause, backup, detail) + error("LabKit:Deployment:RollbackFailed", ... + "Update failed (%s). %s Recovery backup remains at %s.", ... + failureText(cause), detail, backup); +end + +function value = failureText(cause) + value = string(cause.message); + if strlength(string(cause.identifier)) > 0 + value = string(cause.identifier) + ": " + value; + end +end + +function notify(callback, message, fraction) + if isempty(callback) + return; + end + try + callback(string(message), fraction); + catch + end +end +function result = resultFor(root, source, updated, message, backup, retained, preserved) + result = struct( ... + "root", string(root), ... + "source", source, ... + "updated", logical(updated), ... + "message", string(message), ... + "backupFolder", string(backup), ... + "backupRetained", logical(retained), ... + "preservedItemCount", preserved); +end + +function removeFolder(folder) + if isTextScalar(folder) && strlength(string(folder)) > 0 && ... + exist(folder, "dir") == 7 + rmdir(folder, "s"); + end +end + +function root = repoRoot() + root = fileparts(fileparts(fileparts(mfilename("fullpath")))); +end + +function prefix = repositoryArchivePrefix() + prefix = "https://github.com/Pluze/" + ... + "LabKit-MATLAB-Workbench/archive/"; +end + +function tf = isTextScalar(value) + tf = (ischar(value) && isrow(value)) || ... + (isstring(value) && isscalar(value)); +end diff --git a/tools/docs/private/loadLabKitDocumentation.m b/tools/docs/private/loadLabKitDocumentation.m index 64058ab09..a952afffe 100644 --- a/tools/docs/private/loadLabKitDocumentation.m +++ b/tools/docs/private/loadLabKitDocumentation.m @@ -415,6 +415,9 @@ if contains(filepath, filesep + "private" + filesep) continue; end + if contains(filepath, filesep + "+internal" + filesep) + continue; + end if contains(filepath, filesep + "@") continue; end diff --git a/tools/maintenance/cleanLabKitArtifacts.m b/tools/maintenance/cleanLabKitArtifacts.m new file mode 100644 index 000000000..3278a8cda --- /dev/null +++ b/tools/maintenance/cleanLabKitArtifacts.m @@ -0,0 +1,146 @@ +function result = cleanLabKitArtifacts(root, varargin) +%CLEANLABKITARTIFACTS Remove only generated LabKit artifact folders. +% +% Syntax: +% result = cleanLabKitArtifacts +% result = cleanLabKitArtifacts(root) +% result = cleanLabKitArtifacts(root, ProgressFcn=fcn) +% +% Inputs: +% root - LabKit installation folder. When omitted, uses the repository root +% containing this tool. The folder must contain labkit_launcher.m and may +% not be a filesystem root. +% +% Name-Value Arguments: +% ProgressFcn - Function handle called as ProgressFcn(message, value), where +% value is a finite fraction from 0 through 1. The callback is optional. +% +% Outputs: +% result - Scalar struct with root, removedCount, removedTargets, and errors. +% removedTargets contains only successfully removed generated targets. +% errors contains removal failures; invalid roots or unsafe targets throw +% a stable error instead of performing any deletion. +% +% Deletion range: +% This tool removes only root/artifacts. It never removes projects, source, +% application folders, exported laboratory data, or a target that resolves +% outside root. The operation is idempotent when artifacts is absent. +% +% Errors: +% cleanLabKitArtifacts:InvalidRoot rejects non-scalar, empty, filesystem-root, +% and non-LabKit roots. cleanLabKitArtifacts:UnsafeTarget rejects a generated +% target that resolves outside the validated root. +% cleanLabKitArtifacts:InvalidOption rejects unsupported name-value syntax. +% cleanLabKitArtifacts:InvalidProgressFcn rejects a non-function-handle +% ProgressFcn. Deletion failures are collected in result.errors; exceptions +% thrown by ProgressFcn propagate to the caller. +% +% See also rmdir, delete + + if nargin < 1 || isempty(root) + root = defaultRoot(); + end + progressFcn = parseOptions(varargin{:}); + root = validateRoot(root); + relativeTarget = "artifacts"; + target = fullfile(root, char(relativeTarget)); + removedTargets = strings(0, 1); + errors = strings(0, 1); + + notifyProgress(progressFcn, "Checking cleanup root...", 0.05); + notifyProgress(progressFcn, "Finding generated artifact targets...", 0.20); + notifyProgress(progressFcn, "Checking " + relativeTarget + "...", 0.25); + validateCleanLabKitArtifactsTarget(root, target, relativeTarget); + if exist(target, "dir") == 7 + notifyProgress(progressFcn, "Removing " + relativeTarget + "...", 0.55); + try + rmdir(target, "s"); + removedTargets(end + 1, 1) = relativeTarget; + catch cause + errors(end + 1, 1) = string(cause.message); + end + elseif exist(target, "file") == 2 + notifyProgress(progressFcn, "Removing " + relativeTarget + "...", 0.55); + try + delete(target); + removedTargets(end + 1, 1) = relativeTarget; + catch cause + errors(end + 1, 1) = string(cause.message); + end + else + notifyProgress(progressFcn, relativeTarget + " is already clean.", 0.85); + end + notifyProgress(progressFcn, "Clean Artifacts complete.", 1.00); + result = struct("root", string(root), "removedCount", numel(removedTargets), ... + "removedTargets", removedTargets, "errors", errors); +end + +function root = defaultRoot() +root = fileparts(fileparts(fileparts(mfilename("fullpath")))); +end + +function progressFcn = parseOptions(varargin) +progressFcn = []; +if isempty(varargin) + return; +end +if numel(varargin) ~= 2 || ~(ischar(varargin{1}) || ... + (isstring(varargin{1}) && isscalar(varargin{1}))) || ... + ~strcmpi(string(varargin{1}), "ProgressFcn") + error("cleanLabKitArtifacts:InvalidOption", ... + "Use the ProgressFcn name-value argument."); +end +progressFcn = varargin{2}; +if ~isa(progressFcn, "function_handle") + error("cleanLabKitArtifacts:InvalidProgressFcn", ... + "ProgressFcn must be a function handle."); +end +end + +function root = validateRoot(root) +if ~(ischar(root) || (isstring(root) && isscalar(root))) || ... + ismissing(string(root)) || strlength(strip(string(root))) == 0 + error("cleanLabKitArtifacts:InvalidRoot", ... + "Root must be nonempty scalar text naming a LabKit installation."); +end +if exist(char(root), "dir") ~= 7 + error("cleanLabKitArtifacts:InvalidRoot", ... + "Root must name an existing LabKit installation folder."); +end +try + root = realExistingPath(root); +catch + error("cleanLabKitArtifacts:InvalidRoot", ... + "Root must resolve to an accessible LabKit installation folder."); +end +if isFilesystemRoot(root) || exist(fullfile(root, "labkit_launcher.m"), "file") ~= 2 + error("cleanLabKitArtifacts:InvalidRoot", ... + "Clean Artifacts refused unsafe or non-LabKit root: %s", root); +end +end + +function resolvedPath = realExistingPath(filepath) +file = java.io.File(char(filepath)); +linkOptions = javaArray("java.nio.file.LinkOption", 0); +resolvedPath = char(file.toPath().toRealPath(linkOptions).toString()); +end + +function tf = isFilesystemRoot(filepath) +file = java.io.File(char(filepath)); +parent = file.getParentFile(); +tf = isempty(parent) || isSamePath(filepath, char(parent.getCanonicalPath())); +end + +function tf = isSamePath(left, right) +if ispc + tf = strcmpi(char(left), char(right)); +else + tf = strcmp(char(left), char(right)); +end +end + +function notifyProgress(progressFcn, message, value) +if ~isempty(progressFcn) + progressFcn(char(message), value); +end +end diff --git a/tools/maintenance/private/validateCleanLabKitArtifactsTarget.m b/tools/maintenance/private/validateCleanLabKitArtifactsTarget.m new file mode 100644 index 000000000..0cdf94cae --- /dev/null +++ b/tools/maintenance/private/validateCleanLabKitArtifactsTarget.m @@ -0,0 +1,54 @@ +function validateCleanLabKitArtifactsTarget(root, target, relativeTarget) +%VALIDATECLEANLABKITARTIFACTSTARGET Reject a cleanup target outside its root. +% Expected caller: cleanLabKitArtifacts. Inputs are one validated root, the +% concrete generated target, and its fixed relative name. No deletion occurs. + + if exist(target, "dir") ~= 7 && exist(target, "file") ~= 2 + return; + end + try + canonicalRoot = realExistingPath(root); + canonicalTarget = realExistingPath(target); + catch + error("cleanLabKitArtifacts:UnsafeTarget", ... + "Clean Artifacts refused a generated target that cannot be resolved safely."); + end + expectedTarget = lexicalPath(fullfile(canonicalRoot, char(relativeTarget))); + if ~isSamePath(canonicalTarget, expectedTarget) || ... + isSamePath(canonicalTarget, canonicalRoot) || ... + ~isDescendant(canonicalTarget, canonicalRoot) + error("cleanLabKitArtifacts:UnsafeTarget", ... + "Clean Artifacts refused generated target outside root: %s", target); + end +end + +function resolvedPath = realExistingPath(filepath) +file = java.io.File(char(filepath)); +linkOptions = javaArray("java.nio.file.LinkOption", 0); +resolvedPath = char(file.toPath().toRealPath(linkOptions).toString()); +end + +function resolvedPath = lexicalPath(filepath) +file = java.io.File(char(filepath)); +resolvedPath = char(file.toPath().toAbsolutePath().normalize().toString()); +end + +function tf = isDescendant(filepath, root) +separatorRoot = string(root); +if ~endsWith(separatorRoot, filesep) + separatorRoot = separatorRoot + filesep; +end +if ispc + tf = startsWith(lower(string(filepath)), lower(separatorRoot)); +else + tf = startsWith(string(filepath), separatorRoot); +end +end + +function tf = isSamePath(left, right) +if ispc + tf = strcmpi(char(left), char(right)); +else + tf = strcmp(char(left), char(right)); +end +end