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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ function alert(obj, message, title)
end

function result = chooseInputFile(~, filters, startPath)
filters = labkit.app.internal.NativeAdapterValues.dialogFilters(filters);
[name, folder] = uigetfile(filters, "Choose input file", ...
labkit.app.internal.NativeAdapterValues.safeStartPath(startPath));
result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder);
Expand All @@ -272,6 +273,7 @@ function alert(obj, message, title)
end

function result = chooseOutputFile(~, filters, startPath)
filters = labkit.app.internal.NativeAdapterValues.dialogFilters(filters);
[name, folder] = uiputfile(filters, "Choose output file", ...
labkit.app.internal.NativeAdapterValues.safeStartPath(startPath));
result = labkit.app.internal.NativeAdapterValues.dialogPath(name, folder);
Expand Down
18 changes: 14 additions & 4 deletions +labkit/+app/+internal/NativeAdapterValues.m
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ function restoreViewport(axes, viewport)
end

function value = dialogFilters(filters)
filters = string(filters(:));
filters = textColumn(filters);
if isempty(filters)
value = "*.*";
elseif mod(numel(filters), 2) == 0
Expand All @@ -401,7 +401,7 @@ function restoreViewport(axes, viewport)
end

function paths = filesInFolder(folder, filters, recursive)
filters = string(filters(:));
filters = textColumn(filters);
if mod(numel(filters), 2) == 0
filters = filters(1:2:end);
end
Expand Down Expand Up @@ -467,11 +467,11 @@ function restoreViewport(axes, viewport)
end

function labels = formatFileLabels(paths, statuses)
paths = string(paths(:));
paths = textColumn(paths);
if nargin < 2 || isempty(statuses)
statuses = strings(size(paths));
else
statuses = string(statuses(:));
statuses = textColumn(statuses);
end
if numel(statuses) ~= numel(paths)
error("labkit:app:contract:InvalidValue", ...
Expand Down Expand Up @@ -551,3 +551,13 @@ function restoreViewport(axes, viewport)

end
end

function values = textColumn(values)
if isempty(values)
values = strings(0, 1);
elseif ischar(values)
values = string(values);
else
values = string(values(:));
end
end
8 changes: 7 additions & 1 deletion +labkit/+app/+internal/PortableSourceStore.m
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,13 @@ function validateRecord(record)
if ~(ischar(value) || isstring(value) || iscellstr(value))
invalid("Source paths must be text.");
end
paths = string(value(:));
if isempty(value)
paths = strings(0, 1);
elseif ischar(value)
paths = string(value);
else
paths = string(value(:));
end
if any(strlength(paths) == 0)
invalid("Source paths must be nonempty.");
end
Expand Down
38 changes: 34 additions & 4 deletions +labkit/+app/+internal/RuntimeKernel.m
Original file line number Diff line number Diff line change
Expand Up @@ -192,20 +192,50 @@ function setTraceCapture(obj, enabled)
catch cause
obj.Recorder.finish( ...
operation, "failed", "notApplicable", cause);
rethrow(cause);
destination = obj.exportDiagnosticTextFallback( ...
destination, cause);
end
end

function destination = exportDiagnosticBundleInteractive(obj)
choice = obj.Context.chooseOutputFile( ...
{"*.zip", "Diagnostic bundle (*.zip)"}, ...
"labkit-diagnostics.zip");
destination = "";
try
choice = obj.Context.chooseOutputFile( ...
{"*.zip", "Diagnostic bundle (*.zip)"}, ...
"labkit-diagnostics.zip");
catch cause
destination = obj.exportDiagnosticTextFallback("", cause);
obj.alertDiagnosticTextFallback(destination);
return;
end
if ~choice.Cancelled
destination = obj.exportDiagnosticBundle(choice.Value);
if endsWith(destination, ".txt", ...
IgnoreCase=true)
obj.alertDiagnosticTextFallback(destination);
end
end
end

function destination = exportDiagnosticTextFallback( ...
obj, preferredDestination, cause)
obj.Recorder.log( ...
"warning", "diagnostics.text_fallback.started", ...
"Diagnostic ZIP export failed; writing a plain-text fallback.", ...
Category="runtime.lifecycle", Audience="user", ...
Exception=cause);
destination = obj.Recorder.exportTextFallback( ...
preferredDestination, cause);
end

function alertDiagnosticTextFallback(obj, destination)
obj.Context.alert( ...
"The diagnostic ZIP could not be exported. A plain-text " + ...
"diagnostic fallback was written to:" + newline + ...
string(destination), ...
"Diagnostic Text Fallback");
end

function supported = supportsSyntheticInputs(obj)
supported = ~isempty(obj.Application.BuildSyntheticSample);
end
Expand Down
147 changes: 147 additions & 0 deletions +labkit/+app/+internal/SessionDiagnosticBundle.m
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,29 @@
end
clear zipCleanup cleanup
end

function destination = writeFallback(snapshot, preferredDestination)
% RuntimeKernel supplies sanitized in-memory records. Prefer a
% surviving selected folder, then MATLAB's writable temp folder.
snapshot = validateFallbackSnapshot(snapshot);
folders = fallbackFolders(preferredDestination);
failure = [];
for index = 1:numel(folders)
destination = availableFallbackPath(folders(index));
try
writeText(destination, fallbackLines(snapshot));
return;
catch cause
failure = cause;
end
end
if isempty(failure)
error("labkit:app:runtime:DiagnosticWriteFailed", ...
"No diagnostic text fallback folder is available.");
end
error("labkit:app:runtime:DiagnosticWriteFailed", ...
"Could not write the diagnostic text fallback.");
end
end
end

Expand Down Expand Up @@ -101,6 +124,30 @@ function validateRecord(record)
record.attributes);
end

