Skip to content

Navigation Menu

Sign in
Sign up

fix(shots): read back shots imported from de1app - #786

Open
allofmeng wants to merge 2 commits into
main from
fix/shot-history-unreadable-imported-profile
Open

fix(shots): read back shots imported from de1app #786
allofmeng wants to merge 2 commits into
main from
fix/shot-history-unreadable-imported-profile

Conversation

@allofmeng

@allofmeng allofmeng commented Sep 4, 2026
edited
Loading

Copy link
Copy Markdown
Collaborator

Summary

What changed, and why?

  • de1app .shot files record the shot, not the profile that produced it, so TclShotParser (tcl_shot_parser.dart:48) and the no-profile branch of ShotV2JsonParser (shot_v2_json_parser.dart:80) store a placeholder profile with an empty steps array. Profile.fromJson (profile.dart:52) refuses to read that back, so importing a de1app folder made every later GET /api/v1/shots throw ArgumentError and return 500, and streamline.js rendered no history at all.
  • DriftStorageService mapped rows with a bare rows.map(ShotMapper.fromRow), so the single unreadable row aborted the whole page — one 2021 import hid a history of otherwise fine shots.
  • ShotMapper.fromRow now reads the stored workflow with Workflow.fromRecordedJson, which parses its profile with Profile.fromRecordedJson. A recorded profile is history, not something to brew from.
  • Workflow.fromJson and Profile.fromJson stay strict, backing the profile library, the profile and workflow REST handlers, the stored current workflow, and DE1 upload.
  • Reading was only half of it (review feedback). PUT /api/v1/shots/<id> merges the patch into existingShot.toJson() and reparses the result, and ShotImporter reads back shots from a backup export; both used the strict ShotRecord.fromJson, so a recovered de1app shot read back fine but threw again on any annotation edit or re-import. ShotRecord.fromRecordedJson mirrors the same split and is used on those two stored-shot paths; ShotRecord.fromJson is unchanged and stays strict everywhere else.
  • ShotMapper.fromRows skips and logs a row it cannot map, so future corruption costs its own shot instead of the entire list. Single-shot reads (getShot, getLatestShot) still surface the error, because there the failing row is the answer.

The evidence, from the logs attached to the issue. webview_console.log:

[streamline.js] [WARN] Could not load shot history source: Error: HTTP error! status: 500

nohisotry_log.txt, 132 identical failures:

19:53:27 SEVERE ShotsHandler - Error getting paginated shots
### ArgumentError: Invalid argument(s): Profile must have a non-empty "steps" array
#0 new Profile.fromJson (profile.dart:52)
#1 new Workflow.fromJson (workflow.dart:68)
#2 ShotMapper.fromRow (shot_mapper.dart:12)
#9 DriftStorageService.getShotsPaginated (drift_storage_service.dart:95)
#10 ShotsHandler._getShots (shots_handler.dart:107)

The timeline pins the trigger to a de1app import:

19:38-19:52 GET /api/v1/shots -> 200 (repeatedly, history works)
19:53:16 SafFolderCopier - Picked directory: de1plus ... Copied 6 files
19:53:27 GET /api/v1/shots -> 500 (and every call after)

The poison row is named by the one single-shot failure: GET /api/v1/shots/de1app-1626149813 -> 500, while de1app-1785894912, -1785895243 and -1785895651 still return 200. Unix 1626149813 is July 2021, an old TCL-format .shot file; the 2026 ones came through the v2 JSON parser's real-profile branch.

Linked Issue

Fixes #784

Verification