function snapshot = validateFallbackSnapshot(snapshot)
fields = ["application", "events", "capture", "failureIdentifier"];
if ~isstruct(snapshot) || ~isscalar(snapshot) || ...
~isequal(string(fieldnames(snapshot)), fields.') || ...
~isstruct(snapshot.application) || ...
~isscalar(snapshot.application) || ...
~isstruct(snapshot.capture) || ~isscalar(snapshot.capture)
error("labkit:app:runtime:InvariantFailure", ...
"Diagnostic text fallback snapshot is invalid.");
end
if isempty(snapshot.events)
snapshot.events = emptyEvents();
elseif ~isstruct(snapshot.events)
error("labkit:app:runtime:InvariantFailure", ...
"Diagnostic text fallback 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 destination = diagnosticZipPath(destination)
if ~(ischar(destination) || ...
(isstring(destination) && isscalar(destination))) || ...
Expand Down Expand Up @@ -144,6 +191,106 @@ function validateRecord(record)
];
end

function value = fallbackLines(snapshot)
application = snapshot.application;
capture = snapshot.capture;
value = [
"LabKit Diagnostic Text Fallback"
""
"The normal diagnostic ZIP could not be written. This single text file contains the surviving privacy-safe Runtime session records."
"It does not contain projects, scientific inputs or results, images, screenshots, source files, paths, or original filenames."
""
"Application:"
"- Name: " + textField(application, "title")
"- App ID: " + textField(application, "appId")
"- App version: " + textField(application, "appVersion")
"- LabKit App SDK version: " + ...
textField(application, "labkitAppVersion")
"- ZIP failure identifier: " + string(snapshot.failureIdentifier)
""
"Capture notes:"
"- TRACE enabled at fallback: " + ...
yesNo(logicalField(capture, "traceEnabled"))
"- In-memory live view truncated: " + ...
yesNo(logicalField(capture, "inMemoryTruncated"))
"- Retained canonical event count: " + ...
string(numel(snapshot.events))
""
"Session timeline:"
timeline(snapshot.events)
""
"Structured failure records:"
fallbackErrorLines(snapshot.events)
];
end

function value = fallbackErrorLines(events)
records = errorRecords(events);
if isempty(records)
value = "(none)";
return;
end
value = strings(numel(records), 1);
for index = 1:numel(records)
value(index) = string(jsonencode(records(index)));
end
end

function value = textField(structure, name)
if isfield(structure, name)
value = string(structure.(name));
else
value = "unknown";
end
end

function value = logicalField(structure, name)
value = false;
if isfield(structure, name) && isscalar(structure.(name))
value = logical(structure.(name));
end
end

function folders = fallbackFolders(preferredDestination)
folders = strings(0, 1);
if ischar(preferredDestination) || ...
(isstring(preferredDestination) && isscalar(preferredDestination))
preferredDestination = strip(string(preferredDestination));
if strlength(preferredDestination) > 0
folder = string(fileparts(preferredDestination));
if strlength(folder) == 0
folder = string(pwd);
end
if exist(char(folder), "dir") == 7
folders(end + 1, 1) = folder;
end
end
end
temporaryFolder = string(tempdir);
if exist(char(temporaryFolder), "dir") == 7
folders(end + 1, 1) = temporaryFolder;
end
folders = unique(folders, "stable");
end

function destination = availableFallbackPath(folder)
destination = fullfile(folder, "labkit-diagnostics-fallback.txt");
if exist(char(destination), "file") ~= 2 && ...
exist(char(destination), "dir") ~= 7
return;
end
for index = 2:1000
candidate = fullfile(folder, ...
"labkit-diagnostics-fallback-" + string(index) + ".txt");
if exist(char(candidate), "file") ~= 2 && ...
exist(char(candidate), "dir") ~= 7
destination = candidate;
return;
end
end
destination = string(tempname(char(folder))) + ".txt";
end

function value = timeline(events)
value = strings(numel(events), 1);
for index = 1:numel(events)
Expand Down
38 changes: 38 additions & 0 deletions +labkit/+app/+internal/SessionDiagnostics.m
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,44 @@ function setTraceEnabled(obj, enabled)
snapshot, destination);
end

function destination = exportTextFallback( ...
obj, preferredDestination, failure)
% Keep this path independent of the journal and ZIP staging so a
% failure in either subsystem cannot consume the last evidence.
try
streamSnapshot = obj.Stream.captureSnapshot();
events = streamSnapshot.events;
capture = struct( ...
"traceEnabled", streamSnapshot.traceEnabled, ...
"inMemoryTruncated", ...
streamSnapshot.inMemoryTruncated);
catch
events = [];
capture = struct( ...
"traceEnabled", false, ...
"inMemoryTruncated", true);
end
sdk = labkit.app.version();
application = struct( ...
"title", obj.Application.Title, ...
"appId", obj.Application.AppId, ...
"appVersion", obj.Application.AppVersion, ...
"labkitAppVersion", string(sdk.current));
failureIdentifier = "unknown";
if isa(failure, "MException") && ...
strlength(string(failure.identifier)) > 0
failureIdentifier = string(failure.identifier);
end
snapshot = struct( ...
"application", application, ...
"events", events, ...
"capture", capture, ...
"failureIdentifier", failureIdentifier);
destination = ...
labkit.app.internal.SessionDiagnosticBundle.writeFallback( ...
snapshot, preferredDestination);
end

function close(obj)
if obj.Closed
return;
Expand Down
5 changes: 4 additions & 1 deletion +labkit/+app/+internal/SessionEventValidator.m
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ function rejectSensitiveAttributeKey(key, value)
end

function count = maximumRetainedAttributeKeyLength()
count = 64;
% R2022b truncates MATLAB identifiers after 63 characters. Attribute keys
% arrive as struct field names, so the portable contract cannot exceed that
% floor even when a newer MATLAB reports a larger namelengthmax.
count = 63;
end

function count = maximumRetainedSemanticTokenLength()
Expand Down
4 changes: 4 additions & 0 deletions +labkit/+app/CallbackContext.m
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ function saveRecoveryDocument(obj, state, filepath)
elseif ~(ischar(ids) || isstring(ids) || iscellstr(ids))
error("labkit:app:contract:InvalidValue", ...
"CallbackContext source ids must be text.");
elseif isempty(ids)
ids = strings(0, 1);
elseif ischar(ids)
ids = string(ids);
else
ids = string(ids(:));
end
Expand Down
2 changes: 1 addition & 1 deletion +labkit/+app/version.m
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@
% labkit.app.Definition

info = labkit.contract.versionInfo( ...
"app", "2.0.1", ">=2 <3", "stable", ...
"app", "2.0.3", ">=2 <3", "stable", ...
"Explicit LabKit App SDK contract for tracked production Apps.");
end
Loading