How did you verify the change? Include relevant tests and any manual or hardware testing.

  • flutter analyzeNo issues found!
  • flutter test — 3866 passed, 8 failed. The 8 are test/webui_support/webui_token_injection_test.dart failing with SocketException: Failed to create server socket (OS Error: Address already in use, errno = 48), address = 0.0.0.0, port = 3000, caused by a Decaid instance running on the dev machine holding port 3000. They fail identically on clean origin/main with none of this branch's changes, and are unrelated to shots.
  • New regression suite test/database/shot_history_imported_profile_test.dart (10 tests), against an in-memory Drift database (the update tests drive the real ShotsHandler):
    • a stored profile with no steps reads back (reproduces de1app-1626149813);
    • a stored profile with no title reads back;
    • one step-less shot does not hide the rest of the history (the actual issue: the page returns all three shots instead of throwing);
    • PUT /api/v1/shots/de1app-1626149813 with an annotation patch on a step-less shot returns 200, persists the note, and leaves the profile step-less (both update tests return 500 on the previous commit);
    • the same for a title-less imported shot, which reads back as Unknown profile;
    • a row that cannot be mapped at all is skipped, not fatal;
    • getShot still surfaces the failure for a single unreadable id;
    • Workflow.fromJson still rejects a step-less profile while Workflow.fromRecordedJson accepts it;
    • Profile.fromJson still rejects an empty steps array and an empty title.
  • test/webserver/workflow_handler_test.dart caught a first attempt that made Workflow.fromJson lenient everywhere: PUT /api/v1/workflow with an empty profile title returned 200 instead of 400, breaking issue [Bug]PUT /api/v1/workflow hangs forever when profile JSON fails enum parse #338 . Leniency is now reached only through Workflow.fromRecordedJson, that suite is green (47 tests), and the new suite pins the boundary.
  • No hardware testing; this is a storage read path exercised entirely by the Drift tests.

Impact

Note any user-visible behavior, compatibility, migration, API/spec, documentation, or security impact. Write None if there is none.

  • Users who imported a de1app folder get their shot history back. GET /api/v1/shots returns 200 instead of 500, and streamline.js renders the list again. This recovers existing databases with no migration — the offending rows become readable rather than needing repair or deletion.
  • A recorded profile with no title reads back as Unknown profile, and missing tank_temperature / target_volume_count_start read back as 0. These only apply to profiles embedded in stored shots.
  • No behavior change for the profile library or any write boundary: with requireExecutable: true every guard still fires before the new fallbacks, so Profile.fromJson is byte-for-byte the parser it was. PUT /api/v1/workflow still returns 400 for a step-less profile (issue [Bug]PUT /api/v1/workflow hangs forever when profile JSON fails enum parse #338 ), verified by the existing suite.
  • Backup export (main.dart:563) also routes through ShotMapper.fromRows, so an unreadable row no longer aborts an export, and ShotImporter can read such a backup back in.
  • Recovered de1app shots are editable: annotations, notes and metadata can be written through PUT /api/v1/shots/<id> instead of returning 500.
  • No API, spec, schema, or migration changes. No REST or WebSocket surface was touched, so assets/api/rest_v1.yml and assets/api/websocket_v1.yml are unchanged.
  • doc/Profiles.md documents the strict/lenient split and when each parser applies. doc/AI_STORAGE_NOTES.md records why de1app imports carry a step-less profile, why one bad row used to hide the whole history, the single-shot-read exception, and why every stored-shot JSON read path (row mapper, shot update, backup import) has to be lenient together.
  • The importers still store steps: []. That is the honest representation of a .shot file, and it now round-trips.

Contributor Responsibility

AI-assisted development is allowed. The submitter remains responsible for the submitted work.

  • I have reviewed and understand all changes in this PR and take responsibility for their correctness, security, behavior, licensing, and provenance, including any AI-assisted or AI-generated work.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RYDtpEj71im9nmUmWkhveZ

tadelv reacted with heart emoji
de1app .shot files record the shot, not the profile that produced it, so
TclShotParser and the no-profile branch of ShotV2JsonParser store a placeholder
profile with an empty steps array. Profile.fromJson refuses to read that back,
so importing a de1app folder made every later GET /api/v1/shots throw
ArgumentError and return 500, and streamline.js rendered no history at all.
Because DriftStorageService mapped rows with a bare rows.map(...), the single
unreadable row aborted the whole page, hiding a history of otherwise fine shots
behind one 2021 import.
Read a stored shot's workflow with Workflow.fromRecordedJson, which parses its
profile leniently. A recorded profile is history, not something to brew from.
Workflow.fromJson and Profile.fromJson stay strict for the profile library, the
profile and workflow REST handlers, the stored current workflow, and DE1
upload, so PUT /api/v1/workflow still rejects a step-less profile with 400
(issue #338).
Skip and log a row that cannot be mapped, so future corruption costs its own
shot instead of the entire list. Single-shot reads still surface the error,
because there the failing row is the answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYDtpEj71im9nmUmWkhveZ 
tadelv self-requested a review September 4, 2026 11:49

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One required fix: the new recorded-workflow read path makes imported step-less shots visible, but the existing shot update path still reparses those same records through the strict executable workflow parser. That leaves the recovered shots uneditable. Please preserve strictness at executable/write boundaries while making stored-shot updates round-trip recorded workflows, and add a regression test for updating an imported step-less shot.


static domain.ShotRecord fromRow(db.ShotRecord row) {
final workflow = domain_workflow.Workflow.fromJson(row.workflowJson);
final workflow = domain_workflow.Workflow.fromRecordedJson(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the DB read path, but PUT /api/v1/shots/<id> still cannot update one of these recovered de1app shots. ShotsHandler._updateShot starts from existingShot.toJson() and then calls ShotRecord.fromJson(merged); ShotRecord.fromJson still uses strict Workflow.fromJson, so a profile with steps: [] throws again even when the patch only changes annotations/metadata. Please make the stored-shot update round-trip use the recorded-workflow semantics without weakening the executable profile/workflow boundaries, and add a regression test that updates a step-less imported shot.

@allofmeng allofmeng Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a9156b1.

ShotRecord.fromRecordedJson now mirrors the existing Workflow / Profile split, and ShotsHandler._updateShot uses it for both the contentChanged comparison and the merged record it stores. ShotRecord.fromJson is unchanged and still strict everywhere else, so no executable profile or workflow boundary moved: PUT /api/v1/workflow still returns 400 for a step-less profile (issue #338), and test/webserver/workflow_handler_test.dart stays green.

ShotImporter had the same gap in the other direction: a backup export of a recovered de1app shot could be written but not read back, so its two ShotRecord.fromJson calls moved to fromRecordedJson as well.

Regression tests added to test/database/shot_history_imported_profile_test.dart, driving the real ShotsHandler over an in-memory Drift database:

  • PUT /api/v1/shots/de1app-1626149813 with an annotation patch on a steps: [] shot returns 200, persists the note, and leaves the profile step-less;
  • the same for a title-less imported shot, which reads back as Unknown profile.

Both fail with 500 on the previous commit and pass now. flutter analyze clean; flutter test 3866 passed with the same 8 pre-existing webui_token_injection_test.dart port-3000 failures reported earlier.

Reading a stored shot leniently was not enough. PUT /api/v1/shots/<id> merges
the patch into existingShot.toJson() and reparses the result, and ShotImporter
reads back shots from a backup export; both went through the strict
ShotRecord.fromJson, so a recovered de1app shot with steps: [] read back fine
but threw again on any annotation edit or re-import.
Add ShotRecord.fromRecordedJson, mirroring the existing Workflow and Profile
split, and use it on those two stored-shot read paths. ShotRecord.fromJson
stays strict everywhere else, so no executable profile or workflow boundary is
weakened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5WcW9yBsD9DMBy838wsGQ 

@tadelv tadelv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two required fixes remain in the backup path. The recorded-shot parser split is sound and the previous PUT /api/v1/shots/<id> blocker is fixed, but the current backup restore path still reparses shots through the strict parser, and the new skip-on-map export callback can truncate a paged backup. Please address both and add regression coverage for backup export/import.

);
}
final shot = ShotRecord.fromJson(item);
final shot = ShotRecord.fromRecordedJson(item);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes ShotImporter, but that is not the importer used by the current backup archive path. ShotExportSection.importJson still does ShotRecord.fromJson(...), so a shots.json backup containing one of these recovered steps: [] records can be exported successfully and then rejected on restore. Please switch that stored-shot backup import path to recorded parsing as well and add a backup round-trip regression for a step-less de1app shot.

Comment thread lib/main.dart
cursorId: afterId,
);
return rows.map(ShotMapper.fromRow).toList();
return ShotMapper.fromRows(rows);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShotExportSection.exportJson treats page.length < pageSize as EOF. getShotsForExport returns a raw page of up to pageSize rows, but ShotMapper.fromRows can drop an unreadable row, making this callback return fewer items; the exporter then stops and silently omits every older shot. Since the known de1app rows are now readable via fromRow, please either keep export mapping non-filtering or preserve the raw-page cursor/size semantics when skipping rows. Add coverage so one unreadable row cannot truncate the remainder of a backup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@tadelv tadelv tadelv requested changes

Requested changes must be addressed to merge this pull request.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

[bug] shot history can not be rendered in streamline.js

2 participants

AltStyle によって変換されたページ (->オリジナル) /