Skip to content

Add support for BlueOS Cloud missions - #2865

Open
rafaellehmkuhl wants to merge 5 commits into
bluerobotics:masterfrom
rafaellehmkuhl:add-support-for-blueos-cloud-missions
Open

Add support for BlueOS Cloud missions#2865
rafaellehmkuhl wants to merge 5 commits into
bluerobotics:masterfrom
rafaellehmkuhl:add-support-for-blueos-cloud-missions

Conversation

@rafaellehmkuhl

@rafaellehmkuhl rafaellehmkuhl commented Jul 24, 2026

Copy link
Copy Markdown
Member

What's new

BlueOS Cloud missions, managed from the Mission Identifier dialog when you're in pirate mode and signed in.

You can:

  • Create a mission (name, optional description, optional start location on a map)
  • Select an existing cloud mission to keep logging into
  • Edit the linked mission's name, description, and location
  • Reset to unlink the cloud mission and start a fresh local mission cycle

Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back. If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s.

On Cockpit startup (pirate + signed in), a dialog asks how you want to work with cloud missions for this session: continue the previous mission, select an existing one, create a new one, or continue without a mission. It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes.

What changed (local mission dialog)

When BlueOS Cloud missions aren't active, the dialog stays local-only but a few UX bits moved:

  • The old restore-last-name control is gone; there's a Reset current mission action instead (new automatic name + new mission cycle)
  • Top-right close control on the dialog
  • Mission Identifier widget: edit icon only on hover, no background hover tint

Mission flows

Not in pirate mode

Same local mission name flow as before (with the reset/hover tweaks above). No BlueOS Cloud mission UI — cloud missions stay behind pirate mode like the Cloud settings menu.

Pirate mode, not signed into BlueOS Cloud

Still the local name flow (edit name, generate name, reset, save). A short note points you to Cloud settings to log in if you want cloud missions.

Pirate mode, signed into BlueOS Cloud

Cloud missions take over the dialog:

  1. No mission linked this cycle — choose Continue previous mission, Select existing mission or Create a new mission. Close dismisses the dialog.
  2. Mission linked this cycle — read-only name / description / location / sync status (with a link to open it on BlueOS Cloud when synced). Actions: Edit mission, Reset mission, or Close.

Linking is tied to the current mission cycle (the same ~6h idle / new-day cycle as the automatic mission name). When the cycle renews, the cloud association clears and you're back to select/create.

The startup dialog and the mission configuration dialog share the same cycle state and linking actions through a single composable, so both entry points behave identically.

Closes #2711.

@rafaellehmkuhl
rafaellehmkuhl marked this pull request as ready for review July 24, 2026 21:43
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@rafaellehmkuhl rafaellehmkuhl changed the title feat: Add BlueOS Cloud mission creation and linking Add support for BlueOS Cloud missions Jul 24, 2026
@github-actions

Copy link
Copy Markdown

Automated PR Re-review 1 (Claude)

This is a full review — no previous review exists for this PR.

0. Summary

Verdict: MINOR SUGGESTIONS

Critical items to address: 2.1, 6.1

This PR adds BlueOS Cloud mission support behind pirate mode: users can create, select, edit, and reset cloud missions from the Mission Identifier dialog, with offline-first queuing that replays mutations once connectivity returns. It introduces a clean separation of API (src/libs/blueos-cloud/api.ts), sync-queue logic (mission-sync-queue.ts), reminder logic (mission-reminder.ts), a startup composable, three new Vue components (location picker, mission form, mission picker), and wires everything through the existing blueOsCloudStore. The architecture is well-structured with good domain-logic extraction into framework-agnostic .ts modules.


1. Correctness & Implementation Bugs

1.1 (major) — retryTimer in blueOsCloud.ts is never cleaned up. The scheduleQueueRetry function creates a setTimeout that is never cleared when the store is torn down. More importantly, window.addEventListener('online', ...) at the store module level (line ~340 in the post-merge file) registers a global event listener that is never removed. Since this is a Pinia store (singleton, never unmounted), the online listener is acceptable, but the retryTimer should still be cleared in clearSession to avoid a stale flush after logout.

1.2 (minor) — flushMissionSyncQueue iterates a mutating collection. At blueOsCloud.ts, pendingMissions(missionSyncQueue.value) returns Object.values(...) which is a snapshot, so mid-loop mutations to missionSyncQueue.value are safe. However, the return on failed sync (line ~268 in the diff) aborts the entire flush after a single failure — this means entries queued after the failing one are never attempted in this pass. Acceptable for now given the retry, but worth documenting with a ponytail: comment.

1.3 (minor) — BlueOsCloudLocationPicker.vue reads vehicleStore.coordinates directly from useMainVehicleStore. Per the data-lake-first guideline, widgets/mini-widgets should use useDataLakeVariable. However, this component is not itself a widget — it's a dialog-internal helper using the coordinates only to offer a "center on vehicle" shortcut — so this is borderline acceptable. Noting for awareness only.

1.4 (minor) — baseLayer in BlueOsCloudLocationPicker.vue is assigned but never cleaned up in onBeforeUnmount. The map?.remove() call will detach the tile layer, but explicitly setting baseLayer = null (like centerMarker) would be consistent.


2. AGENTS.md Adherence

2.1 (major) — Empty JSDoc summaries in BlueOsCloudMissionForm.vue. The emit type at lines ~92–103 of the new file has three /** */ blocks with empty bodies (the name, description, and location fields of the submit payload). AGENTS.md and the JSDoc rules require that JSDoc blocks must never have an empty or whitespace-only summary — either write a real description or omit the block entirely.

2.2 (minor) — Raw fetch used instead of ky (already installed). src/libs/blueos-cloud/api.ts uses raw fetch for all API calls, while ky is already a dependency used elsewhere in the codebase (src/libs/blueos.ts, src/libs/blueos-files.ts). Per AGENTS.md rule "Use existing dependencies when possible", ky would reduce boilerplate (status checks, JSON parsing, error handling). This is a stylistic preference — fetch works — but flagging for consistency.

2.3 (nit) — linkExistingMission and finishMission have @returns {void} in their JSDoc. Per AGENTS.md "Always create docs for the @returns, unless the function has no specified return value" — when the return type is void, the @returns tag is unnecessary noise.


3. Security — ✅

4. Performance

4.1 (minor) — fetchAllPages replaces http:// with https:// on pagination URLs. At api.ts line ~47: nextUrl.replace(/^http:\/\//, 'https://'). If the BlueOS Cloud API ever returns http pagination URLs, silently upgrading is reasonable, but this suggests the API might already return correct https URLs. If so, the replacement is dead code. If not, this is a mild MITM concern (the first request is already to https). Low risk but worth a comment explaining why it's needed.


5. UI / UX

5.1 (nit) — 60-second "Remember to create your mission" snackbar on every startup. A 60-second snackbar is quite long. Users who intentionally skip mission creation will see this every session. Consider making it dismissable (it already has closeButton: true, so this is fine) or shortening the default duration.


6. Code Quality & Style

6.1 (major) — Empty JSDoc blocks on MissionSnapshot type in existing mission.ts. The base file (src/stores/mission.ts, lines 213–221) already has empty JSDoc blocks (/** * */) on the MissionSnapshot type's waypoints and surveys fields. This is pre-existing and not introduced by this PR, so no action required from this PR — just noting for awareness.

6.2 (minor) — formatMissionMeta in BlueOsCloudMissionPicker.vue uses parseFloat on already-typed string | null fields. The start_latitude and start_longitude are string | null per the type — the parseFloat is correct for display formatting but could produce NaN silently if the API returns a non-numeric string. Consider guarding with Number.isFinite.

6.3 (nit) — buildBaseLayer in BlueOsCloudLocationPicker.vue duplicates tile-URL logic. The Map.vue widget and MissionPlanningView.vue likely have similar tile-provider URL maps. Per the shared-logic guideline, if this tile-URL mapping exists elsewhere, it should be reused. However, this is a small self-contained helper in a dialog-internal component, so the duplication is minor.


7. Commit Hygiene

Commits are well-structured with logical separation:

  1. feat: Add BlueOS Cloud mission API, sync queue, and store linking — backend/store
  2. feat: Replace restore-last-name with reset in mission config — local UX change
  3. feat: Add BlueOS Cloud mission UI and wire it into MissionIdentifier — UI
  4. feat: Remind about BlueOS Cloud mission on Cockpit startup — startup reminder
  5. feat: Show mission edit icon only on hover — polish

All commits are prefixed feat: which is debatable for commit 5 (a UI tweak could be style: or refactor:), but this is a minor nit given they're all part of the same feature PR.

--- ✅

8. Tests — ✅

9. Documentation — ✅

10. Nitpicks / Optional

10.1 (nit) — In BlueOsCloudMissionForm.vue, the generateName function logs 'Generated a mission name in the creation form' regardless of whether the form is in create or edit mode.

10.2 (nit) — The missionFormInitialName, missionFormInitialDescription, missionFormInitialLocation refs in MissionIdentifier.vue could be consolidated into a single reactive object to reduce the number of individual refs.

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from d16e23a to a969479 Compare July 24, 2026 21:53
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 1

Done

  • src/stores/blueOsCloud.ts (1.1 — retryTimer after logout): clear the retry timeout in clearSession so a flush can't fire after sign-out.
  • src/stores/blueOsCloud.ts (1.2 — flush aborts on first failure): left the behavior, added a ponytail: note that later queue entries wait for the scheduled retry.
  • src/components/blueos-cloud/BlueOsCloudLocationPicker.vue (1.4 — baseLayer cleanup): null out baseLayer in onBeforeUnmount.
  • src/components/blueos-cloud/BlueOsCloudMissionForm.vue (2.1 — empty JSDocs): replaced the empty emit-payload docs with a named MissionFormSubmitPayload type that has real summaries.
  • src/stores/blueOsCloud.ts (2.3 — @returns {void}): dropped the unnecessary @returns tags on linkExistingMission / finishMission / updateLinkedMission.
  • src/components/blueos-cloud/BlueOsCloudMissionPicker.vue (6.2 — NaN coords): guard lat/lng with Number.isFinite before formatting.
  • src/components/blueos-cloud/BlueOsCloudMissionForm.vue (10.1 — generate-name log): log message now says create vs edit mode.

Won't change (with reasoning)

  • 1.3 — LocationPicker uses vehicle store: dialog helper for a one-off “center on vehicle” shortcut, not a telemetry widget; data-lake-first doesn’t really apply here.
  • 2.2 — raw fetch vs ky: fetch is fine for these few calls; swapping the whole BlueOS Cloud client to ky is scope creep for this PR.
  • 4.1 — http→https rewrite on pagination: low risk; keeping the upgrade in case the API returns http next links behind a proxy.
  • 5.1 — 60s create-mission snackbar: intentional; it’s already dismissible via the close button.
  • 6.1 — empty JSDocs in mission.ts: pre-existing, not introduced here.
  • 6.3 — tile URL helper duplication: small dialog-local helper; not worth extracting in this PR.
  • 10.2 — consolidate mission form refs: speculative refactor, skipping.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Re-review 2 (Claude)

Comparing d16e23ae69f36a1204663f8da21678c2ad9bfc19a96947980e37605f2e7e3fb0bb625dad5f340522

The commits were force-pushed/rebased (all commits share the same committedDate), so the incremental diff covers the full PR. Re-checked all previous findings against the current pr.diff.

Discussion since last review

The author posted a detailed follow-up (@rafaellehmkuhl) explaining which findings were addressed and which were intentionally declined. The addressed items are confirmed below. The declined items (1.3, 2.2, 4.1, 5.1, 6.1, 6.3, 10.2) have reasonable justifications — no objection.

Previous findings status

# Finding Severity Status
1.1 retryTimer not cleared in clearSession major ✅ Addressed — clearSession now clears the timer
1.2 flushMissionSyncQueue aborts on first failure without ponytail: comment minor ✅ Addressed — ponytail: comment added
1.3 BlueOsCloudLocationPicker reads vehicle store directly minor ⚪ No longer applicable — author declined with valid reasoning (dialog helper, not a widget)
1.4 baseLayer not nulled in onBeforeUnmount minor ✅ Addressed — baseLayer = null added before map?.remove()
2.1 Empty JSDoc summaries in BlueOsCloudMissionForm.vue emit payload major ✅ Addressed — replaced with named MissionFormSubmitPayload type with real summaries
2.2 Raw fetch used instead of ky minor ⚪ No longer applicable — author declined; fetch works fine for these calls
2.3 Unnecessary @returns {void} tags nit ✅ Addressed — @returns tags removed from void functions
4.1 http://https:// replacement on pagination URLs minor ⚪ No longer applicable — author declined; kept as a safety measure
5.1 60-second startup snackbar nit ⚪ No longer applicable — author declined; intentional, already dismissible
6.1 Empty JSDoc blocks on MissionSnapshot (pre-existing) major ⚪ No longer applicable — pre-existing, not introduced by this PR
6.2 parseFloat without NaN guard in formatMissionMeta minor ✅ Addressed — Number.isFinite guard added
6.3 buildBaseLayer tile-URL duplication nit ⚪ No longer applicable — author declined; small self-contained helper
10.1 generateName log message ignores form mode nit ✅ Addressed — message now interpolates ${props.mode}
10.2 Consolidate mission form refs into one object nit ⚪ No longer applicable — author declined

New findings

0. Summary

Verdict: READY TO MERGE

This PR adds BlueOS Cloud mission support behind pirate mode with offline-first queuing, clean separation of API, sync-queue, and reminder logic into framework-agnostic .ts modules, and well-structured Vue components. All findings from the first review have been addressed or intentionally declined with valid reasoning. No new issues found on re-review.

1. Correctness & Implementation Bugs — ✅

2. AGENTS.md Adherence — ✅

3. Security — ✅

4. Performance — ✅

5. UI / UX — ✅

6. Code Quality & Style — ✅

7. Commit Hygiene — ✅

8. Tests — ✅

9. Documentation — ✅

10. Nitpicks / Optional — ✅

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from 7adc274 to c3dd9ac Compare August 4, 2026 12:14

@ArturoManzoli ArturoManzoli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Its syncing correctly with the cloud.

Still reviewing the code, but some UI and UX changes are needed:

---- Screen: Select BlueOS Cloud mission -----

  • Some way to sort missions on the table is important;
  • Some way to filter by user, date, place (around 'this coordinates' or map pin);
  • Add a search bar to filter by mission name or description;
  • Fix the layout on the Header, Footer, margins (I'll provide a skill/ruleset to temporarily fix those UI alignments and diagramation);
Image

----- Screen: Mission configuration 1 -----

  • Fix header (2) title aliment and X close button size;
  • This text and icon should be clickable, as a text-button and should open the Main menu -> Settings -> Cloud page (We already have the mechanics for that on a store);
  • When resetting the mission on any screen, ask for confirmation.
Image

----- Screen: Mission configuration 2 -----
The ideal is to prevent more than two actions on the dialog's footer. To do so,

(1) Move edit mission to the red rectangle;
(2) Move reset mission to the blue one;
(3) Move the mission status box to the yellow place, so the dialog can be shorter in height;
(4) Move the cancel button where the reset mission used to be (whenever there is only one button on the footer, place it on the right);

Image

@ES-Alexander ES-Alexander added the docs-needed Change needs to be documented label Aug 17, 2026
@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from c3dd9ac to 4a75dd0 Compare August 19, 2026 20:18
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 2

Done

Screen: Select BlueOS Cloud mission (src/components/blueos-cloud/BlueOsCloudMissionPicker.vue)

  • Sorting: order selector with Newest first / Oldest first / Name (A-Z), newest first by default. The filter/sort logic is in src/libs/blueos-cloud/mission-list.ts (out of the component, so it's testable) with unit tests in src/tests/libs/blueos-cloud/mission-list.test.ts.
  • Search bar: one field matching name, description, start date and coordinates as they are displayed on the row, so reef, 8/4/2026 and -48.5482 all narrow the list. It replaces the old MISSIONS caption row, so it costs no extra height. That also covers "filter by date / place" — see the question below about filtering by user.
  • Header / footer / margins: title centered, X top-right (equal 16px top and right inset, vertically centered on the title), divider under the header removed, content on px-8 instead of the doubled pa-4 + px-2, Cancel alone on the right of the footer, list cap max-h-[40vh] instead of 260px, empty state is a centered icon plus one line (and says "no missions match your search" when the query is what emptied it).

Screen: Mission configuration 1 (src/components/mini-widgets/MissionIdentifier.vue)

  • Header: X now at top-4 right-4 with a 22px icon, so its top and right margins match and it sits vertically centered on the title (32px button in a 64px header).
  • The cloud line is now a text button: it closes the dialog and opens Main menu → Settings → Cloud through interfaceStore, same mechanic as MissionPlanningView.handleOpenMissionSettings.
  • Reset asks for confirmation on both screens: local Reset current mission and cloud Reset mission both go through one confirmMissionReset helper built on useInteractionDialog.

Screen: Mission configuration 2 (src/components/mini-widgets/MissionIdentifier.vue)

  • (1) Edit mission moved beside the Name field, (2) Reset mission beside Description, (3) the sync-status box beside Location — the dialog is ~90px shorter, (4) Cancel is the only footer action and sits on the right. The Description row now always renders (Not set when empty), otherwise Reset mission would vanish for missions without a description. The cloud "no mission linked yet" footer got the same treatment: Close on the right.

Create / edit mission dialog (src/components/blueos-cloud/BlueOsCloudMissionForm.vue)

  • Same header / footer / margins pass, since it has the same hand-rolled shell. Primary action is now #FFFFFF33 with white text instead of a second text button, and dims to 40% when disabled.

Won't change (with reasoning)

  • Generate name in the create/edit form still sits in its own row under the name field. forms.md wants a field-attached action inside the input's row, but that's not in this review and it's the same shape as Reset current mission / Generate new name on the local dialog, so I'd rather change both at once, later.

Questions for reviewers

  • Filter by user: the missions endpoint returns created_by as a bare numeric id and our own identity is the Auth0 sub, so there's nothing to compare it against locally — "missions created by me" needs a users lookup on the API side. Do you want me to open an issue for it, or is the search field enough for now?
  • Disabled primary: with no dialog shell on this branch, #FFFFFF33 + white text + 40% disabled are hand-written classes on each footer button. Fine as-is, or should they wait for the GlassModal variant="dialog" shell so the shell paints them?

Branch still conflicts with master; the rebase is coming in its own pass, this round is only the UI/UX feedback.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 3

Caution

⛔ DO NOT MERGE
20 findings open — 2 critical (1.5, 1.6), 2 major, 12 minor and 4 nits — and 10 closed since round 1.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Mission creation, selection and editing happen in the mission-name widget's dialog and, on startup, in a dialog that asks what to do this session. Because Cockpit is normally used with no internet, mission creates and edits are not sent directly: they are written into a queue in the browser's local storage and replayed later, when a window "online" event, another mission edit, or a 30-second retry timer fires. A cloud mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name — so a new cycle asks the question again. The same round also replaces the local dialog's restore-last-name control with a Reset action and hides the widget's edit pencil until hover.

Two of this round's findings are about that queue, and they are why the verdict moved down rather than up: the replay path deletes the user's queued mission on the failure it is most likely to hit, which is being offline.

What still needs attention

# Problem What it means Severity Status
1.5 Offline retry budget deletes the queued mission A mission created without internet is silently thrown away after about two and a half minutes offline, while the app keeps saying it will be uploaded. critical
1.6 Logging out, or a failed token renewal, wipes unsent work If the sign-in token needs renewing while there is no internet, the user is logged out and every mission still waiting to be uploaded is erased. critical
1.7 Latitude and longitude cannot be typed Typing coordinates into the mission location fields rewrites what the user is typing after every keystroke, so the number they wanted is unreachable. major
1.8 Location map is blank with no internet The map used to pick where a mission starts shows nothing offline, which is the situation the whole feature was built for. major
1.3 Vehicle position read from the vehicle store The location picker takes the vehicle's position from a different place than the rest of the app, so it can disagree with what other displays show. minor 💬
1.9 "Vehicle" button never appears when the fix arrives late If the vehicle gets its GPS position after the dialog is open, the button that centres the map on the vehicle stays hidden until the dialog is reopened. minor
1.10 An untitled cloud mission blanks the local mission name Selecting a cloud mission that has no title leaves the session with an empty mission name, and recordings then fall back to a generic label. minor
1.11 Authorization header sent without a scheme Mission requests send the token differently from every other cloud request in the app, so they may all be rejected. minor
1.12 Nothing retries the queue after signing back in Work queued before a logout sits unsent until something unrelated happens to nudge the queue. minor
3.1 A counter is computed and exported but never used Dead code ships with the feature, which the project's rules ask to land with its first real use instead. minor
3.2 Removed control leaves its saved value behind The app keeps writing a stored value that nothing reads any more, and a comment still describes the button that was deleted. minor
4.1 Pagination URLs rewritten to https If the server ever answers over plain http, the app quietly patches it instead of surfacing a misconfiguration. minor 💬
5.2 The whole mission list is downloaded twice per open Opening the mission picker costs twice the data and time it needs to, which is slow on a field connection. minor
6.4 No way back if the picker is cancelled Cancelling the mission picker or form at startup leaves the session with no mission and no way to get the question back. minor
6.5 Dialog anatomy and labelling gaps The startup dialog cannot be closed with a close button, and the new close buttons say nothing to a screen reader. minor
8.1 Two commits too large to review as a unit A reviewer cannot check the change step by step, which is where mistakes survive. minor
5.3 Connectivity listener is never removed Harmless in the running app, but it makes the store awkward to re-create in tests and during hot reload. nit
6.3 Tile URLs duplicated in the picker The same map addresses now exist in two files, so a future change has to find both. nit 💬
10.2 Four form refs that could be one object Slightly more state to keep in step by hand than the form needs. nit 💬
11.1 Dismiss button says "Cancel" on a read-only view The button label suggests changes are being discarded when there is nothing to discard. nit

🙋 Decisions for a human

1.3 — The location picker reads the vehicle position from useMainVehicleStore instead of the data lake
Author's argument: the picker is a dialog helper rather than a widget, so the data-lake-first rule for widget telemetry does not apply to it.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

4.1 — Pagination URLs are rewritten from http to https before being followed
Author's argument: it is a cheap safety measure against a server that hands back a plain-http next-page link.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

6.3 — buildBaseLayer repeats the OpenStreetMap and Esri tile URLs
Author's argument: it is a small self-contained helper, so the duplication is not worth removing.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

10.2 — The four missionForm* refs in MissionIdentifier.vue could be one object
Author's argument: separate refs are clearer here than a single grouped object.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

Ticking a box records the decision where the next reader can see it, but the finding itself only closes on /resolve <id> <reason>.

Since round 2 — 10 closed, 4 reopened as disputed, 16 new, comparing a9694794a75dd0

How this round was derived. Three inputs were degraded, and each changed what could be relied on:

  • The carried ledger arrived empty, because the round-2 review predates the machine-readable ledger. It was rebuilt from that review's prose table, so all fourteen carried findings are recorded as raised at round 1's head (d16e23a).
  • incremental.diff is unreliable: its merge base is this PR's first commit (3782931), not a969479, so it presents already-reviewed files such as BlueOsCloudLocationPicker.vue as newly added. A force-push or rebase between rounds is the shape of that. Every status transition below was therefore judged against pr.diff rather than the increment.
  • complexity-report.json was not available for this head. No claim is made about why. No complexity findings are raised this round, and section 7 says so.

No /resolve command has been issued on this PR, so nothing was closed by a maintainer.

Four round-2 closures are reopened. Round 2 closed 1.3, 4.1, 6.3 and 10.2 as "No longer applicable" on the strength of the author's reasoning, with the code unchanged. Under the guidelines an author's explanation, however sound, closes nothing: it makes a finding disputed, which stays open and keeps counting toward the verdict until the code changes or a maintainer resolves it. All four are open again, with their arguments carried into the Decisions block above. Nothing about that says the arguments are wrong — only that accepting one is a maintainer's call.

Closed this round (10).

# Round-2 status Now Evidence
1.1 Addressed ✅ Addressed clearSession in src/stores/blueOsCloud.ts clears and nulls retryTimer before touching any state.
1.2 Addressed ✅ Addressed The ponytail: comment in flushMissionSyncQueue names the ceiling (abort on first failure) and the upgrade (keep walking and collect failures).
1.4 Addressed ✅ Addressed onBeforeUnmount in BlueOsCloudLocationPicker.vue:239-244 nulls centerMarker and baseLayer before map?.remove().
2.1 Addressed ✅ Addressed The emit payload is now the named MissionFormSubmitPayload in src/composables/blueos-cloud/useBlueOsCloudMission.ts:14-28, every member documented.
2.3 Addressed ✅ Addressed No @returns {void} remains anywhere in the diff.
6.2 Addressed ✅ Addressed locationTextOf in src/libs/blueos-cloud/mission-list.ts:13-20 guards both values with Number.isFinite, and a test covers the rejected case.
10.1 Addressed ✅ Addressed generateName in BlueOsCloudMissionForm.vue:121 interpolates the form mode.
2.2 Declined ⚪ No longer applicable Retracted — this finding was wrong, not fixed. src/libs/blueos-cloud/auth.ts already calls raw fetch at lines 41, 110, 162 and 196, so api.ts follows the in-tree pattern for this module. Asking for ky here was asking the PR to be inconsistent with the file it sits beside.
5.1 Declined ⚪ No longer applicable The premise is gone in code: the 60-second startup snackbar and the reminder composable that raised it no longer exist in the PR. The startup decision dialog replaced them.
6.1 Declined ⚪ No longer applicable The MissionSnapshot JSDoc blocks are not in pr.diff at all; the finding was against pre-existing code and was retracted in round 2 for that reason.

Discussion since the last review. @rafaellehmkuhl posted a round-2 follow-up (comment 5347527327) describing a UI/UX pass. The claims that could be checked against pr.diff hold: the picker's title is centered with the close X at top-4 right-4, the divider under the header is gone, content sits on px-8, the footer carries Cancel alone on the right, and the list cap is max-h-[40vh]; confirmMissionReset in MissionIdentifier.vue routes both Reset actions through useInteractionDialog; the form's committing button carries bg-[#FFFFFF33] with white text. Three things follow from that comment rather than from the diff:

  • The declared "won't change" — Generate name sitting in its own row under the name field — does breach the field-attached-action rule, and the stated reason (it mirrors the local dialog's own row, so both should move together) is a reasonable sequencing call. It is not raised as a finding this round.
  • The question about filtering by user checks out: BlueOsCloudMission.created_by is typed number | null in src/libs/blueos-cloud/types.ts, and the local identity is the Auth0 sub string, so there is genuinely nothing to compare locally. That is a maintainer's call on scope, not a review finding.
  • The question about the disabled primary awaiting a shared dialog shell is also a maintainer's call. Hand-written bg-[#FFFFFF33] plus disabled:opacity-40 is what the house direction asks for today, so nothing here blocks on the shell.
  • The comment also notes the branch still conflicts with master. That is outside what this review can see or judge.

Nothing in the PR body, the diff or the comments contained instructions addressed to this reviewer.

Change map — what was established before judging

Claims (from the PR body, each checked against the code):

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back."contradicted. The queue is written and replayed as described, but two paths destroy entries before they can be pushed: the five-strike attempt counter in registerFailedAttempt counts transient offline failures (finding 1.5), and clearSession empties the whole queue, including when it is called automatically after a failed token refresh (finding 1.6).
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s."verified. flushMissionSyncQueue in src/stores/blueOsCloud.ts tests opError instanceof BlueOsCloudApiError && opError.status === 404 for an entry that already has a cloudId, re-creates it, and re-points linkedMissionId at the new id.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes."verified. openIfEligible in src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts:101-107 returns early on hasMissionThisCycle.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu."verified. isCloudActive is interfaceStore.pirateMode && cloudStore.isAuthenticated (useBlueOsCloudMission.ts:85), and MissionIdentifier.vue branches the whole dialog on it.
  • "Optional start location on a map."present but broken in two ways: the coordinate fields cannot be typed into (1.7) and the map renders nothing without internet (1.8).
  • "Both entry points behave identically through a single composable."partly verified. The state and the four actions are genuinely shared, but the two paths diverge: the startup path closes the decision dialog before opening the picker and leaves no way back (6.4), and selectExistingMission omits the empty-title guard that its sibling continuePreviousMission performs (1.10).

No bug-fix claim is made, so there is no failure site to locate.

Entry points

Function Reached from Frequency
flushMissionSyncQueue (store) store body on first useBlueOsCloudStore(); window "online"; startCloudMission; updateLinkedMission; scheduleQueueRetry one-shot, per user action, and every 30 s while the queue keeps failing
scheduleQueueRetry every failure path of flushMissionSyncQueue per failed flush
registerFailedAttempt flushMissionSyncQueue catch block per failed operation per pass
refreshMissions picker watch(modelValue, {immediate:true}); picker refresh button; ensurePreviousMissionLoaded; loadMissionsForPicker twice per picker open, once per refresh click
startCloudMission createMission ← form submit per user action
updateLinkedMission editLinkedMission ← form submit in edit mode per user action
linkExistingMission selectExistingMission, continuePreviousMission per user action
finishMission finishCloudMission ← Reset mission per user action
clearMissionCycleLink skipMission ← "Continue without a mission" per user action
clearSession Cloud settings logout, and ensureValidAccessToken on any refresh failure per user action, plus automatically on a failed refresh
openIfEligible onMounted of BlueOsCloudMissionStartupHost (mounted unconditionally in App.vue), watch(isCloudActive) one-shot per launch, once more per login
setCoordinates (location picker) lat/lng @update:model-value, map moveend, Vehicle/Clear buttons, props.modelValue watch per keystroke and per map pan
updateInputs setCoordinates only per keystroke and per map pan
buildBaseLayer onMounted of the location picker per create/edit form open
filterAndSortMissions picker visibleMissions computed per keystroke in the search field
formatMissionMeta picker row template per visible row per render
findPending, enqueueCreate, enqueueUpdate, removePending, pendingMissions store mutations and the flush loop per user action and per flush pass
pendingSyncCount (store) nothing — defined and exported, no reader in pr.diff never (finding 3.1)
authHeaders, authJsonHeaders, fetchAllPages fetchMissions, createMission, updateMission per flush and per picker open

Invariants

  • A queued mission survives until it is accepted by the server. This is what the offline promise rests on. Sites that can violate it: registerFailedAttempt (drops an entry at five attempts, whatever the cause — not covered, finding 1.5); clearSession (empties the queue, and runs automatically on a failed refresh — not covered, finding 1.6); removePending (only after a confirmed 2xx — covered). Nothing warns the user in either uncovered case.
  • linkedMissionId always resolves to something showable. linkedMission falls back from the fetched list to the queue, which covers the offline-created case. It breaks the moment 1.5 or 1.6 drops the queue entry while linkedMissionId still points at the client id: the dialog then shows "Untitled mission" with "Not set" fields and "Saved locally · will upload when online" forever.
  • A cloud link belongs to exactly one mission cycle. The cycle id is new Date(missionStore.missionStartTime).getTime(). Writers of missionStartTime: applyMissionName with startNewMission: true, the launch-time renewal at src/stores/mission.ts:223-229, and clearMission at src/stores/mission.ts:380-384. The PR's own call order is correct — createMission applies the name first and reads currentCycleId after, so the new mission lands on the new cycle — and the launch renewal runs at store instantiation, before any cycle id is read. clearMission (Mission Planning's clear action) is not covered: it silently ends the cycle mid-session, so the mission dialog flips back to the select/create options while linkedMissionId still holds the mission. Consequence is small today because nothing else consumes the link yet, so it is recorded here rather than raised.
1. Correctness & Implementation Bugs — 9 findings (2 critical, 2 major)

1.5 — The offline retry budget deletes the user's queued mission (critical)

src/libs/blueos-cloud/mission-sync-queue.ts:167-178 drops the queue entry once attempts reaches the maximum:

const attempts = existing.attempts + 1
if (attempts >= maxAttempts) return removePending(queue, clientId)

MAX_MISSION_SYNC_ATTEMPTS is 5 and MISSION_SYNC_RETRY_MS is 30 s (both in src/stores/blueOsCloud.ts). Trace the offline path with the access token still valid, which is the ordinary case: flushMissionSyncQueue gets its token without a network call, createMission calls fetch, fetch rejects because there is no connection, the catch is not a 404, so registerFailedAttempt counts the attempt and scheduleQueueRetry books the next pass 30 s later. Five passes — two and a half minutes with no internet — and the entry is deleted. linkedMissionId still holds the client id, so the dialog keeps showing "Saved locally · will upload when online" for a mission whose name, description and location no longer exist anywhere. The user was told "Mission ... saved. It will sync to BlueOS Cloud when online."

The comment above the constant states the intent — "so a permanently rejected op can't wedge the queue" — and that intent is right; the implementation cannot tell a rejection from an absence of network. Fix: only count an attempt against the budget when the server actually answered and rejected the operation (a BlueOsCloudApiError with a 4xx other than 408/429), and treat a thrown fetch — no status at all — as "not attempted", leaving the entry alone. If a hard cap is still wanted for the rejected case, it needs to end in something the user sees rather than a silent delete.

1.6 — Logging out, or a token refresh that fails while offline, erases every unsent mission (critical)

clearSession in src/stores/blueOsCloud.ts now also does:

missions.value = []
linkedMissionId.value = null
linkedMissionCycleId.value = null
missionSyncQueue.value = {}

That last line destroys persisted user data, and clearSession is not only the logout button. ensureValidAccessToken (src/stores/blueOsCloud.ts:55-82 on master, unchanged by this PR) calls it from the .catch of the refresh exchange, and refreshAccessToken uses fetch, which rejects on any network failure. So: work offline long enough for the access token to pass its expiry, let the 30-second retry timer fire one flush, and the user is logged out and their queued missions are gone — from a transient absence of internet, with no prompt and nothing to undo. The base clearSession only cleared tokens and the cached profile, so this is the PR's own regression.

Two separate fixes: distinguish "the refresh could not be attempted" from "the refresh was refused" in ensureValidAccessToken and keep the session on the former; and leave missionSyncQueue out of clearSession entirely, since the queue is keyed by client id and can be replayed by whoever signs in next — or, if it must be dropped on an explicit logout, drop it there and only after telling the user what is unsent.

1.7 — Latitude and longitude cannot be typed into the location fields (major)

In src/components/blueos-cloud/BlueOsCloudLocationPicker.vue, the two fields are one-way bound (:model-value="latitudeInput", line 31) and every keystroke runs onLatitudeInput (line 138), which ends in setCoordinates([lat, lng], { panMap: true }). setCoordinates (line 121) calls updateInputs and then map.setView(coords, zoom, { animate: false }). With animation off, Leaflet fires moveend synchronously inside setView, and the moveend handler registered at line 222 calls setCoordinates again from the map centre — so updateInputs writes formatCoord(...), six fixed decimals, back into the field the user is typing in, on every keystroke. Typing 19 gives 1.000000 after the first character and something like 1.0000009 after the second. The field is unusable for anything but the first digit; the map itself still works, so the bug is invisible in a demo that only drags the map.

The knot is that one function serves both directions of a two-way relationship. Split them: let the input handlers update coordinates and pan the map without going through updateInputs, and let updateInputs run only when the change originated from the map or from props.modelValue. A isApplyingUserInput guard around the moveend handler would also close it, but the split is the cleaner shape given setCoordinates already takes an options object.

1.8 — The location map is blank offline, and re-implements tile layers the tree already owns (major)

buildBaseLayer (BlueOsCloudLocationPicker.vue:99-106) builds its layers with plain L.tileLayer. src/composables/map/useMapTileLayers.ts exists for exactly this — its own JSDoc says the tile-provider definitions should "live in one place" — and what it returns is not the same object:

  • Both of its base maps are built with tileLayerOffline from leaflet.offline, so they serve tiles the user has already cached. L.tileLayer does not, so a mission created in the field shows an empty grey square where the location picker should be. That is the one scenario this PR is built for.
  • Its OSM layer carries referrerPolicy: 'strict-origin-when-cross-origin' with a comment recording why: without a Referer, OSM answers 403. In Standalone src/electron/services/osm-referer.ts injects the header at the network layer, so the picker survives there; in Lite it depends on the browser's default policy rather than on anything the code states.
  • It also carries crossOrigin: 'anonymous', maxNativeZoom, the shared tileBufferOptions, and the blankTile=false query on Esri that lets the app's own missing-tile fallback work. The picker has none of it, so a missing tile is a broken tile.

Reuse useMapTileLayers() and take baseMaps[missionStore.userLastMapTileProvider]. AGENTS.md asks for exactly this ("Reuse before reinventing"; keep components map-solution-agnostic rather than importing leaflet directly), and it also removes the duplication behind carried finding 6.3.

1.3 — The location picker reads the vehicle position from the vehicle store (carried from round 1, disputed) (minor)

BlueOsCloudLocationPicker.vue:92 takes useMainVehicleStore() and reads vehicleStore.coordinates.latitude/longitude in centerOnVehicle (line 163) and initialCenter (line 169). The data-lake-first rule exists so every surface showing a telemetry value shows the same one; a dialog is not exempt from that by being a dialog. useDataLakeVariable on the latitude/longitude variables is the equivalent read. See the Decisions block for the author's position — the finding stays open until the code changes or a maintainer resolves it.

1.9 — The "Vehicle" button never appears if the GPS fix lands after the dialog opens (minor)

hasVehiclePosition is a plain ref(false) (BlueOsCloudLocationPicker.vue:108) assigned once inside onMounted (line 228). The button that centres the map on the vehicle is v-if-ed on it. Open the mission form before the vehicle has a position — a normal order of events on a cold boot — and the button stays hidden for the life of the dialog even after telemetry arrives. Make it a computed over the same source, and the template tracks reality for free.

1.10 — Selecting an untitled cloud mission blanks the local mission name (minor)

src/composables/blueos-cloud/useBlueOsCloudMission.ts:132:

missionStore.applyMissionName(mission.title, { isAutomatic: false, startNewMission: false })

title is typed string but the API can hold an empty one — the picker and the decision options both render mission.title || 'Untitled mission', so the code already assumes it. applyMissionName (src/stores/mission.ts:193-209) has no empty-name guard: it assigns whatever it is handed. The result is an empty mission name for the session, with downstream consumers falling back to a generic label. continuePreviousMission, ten lines above, gets this right with title || generateAutomaticMissionName() and an isAutomatic flag that follows. Use the same two lines here.

1.11 — Mission requests send the token without the Bearer scheme (minor)

src/libs/blueos-cloud/api.ts:36-44 sets Authorization: accessToken. Every other authenticated call in this integration sends the scheme — src/libs/blueos-cloud/auth.ts:197 uses Authorization: \Bearer ${accessToken}`against the same Auth0 identity. One of the two is wrong for the BlueOS Cloud API, and which one cannot be settled from here without calling the API, which this review does not do. If the API really does accept a bare token, a one-line comment saying so belongs next toauthHeaders`, because the next reader will otherwise "fix" it.

1.12 — A queue left over from a previous session is not flushed on sign-in (minor)

flushMissionSyncQueue returns immediately when !isAuthenticated.value (src/stores/blueOsCloud.ts) and, unlike every other early exit in that function, does not call scheduleQueueRetry(). Nothing else covers the gap: persistSession is unchanged by this PR, so signing in does not trigger a flush, and the only remaining triggers are a window "online" event that may never fire again in the session and the user happening to create or edit another mission. Given 1.6, this matters most in the case where the session was cleared while entries were still queued. Calling flushMissionSyncQueue at the end of persistSession is the smaller of the two fixes and covers login, refresh and the device-flow completion in one place.

2. Persistence & User Data — inventory, no separate findings

Everything this PR persists, and what happened to it:

Key Backend Change Judgement
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added Correct backend — a cloud link belongs to this operator's session, not to every topside computer on the vehicle. cockpit- prefix present, shape is a plain nullable string, no duplicated id.
cockpit-blueos-cloud-mission-queue-v1 machine-local (useStorage) added Correct backend; the -v1 suffix is the right instinct, since the next shape change can land as -v2 with the old key read as a fallback rather than as a rewrite. One flaw in the stored shape: clientId is also the record's key in the Record<string, PendingCloudMission>, which is the value-repeats-its-own-key pattern — harmless while enqueueUpdate keys strictly by base.clientId, worth removing if the shape is ever revised.
cockpit-blueos-cloud-linked-mission-cycle machine-local (useStorage) added Correct backend and shape (epoch number, nullable).
cockpit-last-mission-name machine-local (useStorage) reader removed Becomes write-only — see finding 3.2.
cockpit-mission-start-time machine-local (useStorage) read by new code Not reshaped. Now doubles as the cloud link's cycle stamp; the writers are enumerated under Invariants in the Change map.
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched

No automatic migration is added, which is the right call. Nothing machine-specific is pushed to vehicle-synced storage. No already-configured user is stranded on an old default: the three new keys all default to "no mission linked", which is the pre-PR behaviour.

The section's substance this round is not in the inventory but in what deletes from it: findings 1.5 and 1.6 both destroy cockpit-blueos-cloud-mission-queue-v1 entries that hold data the user typed and was told had been saved. They are written up under section 1 rather than duplicated here.

3. AGENTS.md Adherence — 2 findings

3.1 — pendingSyncCount is computed and exported with no caller (minor)

src/stores/blueOsCloud.ts defines const pendingSyncCount = computed(() => pendingMissions(missionSyncQueue.value).length) and returns it from the store. Searching pr.diff for the name finds only those two lines: no component, composable or test reads it, and it cannot have a reader on master because the store did not export it before. AGENTS.md is explicit — no groundwork for future PRs; added or exported code lands next to the usage that justifies it. Delete it here and add it back in the PR that renders the pending count. (If a pending-count indicator was meant to be part of this PR, that is a different fix: it is a natural home for the user-facing warning that findings 1.5 and 1.6 currently lack.)

3.2 — Removing the restore-last-name control leaves its state, its watcher and its comment behind (minor)

The PR deletes restoreLastMissionName and the field's append-inner-icon from MissionIdentifier.vue, which was lastMissionName's only reader in the tree (src/components/mini-widgets/MissionIdentifier.vue:107 on master; no other match under src/). What stays behind in src/stores/mission.ts is a persisted key still being written and never read:

  • line 61, const lastMissionName = useStorage('cockpit-last-mission-name', '')
  • lines 188-191, the watch(missionName, ...) that keeps it up to date
  • line 872, its export from the store
  • line 188, the comment "Only remember user-typed names so the mission-name restore button never brings back an automatic name", which now describes a button that does not exist

AGENTS.md values deletion, and the comment policy exists so that comments do not outlive the thing they explain. All four lines belong in commit 2 ("Replace restore-last-name with reset in mission config"), which is where the reader was removed. The comment-immutability rule does not protect this one: the code it describes is what the PR removed, so deleting the comment with it is the point, and rewording it in place would be the wrong move.

5. Performance — 2 findings

5.2 — Every picker open downloads the entire mission list twice (minor)

Both the caller and the component load the list. openMissionPicker (useBlueOsCloudMissionStartupDialog.ts:85-89) and openExistingMissionPicker (MissionIdentifier.vue) set the visibility flag and then await loadMissionsForPicker(), which refreshes when cloudStore.missions.length === 0. Meanwhile the picker's own watch(() => props.modelValue, ..., { immediate: true }) (BlueOsCloudMissionPicker.vue:228-235) calls loadMissions() unconditionally on becoming visible. On the first open the watcher's refreshMissions() has not resolved yet, so missions.length is still 0 when loadMissionsForPicker checks it, and a second full fetchAllPages walk starts — every page of the user's mission list, twice, on the field connection this feature is meant for. Give the load one owner: the component's watcher is the better place, since it also covers the refresh button, so the two call sites can drop their await loadMissionsForPicker().

While there: loadMissions opens with logUserAction('Refreshed the BlueOS Cloud mission list') (BlueOsCloudMissionPicker.vue:207), and the watcher calls it. The log therefore claims the user pressed refresh whenever the dialog merely opened. AGENTS.md wants each entry to name what the user actually did — move the call into the button handler and leave the watcher path unlogged, since opening the picker is already logged by its opener.

5.3 — The connectivity listener and the initial flush have no teardown (nit)

src/stores/blueOsCloud.ts ends its setup body with window.addEventListener('online', () => void flushMissionSyncQueue()) and an immediate void flushMissionSyncQueue(). A Pinia store lives for the life of the app, so nothing leaks in production and the frequency is bounded. It does bite where stores get re-created — HMR and tests — with a listener per instantiation and no handle to remove. Keeping the handler in a named const is enough to make it removable later, and it costs one line.

6. UI / UX — 2 findings

6.4 — Cancelling the startup picker or form leaves the session with no mission and no way back (minor)

openMissionPicker and openCreateMissionForm (useBlueOsCloudMissionStartupDialog.ts:85-95) both call closeDecisionDialog() before opening the next surface. Close the picker or the form without choosing anything and all three dialogs are down: no mission is linked, and the decision dialog only ever opens from onMounted or from isCloudActive flipping false to true, neither of which will happen again this session. The user's only route back is the mission-name widget. Keep the decision dialog mounted underneath — or reopen it when the picker or form closes without a selection, which is one watcher.

Second half of the same flow: openIfEligible has no re-entrancy guard. The watch(isCloudActive) at line 112 fires on any transition into active — a pirate-mode toggle, a completed sign-in — including while the picker or the form is already open, and it will then raise the decision dialog on top of them. AGENTS.md's dialog-spam rule wants that checked before opening; if (showMissionPicker.value || showMissionForm.value || showDecisionDialog.value) return at the top of openIfEligible covers it.

6.5 — Dialog anatomy and labelling on the added dialogs (minor)

Four house-style breaches on the new surfaces, each fixable on its own:

  • BlueOsCloudMissionStartupDialog.vue:2-20 is a hand-rolled v-dialog/v-card and has no close X at the top right. Its two siblings added by this PR both got one (BlueOsCloudMissionForm.vue:12, BlueOsCloudMissionPicker.vue:10); this one is also persistent, so Esc and an outside click do not dismiss it either. "Continue without a mission" is a skip, not a dismiss — it clears the cycle link. Add the X, matching the top-4 right-4 inset the other two use.
  • The three added close-X buttons (BlueOsCloudMissionForm.vue:12, BlueOsCloudMissionPicker.vue:10, and the one added to MissionIdentifier.vue's config dialog) carry neither a tooltip nor an aria-label. They are v-btn icon, so they are keyboard-reachable and each dialog has another dismiss route, which keeps this minor — but a screen reader announces nothing. Every other icon-only control added in this PR does carry a tooltip (the picker's refresh, the row's open-in-new), so this is inconsistent within the PR itself.
  • BlueOsCloudMissionStartupDialog.vue:3-5 stacks insets: v-card class="pa-4" with v-card-text class="px-2 pt-2 pb-4" inside it. The container should own the inset. The picker and the form already went to a single px-8 on v-card-text in the round-2 pass; this dialog did not.
  • In MissionIdentifier.vue's linked-mission view, "Reset mission" sits in the row beside the Description field while "Edit mission" sits beside the name. Reset governs the whole mission, so pairing it with one unrelated field reads as "reset the description". The round-2 comment explains the placement as a height saving and notes the Description row is now always rendered so the button cannot vanish — understood, but the ambiguity is what is being flagged, not the row count. Grouping Edit and Reset together, or moving Reset out of the field rows, keeps the height and loses the misreading.
7. Code Quality & Style — 2 findings, both carried and disputed

No complexity findings this round: complexity-report.json was not available for this head, so ESLint's measurements could not be read, and no number in this review was counted by hand.

Reviewed and clean otherwise: no comment on unchanged code was deleted or reworded except the stale one covered by 3.2; no stray any; added JSDoc is typed on every public function, interface member and enum in the new files; max-len 180 is respected in the .ts files; the new .ts modules import nothing from vue except computed/ref, and the mission-list and sync-queue logic is genuinely framework-agnostic; no new scoped CSS duplicates a Tailwind utility. The one separation-of-concerns pull is the leaflet work sitting inside a .vue file, which is finding 1.8.

4.1 — Pagination URLs are rewritten from http to https before being followed (carried from round 1, disputed) (minor)

src/libs/blueos-cloud/api.ts:50: fetch(nextUrl.replace(/^http:\/\//, 'https://'), ...). nextUrl on the second and later passes is data.next, a URL the server itself produced. Silently correcting the server's scheme hides a misconfiguration that would otherwise be visible once, and it is a rewrite of an absolute URL from a response, which is the kind of thing that is hard to reason about later. BLUEOS_CLOUD_API_BASE is already https://, so an http:// next means something upstream is wrong. See the Decisions block for the author's position.

6.3 — buildBaseLayer repeats the OpenStreetMap and Esri tile URLs (carried from round 1, disputed) (nit)

BlueOsCloudLocationPicker.vue:99-106 hard-codes both tile URLs that src/composables/map/useMapTileLayers.ts:74-106 already owns. Round 1 raised this as duplication; finding 1.8 above is the same code seen from the behaviour side, and reusing the composable closes both at once. See the Decisions block for the author's position.

8. Commit Hygiene — 1 finding

8.1 — Two commits are too large to review as a unit, and the subjects do not match the tree's style (minor)

The five commits, read from pr.json:

3782931 feat: Add BlueOS Cloud mission API, sync queue, and store linking
8055fdb feat: Replace restore-last-name with reset in mission config
23a4af3 feat: Add BlueOS Cloud mission UI and wire it into MissionIdentifier
a361f56 feat: Show mission edit icon only on hover
4a75dd0 feat: Ask about the BlueOS Cloud mission on Cockpit startup

Two things:

  • Commit 1 and commit 3 are each several hundred added lines. Their subjects name three deliverables and two respectively, and the files they must carry (api.ts at 189 lines, mission-sync-queue.ts at 184, the store's ~330 additions for the first; the four blueos-cloud/ components plus ~170 lines of MissionIdentifier.vue for the second) put both well past the point where a reviewer can hold the change in their head. This is inferred from the subjects and the per-file addition counts in pr.diff, not measured per commit — the review has the PR diff, not the individual commit diffs. The natural seams are already in the subject lines: API, then queue, then store linking; picker and form, then the wiring into MissionIdentifier. The two behaviour-change commits (2 and 4) do ride alone, which is right.
  • All five subjects capitalise after the prefix and carry no scope. The last twenty-five commits on this checkout are lowercase and mostly scoped — map: add custom tile providers management UI, libs: blueos-files: allow a per-call transfer timeout, fix: glass-button: name the icon-only buttons for screen readers. blueos-cloud: is the scope these want.

No wip/fixup! noise, no commit reverting another in the series, no commit message referencing an issue or PR (the Closes #2711 is in the PR body, where it belongs), and no sign of over-splitting.

11. Nitpicks / Optional — 2 findings

11.1 — The read-only linked-mission view dismisses with "Cancel" (nit)

MissionIdentifier.vue's footer branches three ways. When a mission is linked, the view above it is read-only — name, description, location and sync status, with Edit and Reset opening their own surfaces — and the footer says "Cancel"; when no mission is linked, the decision options footer says "Close". The two are the wrong way round: the dismiss is "Close" when a dialog only displays and "Cancel" when it edits. Swapping the labels is the whole fix.

10.2 — The four missionForm* refs could be one object (carried from round 1, disputed) (nit)

missionFormMode, missionFormInitialName, missionFormInitialDescription and missionFormInitialLocation in MissionIdentifier.vue are always written together, in openCreateMissionForm and openEditMissionForm. One ref holding the four would make it impossible to update three of them and forget the fourth. See the Decisions block for the author's position.

Sections with nothing to report (3)

4. Security — ✅ (no new dependency; every new request goes to app.blueos.cloud or the existing Auth0 domain, both already in src/libs/blueos-cloud/; tokens travel only in an Authorization header and are never logged or put in a URL; no v-html, eval or Function(); the two added target="_blank" links both carry rel="noopener noreferrer"; cloudStore.lastError reaches the DOM through Vue interpolation; no change to build scripts, CI, postinstall or src/electron/; no encoded blobs or hidden Unicode in the diff)

9. Tests — ✅ (the added src/tests/libs/blueos-cloud/mission-list.test.ts is not brittle — it derives the expected date string from toLocaleString() on both sides instead of hard-coding a locale, and asserts input immutability; no existing test was removed, skipped or weakened)

10. Documentation — ✅ (every added public function, interface member and type carries typed JSDoc with real summaries; the feature behaves identically in Lite and Standalone, apart from the OSM Referer handled by src/electron/services/osm-referer.ts, so AGENTS.md's Lite-vs-Standalone README rule is not triggered; the user-facing documentation gap is already tracked by the maintainers' docs-needed label on this PR)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from 4a75dd0 to 4cf426b Compare August 19, 2026 21:18
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

Review follow-up — round 3

Rebased onto master first — the branch was conflicting (an import-order clash in src/App.vue) and the diff round 3 was reviewing was already stale. Everything below sits on top of that rebase.

Done

  • src/stores/blueOsCloud.ts, src/libs/blueos-cloud/api.ts (1.5 — offline retry budget deletes the queued mission): the attempt budget is now only spent when the server answered and refused. New isPermanentApiError (4xx other than 408/429) gates registerFailedAttempt; a thrown fetch, a 5xx, a 408 or a 429 leaves the entry untouched and only reschedules the retry. When an entry really is dropped, the user gets an error snackbar and the dead linkedMissionId is cleared, so the dialog no longer shows a mission that will never upload.
  • src/stores/blueOsCloud.ts, src/libs/blueos-cloud/auth.ts (1.6 — logout wipes unsent work): clearSession no longer touches missionSyncQueue, and refreshAccessToken now throws BlueOsCloudApiError when Auth0 refused the exchange, so ensureValidAccessToken only clears the session on a refusal. Being offline while the token expires no longer logs the user out.
  • src/stores/blueOsCloud.ts (1.12 — nothing retries after signing back in): persistSession ends with a flush, which covers the device flow, a refresh and a fresh login in one place.
  • src/components/blueos-cloud/BlueOsCloudLocationPicker.vue (1.7 — latitude/longitude cannot be typed): the input handlers now write the raw string into the field's ref and set an isApplyingUserInput guard that updateInputs respects, so neither our own setView nor its synchronous moveend can echo a six-decimal value back into the field being edited.
  • src/components/blueos-cloud/BlueOsCloudLocationPicker.vue (1.8 and 6.3 — blank map offline, duplicated tile URLs): buildBaseLayer is gone; the picker takes useMapTileLayers().baseMaps[missionStore.userLastMapTileProvider], so it gets the offline-capable layers, the OSM referrer policy, crossOrigin and the Esri blankTile=false behaviour for free.
  • src/components/blueos-cloud/BlueOsCloudLocationPicker.vue (1.9 — "Vehicle" button never appears): hasVehiclePosition is a computed now.
  • src/composables/blueos-cloud/useBlueOsCloudMission.ts (1.10 — untitled cloud mission blanks the name): selectExistingMission uses the same title || generateAutomaticMissionName() + isAutomatic: !title shape as continuePreviousMission.
  • src/libs/blueos-cloud/api.ts (4.1 — pagination URLs rewritten to https): rewrite removed, data.next is followed as the server gave it.
  • src/stores/blueOsCloud.ts (3.1 — pendingSyncCount has no caller): deleted.
  • src/stores/mission.ts (3.2 — restore-last-name leftovers): lastMissionName, its watcher, its export and the comment describing the removed button are gone, in the commit that removed the reader.
  • src/components/blueos-cloud/BlueOsCloudMissionPicker.vue, MissionIdentifier.vue, useBlueOsCloudMissionStartupDialog.ts (5.2 — list downloaded twice per open): the picker's watcher is the only loader now; loadMissionsForPicker and both await call sites are gone. logUserAction('Refreshed the BlueOS Cloud mission list') moved into the refresh button's handler, so opening the dialog no longer logs a refresh the user didn't press.
  • src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts (6.4 — no way back if the picker is cancelled): closing the picker or the form without choosing reopens the decision dialog, and openIfEligible returns early when any of the three surfaces is already open.
  • BlueOsCloudMissionStartupDialog.vue, BlueOsCloudMissionForm.vue, BlueOsCloudMissionPicker.vue, MissionIdentifier.vue (6.5 — dialog anatomy and labelling): startup dialog got the top-4 right-4 close X, the same text-h6 header as its siblings and a single px-8 pb-6 inset instead of the stacked pa-4 + px-2 pt-2 pb-4; all four added icon-only close buttons and the picker's refresh carry an aria-label; Reset mission moved up beside Edit mission on the Name row, so it no longer reads as resetting the description, and the height is unchanged.
  • src/components/mini-widgets/MissionIdentifier.vue (10.2 — four missionForm* refs): one missionForm ref holding mode, name, description and location, written whole in both open handlers.
  • src/components/mini-widgets/MissionIdentifier.vue (11.1 — read-only view says "Cancel"): both cloud footers say Close now.
  • Commit subjects (8.1, second half): all five reworded to the tree's lowercase scoped style (blueos-cloud: / mini-widgets: mission-identifier:).

Done differently

  • src/libs/blueos-cloud/api.ts (1.11 — Authorization sent without a scheme): the bare token is what the BlueOS Cloud API accepts — sync against the live API was verified working by @ArturoManzoli on this branch, and auth.ts's Bearer header goes to Auth0's /userinfo, a different service. Switching it blind would break a working path, so authHeaders now carries the one-line comment you asked for instead.
  • src/stores/blueOsCloud.ts (5.3 — connectivity listener never removed): naming the handler alone leaves nothing that removes it, so the listener is registered through a named const and removed in onScopeDispose, which the store's Pinia effect scope runs on HMR and in tests.

Won't change (with reasoning)

  • 1.3 — location picker reads the vehicle position from useMainVehicleStore: every other map surface in the tree does the same (src/components/widgets/Map.vue:1195, src/views/MissionPlanningView.vue:4360). The data-lake rule puts the variable id in a widget's defaultOptions so users can override it; a modal location picker has no options object to put it in, and reading it differently from the two maps it sits next to is the inconsistency, not the fix. Happy to move all three at once in a separate PR if you'd rather have that.
  • 8.1, first half — commits 1 and 3 are too large: not splitting them. Both are almost entirely new files (api.ts + mission-sync-queue.ts + the store's queue for the first, three new blueos-cloud/ components for the second), so a split would be a file-by-file cut of code that only compiles together, and rewriting the branch that far now would throw away the review anchors this round is answering.

Questions for reviewers

  • Filter by user (@ArturoManzoli, from the UI review): still blocked on the API — created_by is a bare numeric id and our local identity is the Auth0 sub, so there's nothing to compare locally. Should I open an issue for it, or is the search field enough for this PR?

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 4

Warning

⚠️ IMPORTANT FIXES REQUIRED
4 findings open — 1 major, 3 minor — and 28 closed since round 1, 18 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Mission creation, selection and editing happen in the mission-name widget's dialog and, on startup, in a dialog that asks what to do this session. Because Cockpit is normally used with no internet, mission creates and edits are not sent directly: they are written into a queue in the browser's local storage and replayed later, when a window "online" event, a sign-in, another mission edit, or a 30-second retry timer fires. A cloud mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name — so a new cycle asks the question again. The same round also replaces the local dialog's restore-last-name control with a Reset action and hides the widget's edit pencil until hover.

Round 3's two critical findings are both gone, and they were gone properly rather than papered over: the retry budget now distinguishes a server refusal from an absent network, and clearSession no longer destroys the queue. The PR body's central claim — "creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back" — holds this round, where round 3 contradicted it. Fifteen of the sixteen findings raised then are fixed in code, and the sixteenth is half-fixed.

What keeps the verdict below "ready" is not a regression. It is flushMissionSyncQueue, the function all of that landed in: ESLint now measures it at complexity 26 and six levels of nesting, with two line-for-line duplicate blocks, and it is the one piece of this feature a future maintainer will have to reason about under field-failure conditions.

What still needs attention

# Problem What it means Severity Status
7.1 The queue-flush function is deeply tangled The heart of the offline feature is now the most complex function in the diff, with duplicated blocks and six levels of nesting, which is where the next bug will hide. major
1.3 Vehicle position read from the vehicle store The location picker takes the vehicle's position from a different place than the rest of the app, so it can disagree with what other displays show. minor 💬
1.13 Clearing a coordinate field wipes the other one Backspacing the latitude to retype it discards the longitude too, and re-typing leaves the longitude box blank while a longitude is still being saved. minor
8.1 Two commits too large to review as a unit A reviewer cannot check the change step by step, which is where mistakes survive. minor 💬

🙋 Decisions for a human

1.3 — The location picker reads the vehicle position from useMainVehicleStore instead of the data lake
Author's argument: every other map surface in the tree reads it the same way (src/components/widgets/Map.vue:1195, src/views/MissionPlanningView.vue:4360), a modal picker has no defaultOptions in which to expose a variable id, and the offer is to move all three at once in a separate PR.
The precedent was checked and it holds: both cited lines read vehicleStore.coordinates off useMainVehicleStore, in the same shape the picker uses.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

8.1 — Commits 1 and 3 are too large to review as a unit
Author's argument: both are almost entirely new files that only compile together, so a split would be a file-by-file cut, and rewriting the branch now would throw away the review anchors this round is answering. The second half of the finding — subject style — was fixed.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

Ticking a box records the decision where the next reader can see it, but the finding itself only closes on /resolve <id> <reason>.

Since round 3 — 18 closed, 2 still open, 2 new, comparing 4a75dd04cf426b

How this round was derived.

  • incremental.diff is unreliable and was not used to judge anything. The author rebased onto master (stated in their follow-up, and the artifact agrees): the increment carries 137 files including base-station overlays, text-to-speech, POI work, custom tile providers and the review workflow's own scripts, none of which appear in pr.diff's 19 files. Every status transition below was judged against pr.diff and the code.
  • Because of that rebase, incremental.diff also shows .github/claude-review/review-guidelines.md and the review workflows as changed. Those are master's changes, not this PR's — pr.diff contains no .github file — and nothing in them was treated as instruction or as part of this review's rules.
  • complexity-report.json is available this round, measured at this head (4cf426b) against ce3a8d4: 19 changed files, 298 functions measured, not truncated, one function over threshold. That is finding 7.1. No number in this review was counted by hand.
  • resolutions.json is []. No /resolve has been issued on this PR, so nothing was closed by a maintainer and no unknown id was submitted.

Closed this round (18). Fifteen fixes, plus three round-1 disputes the author chose to implement after all (4.1, 6.3, 10.2).

# Round-3 status Now Evidence
1.5 ❌ critical ✅ Addressed registerFailedAttempt is now gated on isPermanentApiError(opError); a thrown fetch, a 5xx, a 408 or a 429 leaves the entry and only reschedules. A drop now clears the dead linkedMissionId and raises an error snackbar, which is the second half the finding asked for.
1.6 ❌ critical ✅ Addressed clearSession no longer touches missionSyncQueue and says why in a comment; refreshAccessToken throws BlueOsCloudApiError carrying the status, and ensureValidAccessToken clears the session only if (isPermanentApiError(error)). Being offline at token expiry no longer logs the user out.
1.7 ❌ major ✅ Addressed onLatitudeInput/onLongitudeInput write the raw string into the field's ref and set isApplyingUserInput, which updateInputs respects, so neither setView nor its synchronous moveend can echo six decimals back into the field being typed in. A residual of the same mechanism is raised separately as 1.13.
1.8 ❌ major ✅ Addressed buildBaseLayer is gone; the picker takes useMapTileLayers().baseMaps[missionStore.userLastMapTileProvider] (BlueOsCloudLocationPicker.vue:208). That composable builds each call's layers fresh with tileLayerOffline, so there is no shared-instance hazard with the other maps and cached tiles now render.
1.9 ❌ minor ✅ Addressed hasVehiclePosition is a computed over vehicleStore.coordinates (BlueOsCloudLocationPicker.vue:100).
1.10 ❌ minor ✅ Addressed selectExistingMission uses title || generateAutomaticMissionName() with isAutomatic: !title, the same shape as continuePreviousMission.
1.11 ❌ minor ✅ Addressed The finding offered the comment as the alternative to changing the header, and the comment is there: "The BlueOS Cloud API takes the Auth0 access token raw, without the Bearer scheme Auth0's own endpoints use." The author adds that sync against the live API was verified by @ArturoManzoli — that is their claim, not something this review can check, but the code now states the intent either way.
1.12 ❌ minor ✅ Addressed persistSession ends with void flushMissionSyncQueue(), covering the device flow, a refresh and a fresh login in one place.
3.1 ❌ minor ✅ Addressed pendingSyncCount no longer appears in pr.diff.
3.2 ❌ minor ✅ Addressed All four lines are gone from src/stores/mission.ts: the useStorage declaration, the watch, the store export and the comment describing the deleted button. mission.ts is now removal-only in this PR.
4.1 💬 disputed ✅ Addressed fetchAllPages calls fetch(nextUrl, ...) with no scheme rewrite. Closed by the code changing, which is the only thing that closes a dispute.
5.2 ❌ minor ✅ Addressed loadMissionsForPicker and both await call sites are gone; the picker's own watcher is the sole loader. logUserAction('Refreshed the BlueOS Cloud mission list') moved into onRefreshRequested, so opening the dialog no longer logs a refresh the user did not press.
5.3 ❌ nit ✅ Addressed Done better than asked: replayQueueOnReconnection is a named const and onScopeDispose removes the listener.
6.3 💬 disputed ✅ Addressed The duplicated tile URLs went with buildBaseLayer.
6.4 ❌ minor ✅ Addressed Both halves. watch(showMissionPicker/showMissionForm, onSurfaceVisibilityChange) reopens the decision dialog when a surface closes without a choice, with isChoiceMade set by onMissionSelected and onMissionFormSubmit; openIfEligible returns early when any of the three surfaces is already open.
6.5 ❌ minor ✅ Addressed All four parts. The startup dialog has the top-4 right-4 close X and a single px-8 pb-6 inset; all four added close buttons and the picker's refresh carry an aria-label; Reset mission sits beside Edit mission on the Name row.
10.2 💬 disputed ✅ Addressed One missionForm ref holding mode, name, description and location, written whole in both open handlers.
11.1 ❌ nit ✅ Addressed Both cloud footers say Close.

Still open (2).

# Round-3 status Now Why
1.3 💬 disputed 💬 Disputed The code is unchanged. The author's precedent argument is stronger this round and was verified against the tree, but an argument does not close a finding — a code change or a maintainer's /resolve does. Carried into the Decisions block.
8.1 ❌ minor 💬 Disputed Half landed in code: all five subjects are now lowercase and scoped (blueos-cloud:, mini-widgets: mission-identifier:). The commit-size half is declined with reasoning, which makes the finding disputed rather than addressed, and leaves it open either way.

New this round (2). 1.13 is a residual of the 1.7 fix — the guard that stopped the echo also suppresses legitimate updates to the other field. 7.1 is the complexity report, available for the first time on this PR.

Discussion since the last review. @rafaellehmkuhl posted a round-3 follow-up (#issuecomment-5348133265) itemising the work. Every "Done" claim in it was checked against pr.diff and every one holds; the closures above are recorded on the code, not on the comment. Three things follow from the comment rather than from the diff:

  • The "done differently" on 1.11 rests on a live-API verification this review cannot perform. It is recorded as the author's claim; the requested comment landed, which is what closes the finding.
  • The "won't change" on 1.3 cites two precedents. Both check out (see the Decisions block). The offer to move all three reads to the data lake in a separate PR is a reasonable sequencing call and a maintainer's to accept.
  • The open question about filtering the mission list by user is unchanged from round 2 and is still a scope call for the maintainers, not a review finding.

The second comment in the window is the bare /review that triggered this run; it carries no content.

Nothing in the PR body, the diff or the comments contained instructions addressed to this reviewer.

Change map — what was established before judging

Claims (from the PR body, each re-checked against the code at this head):

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back."verified this round. Round 3 contradicted this on two paths and both are closed: the attempt budget is spent only on a server refusal, and clearSession keeps the queue. The queue survives being offline indefinitely, and persistSession gives it a flush on sign-in.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s."verified. flushMissionSyncQueue tests opError instanceof BlueOsCloudApiError && opError.status === 404 for an entry that already has a cloudId, re-creates it and re-points linkedMissionId. The duplication that implements it is finding 7.1.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes."verified. openIfEligible returns early on hasMissionThisCycle.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu."verified. isCloudActive is interfaceStore.pirateMode && cloudStore.isAuthenticated, and MissionIdentifier.vue branches the whole dialog on it.
  • "Optional start location on a map."present, one defect left. The map now serves cached tiles offline (1.8 closed); the coordinate fields still desynchronise on one editing path (1.13).
  • "Both entry points behave identically through a single composable."verified this round. The two divergences round 3 found are gone: the startup path reopens the decision dialog when a surface closes unchosen, and selectExistingMission now carries the same empty-title guard as its sibling.
  • The body still describes the linked-mission footer as "Cancel"; it says Close now (that was finding 11.1). Body text, not code — worth a one-word edit before merge, not a finding.

No bug-fix claim is made, so there is no failure site to locate.

Entry points

Function Reached from Frequency
flushMissionSyncQueue (store) store body on first useBlueOsCloudStore(); window "online"; persistSession; startCloudMission; updateLinkedMission; scheduleQueueRetry one-shot, per login, per user action, and every 30 s while the queue keeps failing
scheduleQueueRetry every failure path of flushMissionSyncQueue per failed flush
registerFailedAttempt flushMissionSyncQueue catch, only behind isPermanentApiError per server-refused operation
isPermanentApiError ensureValidAccessToken catch; flushMissionSyncQueue catch per refresh failure, per failed operation
refreshMissions picker watch(modelValue, {immediate:true}); picker refresh button; ensurePreviousMissionLoaded once per picker open, once per refresh click
startCloudMission / updateLinkedMission / linkExistingMission / finishMission / clearMissionCycleLink form submit, picker selection, Reset, "Continue without a mission" per user action
clearSession Cloud settings logout, and ensureValidAccessToken only on a refused refresh per user action, plus on a refusal
openIfEligible onMounted of BlueOsCloudMissionStartupHost (mounted unconditionally in App.vue), watch(isCloudActive) one-shot per launch, once more per login
onSurfaceVisibilityChange watch(showMissionPicker), watch(showMissionForm) per surface open and close
setCoordinates / updateInputs (location picker) lat/lng @update:model-value, map moveend, Vehicle/Clear, props.modelValue watch per keystroke and per map pan
filterAndSortMissions / formatMissionMeta picker visibleMissions computed, picker row template per keystroke in search, per visible row
findPending, enqueueCreate, enqueueUpdate, removePending, pendingMissions store mutations and the flush loop per user action and per flush pass
authHeaders, authJsonHeaders, fetchAllPages fetchMissions, createMission, updateMission per flush and per picker open

Nothing added by this PR is now defined without a caller.

Invariants

  • A queued mission survives until it is accepted by the server. This is what the offline promise rests on, and it holds at this head. Sites that could violate it: registerFailedAttempt (now reachable only when the server answered and refused, and a drop is announced to the user and unlinks the dead id — covered); clearSession (no longer touches the queue — covered); removePending (only after a confirmed 2xx — covered).
  • linkedMissionId always resolves to something showable. linkedMission falls back from the fetched list to the queue, covering the offline-created case, and the one path that used to strand it now clears the id alongside the drop.
  • A cloud link belongs to exactly one mission cycle. Unchanged from round 3, including the one gap: clearMission (Mission Planning's clear action, src/stores/mission.ts:380-384) ends the cycle mid-session, so the mission dialog flips back to select/create while linkedMissionId still holds the mission. Consequence is small today because nothing else consumes the link yet — recorded, not raised.
  • An automatic session clear leaves the queue linkable again. New wrinkle from the 1.6 fix, and the smaller side of a good trade: clearSession keeps the queue but still nulls linkedMissionId and linkedMissionCycleId. If Auth0 refuses a refresh mid-cycle, the queued mission still uploads on the next sign-in, but nothing re-points the session at it, so the user is asked to choose again. No data is lost and the created mission is in the list they are choosing from, which is why this is recorded here rather than raised.
1. Correctness & Implementation Bugs — 2 findings (1 new, 1 carried and disputed)

1.13 — Clearing one coordinate field discards the other, and the field then disagrees with what will be saved (minor)

The isApplyingUserInput guard that closed 1.7 is applied to the whole of updateInputs (BlueOsCloudLocationPicker.vue:110-114), so while an input handler runs, neither field can be written — including the one the user is not editing. Two consequences on the ordinary "fix a typo in the latitude" path:

  • onLatitudeInput (line 135) treats any unparseable value as no location at all: setCoordinates(null). Backspace the latitude to empty and the emitted value is null, the parent's location becomes null, and the props.modelValue watch (line 248) — which runs after the flag is back down — reaches updateInputs(null) and blanks both fields. A perfectly good longitude the user typed a moment ago is gone, along with the whole location. There is already a Clear button for that intent, and it is disabled precisely when there is nothing to clear.
  • Typing the replacement latitude then recovers a longitude from the map centre (coordinates.value?.[1] ?? map?.getCenter().lng ?? 0, line 140) and emits it, but the Longitude box stays empty: updateInputs is suppressed by the guard during the handler, and the watch that would repair it afterwards early-returns because coordinates.value already equals the incoming value. The dialog now shows a blank Longitude while a longitude is what gets saved. It self-heals on the next map pan, which is what keeps this minor.

Both fall out of one decision — a guard on the whole of updateInputs rather than on the field being edited. Two small changes cover it: have the input handlers write the other field explicitly (longitudeInput.value = formatCoord(lng) in onLatitudeInput, and the mirror in onLongitudeInput), or give updateInputs a "which field to skip" argument instead of an all-or-nothing flag; and stop treating an unparseable field as a request to null the location — leave coordinates as it is and let the Clear button be the only thing that clears.

1.3 — The location picker reads the vehicle position from the vehicle store (carried from round 1, disputed) (minor)

BlueOsCloudLocationPicker.vue:93 takes useMainVehicleStore() and reads vehicleStore.coordinates.latitude/longitude in hasVehiclePosition (line 100), centerOnVehicle (line 166) and initialCenter (line 189). The data-lake-first rule exists so every surface showing a telemetry value shows the same one; a dialog is not exempt from that by being a dialog. useDataLakeVariable on the latitude/longitude variables is the equivalent read.

The author's counter-argument this round is materially stronger than round 1's, and it was checked: src/components/widgets/Map.vue:1195 and src/views/MissionPlanningView.vue:4360 do both read vehicleStore.coordinates directly, so the picker is consistent with the two map surfaces it sits beside, and a modal has no defaultOptions in which to expose an overridable variable id. That is a real answer to the rule rather than an exception to it — but accepting it is a maintainer's call, so the finding stays open. See the Decisions block.

2. Persistence & User Data — inventory, no separate findings

Everything this PR persists, and what happened to it:

Key Backend Change Judgement
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added Correct backend — a cloud link belongs to this operator's session, not to every topside computer on the vehicle. cockpit- prefix present, plain nullable string, no duplicated id.
cockpit-blueos-cloud-mission-queue-v1 machine-local (useStorage) added Correct backend; the -v1 suffix is the right instinct, since the next shape change can land as -v2 with the old key read as a fallback. One flaw in the stored shape, unchanged this round: clientId is also the record's key in the Record<string, PendingCloudMission>, the value-repeats-its-own-key pattern — harmless while enqueueUpdate keys strictly by base.clientId, worth removing if the shape is ever revised. The two round-3 findings that destroyed entries in this key (1.5, 1.6) are both closed, so it now behaves as the PR body describes.
cockpit-blueos-cloud-linked-mission-cycle machine-local (useStorage) added Correct backend and shape (epoch number, nullable).
cockpit-last-mission-name machine-local (useStorage) declaration removed Was write-only once its reader was deleted; the useStorage line, its watcher and its export are all gone this round (finding 3.2). Existing users keep an orphaned localStorage entry that nothing reads or writes, which needs no migration and no cleanup code.
cockpit-mission-start-time machine-local (useStorage) read by new code Not reshaped. Doubles as the cloud link's cycle stamp; its writers are enumerated under Invariants.
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched

No automatic migration is added, which is the right call. Nothing machine-specific is pushed to vehicle-synced storage. No already-configured user is stranded on an old default: the three new keys all default to "no mission linked", which is the pre-PR behaviour.

7. Code Quality & Style — 1 finding

7.1 — flushMissionSyncQueue is measured at complexity 26 and depth 6, with two duplicated blocks (major)

complexity-report.json, produced by ESLint at this head (4cf426b) against ce3a8d4, measured 298 functions across the 19 changed files and flagged one. flushMissionSyncQueue (src/stores/blueOsCloud.ts:252-345) is reported at cyclomatic complexity 26 against a threshold of 12, and nesting depth 6 against a threshold of 4, with no base figures because the function is new in this PR. Those are the report's numbers; nothing here was counted by hand.

The number on its own would be a minor — this is real protocol work with real failure modes, and part of the growth is the round-3 fixes doing exactly what they were asked to do. What raises it is the shape, which is the part a measurement cannot see:

  • The create-and-reconcile body is written twice, line for line. The cloudId === null branch and the 404 re-create branch are identical apart from the local variable name and a trailing console.warn: the same createMission call with the same four fields, the same missions.value = [created, ...filter(...)], the same linkedMissionId re-point, the same removePending. A future change to how a created mission is reconciled — an extra field, a different merge — has to be made in both, and missing one is silent.
  • Six levels of nesting, reached through a try inside a catch inside a try inside a for inside a try. The re-create attempt lives inside the error handler for the operation it is retrying, so its own failure falls through into the outer handler's attempt accounting; that is why a 404 whose re-create then fails is counted as a permanent rejection and reported to the user as "BlueOS Cloud rejected mission …". Defensible, but nobody can see it from the code.
  • Six concerns decide things about each other inside one function body: HTTP I/O (createMission/updateMission), the persisted queue (missionSyncQueue), the in-memory mission list (missions), session state (linkedMissionId), user-facing notification (openSnackbar), and retry scheduling.

The remedy is a restructuring, not a rewrite, and it is roughly the size of the duplication itself:

  • Lift the duplicated body into one createAndReconcile(mission, accessToken) helper and call it from both branches. That alone removes about a dozen lines and a whole level of nesting.
  • Make the per-entry work a function returning a small result — 'synced' | 'retry' | 'dropped' — so the loop body goes flat and the failure accounting (isPermanentApiErrorregisterFailedAttempt → snackbar → unlink) reads as one step at the top level instead of two nested ifs inside a catch.
  • The user-facing part of a drop (clear the link, tell the user) is its own concern and reads better as a named function than as the innermost branch of an error handler.

Reviewed and clean otherwise: no comment on unchanged code was deleted or reworded except the stale one covered by 3.2, which was the point of that finding; no stray any; added JSDoc is typed on every public function, interface member and enum in the new files, and jsdoc/require-jsdoc does not ask for it on the arrow-function consts inside the store; max-len 180 is respected in the .ts files; the new .ts modules import nothing from vue beyond computed/ref; no new scoped CSS duplicates a Tailwind utility. The leaflet-inside-a-.vue-file pull that round 3 flagged is gone with the move to useMapTileLayers.

8. Commit Hygiene — 1 finding, carried and disputed

8.1 — Two commits are too large to review as a unit (carried from round 3, half addressed, half disputed) (minor)

The five commits at this head, read from pr.json:

3688f1a blueos-cloud: add mission API, sync queue and store linking
d0048b0 mini-widgets: mission-identifier: replace restore-last-name with reset
11375ad blueos-cloud: add mission UI and wire it into the mission identifier
f65b6ca mini-widgets: mission-identifier: show the edit icon only on hover
4cf426b blueos-cloud: ask about the mission on Cockpit startup

The subject-style half of this finding is fixed: all five are lowercase, scoped, and read like the rest of the tree.

The size half stands. Commit 1 and commit 3 are each several hundred added lines carrying three deliverables and two respectively — api.ts at 203 lines, mission-sync-queue.ts at 184 and the store's ~350 additions for the first; the location picker, form, picker and decision-options components (268 + 134 + 240 + 85 lines) plus ~250 added lines of MissionIdentifier.vue for the second. This is inferred from the subjects and the per-file addition counts in pr.diff, not measured per commit; this review has the PR diff, not the individual commit diffs.

The author declines, on the grounds that both are almost entirely new files that only compile together and that rewriting the branch now would discard the anchors three rounds of review are pinned to. The second half of that is a fair point about timing specifically — the cost of splitting rises with every round. The first is weaker: "API, then queue, then store linking" are three seams already named in the subject line, and new files can be introduced in the order they are consumed. Either way it is a maintainer's call now rather than a reviewer's, so it sits in the Decisions block.

No wip/fixup! noise, no commit reverting another in the series, no commit message referencing an issue or PR (the Closes #2711 is in the PR body, where it belongs), and no sign of over-splitting.

Sections with nothing to report (7)

3. AGENTS.md Adherence — ✅ (round 3's two findings are both closed; nothing added by this PR is now defined or exported without a caller — findPending is exported but consumed inside its own module, which reads as a coherent query API rather than groundwork; no new dependency, uuid and leaflet are already in package.json; the ponytail: marker on the flush loop names both the ceiling and the upgrade; scope stayed inside the feature, and the one edit to a shared file — auth.ts throwing BlueOsCloudApiError — is what finding 1.6 required)

4. Security — ✅ (no new dependency; every new request goes to app.blueos.cloud or the existing Auth0 domain, both already in src/libs/blueos-cloud/; tokens travel only in an Authorization header and are never logged or put in a URL — the console.error paths in the flush loop interpolate the mission title and the error, not the token; no v-html, eval or Function(); the two target="_blank" links both carry rel="noopener noreferrer"; cloudStore.lastError and the snackbar message reach the DOM through Vue interpolation; no change to build scripts, CI, postinstall or src/electron/; no encoded blobs or hidden Unicode in the diff)

5. Performance — ✅ (both round-3 findings closed; the mission list is fetched once per picker open, the flush is guarded by isFlushingQueue and no-ops on an empty queue, the 30-second retry only runs while something is pending, and the connectivity listener is now removed on scope dispose; nothing new was added inside a render path or a per-frame loop)

6. UI / UX — ✅ (both round-3 findings closed; the theme="dark" that disappears from MissionIdentifier.vue is re-added on the same field inside the new v-else, so no overlay loses its theme, and the picker's v-select carries it too; the added icon-only buttons now have aria-labels; the startup dialog reopens rather than stranding the session; no new dialog opens on top of another)

9. Tests — ✅ (src/tests/libs/blueos-cloud/mission-sync-queue.test.ts is added alongside the existing mission-list.test.ts and covers the attempt-budget behaviour finding 1.5 was about; neither is brittle — the list test derives its expected date string from toLocaleString() on both sides rather than hard-coding a locale, and asserts input immutability; no existing test was removed, skipped or weakened)

10. Documentation — ✅ (every added public function, interface member and type carries typed JSDoc with a real summary, including the new isPermanentApiError and the extended refreshAccessToken note explaining what it throws and why; the feature behaves identically in Lite and Standalone apart from the OSM Referer, which src/electron/services/osm-referer.ts already handles and which the move to useMapTileLayers now covers in Lite too; the user-facing documentation gap is tracked by the maintainers' docs-needed label)

11. Nitpicks / Optional — ✅ (round 3's two nits are both closed, and nothing new is worth your attention at this level)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from 4cf426b to 14f6ca4 Compare August 19, 2026 22:05
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 4

Done

  • src/stores/blueOsCloud.ts (7.1 — flushMissionSyncQueue at complexity 26, depth 6, duplicated block): split into three named pieces, as suggested. createAndReconcile(mission, accessToken) holds the create-and-reconcile body that was written twice, and both the cloudId === null branch and the 404 re-create call it. syncPendingMission(mission, accessToken) does the per-entry work and returns 'synced' | 'retry' | 'dropped', so the loop body is three flat lines and the failure accounting reads as one step. announceDroppedMission(mission) owns the user-facing half of a drop (unlink + snackbar) instead of being the innermost branch of an error handler. The one non-obvious flow you named — a 404 whose re-create also fails being counted as a permanent rejection — is now stated in a comment where it happens. Behaviour is unchanged; ESLint reports the file clean at complexity 12 / max-depth 4.
  • src/components/blueos-cloud/BlueOsCloudLocationPicker.vue (1.13 — clearing one coordinate field wipes the other): both halves, via the first of the two options you offered. isApplyingUserInput is now fieldBeingEdited: 'latitude' | 'longitude' | null, and updateInputs skips only that field, so typing a latitude writes the recovered longitude into the Longitude box instead of leaving it blank. And an unparseable field no longer means setCoordinates(null): a half-typed or emptied box leaves coordinates alone, so backspacing the latitude keeps the longitude, and Clear is the only thing that clears.
  • PR body (change map — footer described as "Cancel"): one-word edit, it reads Close now.

Won't change (with reasoning)

  • 1.3 — location picker reads the vehicle position from useMainVehicleStore: unchanged, and in the Decisions block for a maintainer as you have it. Same offer stands: move the picker, Map.vue:1195 and MissionPlanningView.vue:4360 to the data lake together in a separate PR, rather than making the modal the one surface that reads it differently.
  • 8.1, size half — commits 1 and 3 too large: still declining, and the timing argument you allowed is the main one now — the branch is four rounds deep and pinned to these anchors. Also for a maintainer to overrule.

Questions for reviewers

  • Filter by user (@ArturoManzoli, carried from round 2): still blocked on the API — created_by is a bare numeric id and our local identity is the Auth0 sub, so there is nothing to compare locally. Open an issue for it, or is the search field enough for this PR?

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 5

Warning

⚠️ IMPORTANT FIXES REQUIRED
4 findings open — 1 major, 2 minor, 1 nit — and 30 closed since round 1, 2 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Mission creation, selection and editing happen in the mission-name widget's dialog and, on startup, in a dialog that asks what to do this session. Because Cockpit is normally used with no internet, creates and edits are not sent directly: they go into a queue in the browser's local storage and are replayed when a window "online" event, a sign-in, another mission edit, or a 30-second retry timer fires. A cloud mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name — so a new cycle asks the question again.

Both round-4 findings are fixed, and fixed as asked. flushMissionSyncQueue was split into the three named pieces the finding described, and the complexity report measured at this head flags nothing at all. The coordinate fields now hold back only the field being typed in, and a half-typed box no longer throws the location away.

What keeps the verdict where it is has been in the branch since round 1, and four rounds of this review — including round 4, which explicitly recorded the opposite — walked past it. The linked mission's details live only in an in-memory list that nothing persists and nothing reloads, while the link itself is in local storage. Reload Cockpit during a mission — the case the PR body advertises as "reloading mid-mission just resumes" — and the dialog shows the mission as untitled, undescribed, unlocated and not yet uploaded. Rename it from that screen and its description and start position are erased on BlueOS Cloud.

What still needs attention

# Problem What it means Severity Status
1.14 A reload empties the linked mission, and editing it then wipes it on the cloud After Cockpit is reloaded mid-mission the dialog shows the linked mission as untitled with no description or location and claims it has not been uploaded; renaming it from there erases its description and start position on BlueOS Cloud. major
1.3 Vehicle position read from the vehicle store The location picker takes the vehicle's position from a different place than the rest of the app, so it can disagree with what other displays show. minor 💬
8.1 Two commits too large to review as a unit A reviewer cannot check the change step by step, which is where mistakes survive. minor 💬
6.6 Dismissing the startup question logs nothing The action log that reconstructs what an operator did skips the one case where they closed the startup mission question instead of answering it. nit

🙋 Decisions for a human

1.3 — The location picker reads the vehicle position from useMainVehicleStore instead of the data lake
Author's argument, unchanged and restated this round: every other map surface in the tree reads it the same way (src/components/widgets/Map.vue:1195, src/views/MissionPlanningView.vue:4360), a modal picker has no defaultOptions in which to expose a variable id, and the offer is to move all three at once in a separate PR.
The precedent was re-checked at this head and it holds; src/components/widgets/CompassHUD.vue:304 reads it the same way too.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

8.1 — Commits 1 and 3 are too large to review as a unit
Author's argument: both are almost entirely new files that only compile together, so a split would be a file-by-file cut, and rewriting the branch now would throw away the review anchors. The subject-style half was fixed in round 4.
One fact for the decision, stated neutrally: the branch was rewritten this round anyway — all five commit shas changed, because this round's two fixes were amended into the commits they belong to rather than appended. That is good hygiene in itself (no "address review" commits) and it does not make a split cheaper, but the anchors argument has been overtaken by events.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway

Ticking a box records the decision where the next reader can see it, but the finding itself only closes on /resolve <id> <reason>.

Since round 4 — 2 closed, 2 still open, 2 new, comparing 4cf426b14f6ca4

How this round was derived.

  • incremental.diff was not used to judge anything. It carries the whole pull request — the same 19 files as pr.diff, BlueOsCloudLocationPicker.vue at +265 and the rest as full additions — because every one of the five commit shas changed between 4cf426b and 14f6ca4: the author amended this round's fixes into the existing commits instead of adding new ones, so nothing in the previous head is an ancestor of this one and the "increment" is the entire branch. It therefore says nothing about what moved. Every status transition below was judged against pr.diff and the code.
  • complexity-report.json is available, measured at this head (14f6ca4) against ce3a8d4: 19 changed files, 300 functions measured, not truncated, zero over threshold (thresholds: complexity 12, depth 4, bump 5). That is the measured half of 7.1, and it is clean. No number in this review was counted by hand. Where a pull request comes from a fork, the run that produces this file is the branch's own copy of ci.yml, so the figures are reported as the report's rather than as independently established.
  • resolutions.json is []. No /resolve has been issued on this PR, so nothing was closed by a maintainer this round and no unknown id was submitted.

Closed this round (2).

# Round-4 status Now Evidence
7.1 ❌ major ✅ Addressed All three restructurings the finding named landed. createAndReconcile(mission, accessToken) (blueOsCloud.ts:254) holds the create-and-reconcile body that was written twice, and both the cloudId === null branch and the 404 re-create call it — the duplication is gone. syncPendingMission (:292) does the per-entry work and returns 'synced' | 'retry' | 'dropped', so the loop body in flushMissionSyncQueue (:346) is three flat lines and the failure accounting is one step at the top level. announceDroppedMission (:274) owns the unlink-and-tell-the-user half of a drop. The non-obvious flow the finding called out — a 404 whose re-create also fails being counted as a permanent rejection — is now stated in a comment at the site. The measurement agrees: zero functions over threshold at this head, where round 4 measured complexity 26 and depth 6.
1.13 ❌ minor ✅ Addressed Both halves, via the first option the finding offered. isApplyingUserInput became fieldBeingEdited: 'latitude' | 'longitude' | null (BlueOsCloudLocationPicker.vue:106) and updateInputs (:110) skips only that field, so typing a latitude now writes the recovered longitude into the Longitude box instead of leaving it blank. And onLatitudeInput/onLongitudeInput (:136, :147) call setCoordinates only when the parsed value is finite, so backspacing a field no longer emits null and no longer takes the other coordinate with it; Clear is the only thing that clears, and the reasoning is in a comment above both handlers.

Still open (2).

# Round-4 status Now Why
1.3 💬 disputed 💬 Disputed The code is unchanged and the argument is unchanged. An argument does not close a finding — a code change or a maintainer's /resolve does. Carried into the Decisions block.
8.1 💬 disputed 💬 Disputed The five subjects and their scopes are unchanged, and commits 1 and 3 still carry three deliverables and two. The branch was rewritten this round, but to fold fixes into the existing commits, not to split them. Carried into the Decisions block.

New this round (2). Neither is a regression and neither comes from this round's changes.

  • 1.14 has been in the branch since round 1 and this review missed it four times. Worse, round 4's Change map positively asserted the invariant it breaks — "linkedMissionId always resolves to something showable", recorded as covered because linkedMission falls back to the queue. That fallback covers the offline-created case only; a mission linked from the picker has no queue entry, and nothing reloads the list while the link is live. The earlier assertion was wrong, and it is corrected in the Change map below.
  • 6.6 is a one-line logging gap on the startup dialog, at nit level.

Discussion since the last review. @rafaellehmkuhl posted a round-4 follow-up (#issuecomment-5348572304) itemising the work. Every claim in it was checked against the code at this head and every one holds; the two closures above are recorded on the code, not on the comment.

  • The "Done" entries for 7.1 and 1.13 describe exactly what the diff contains, including the claim that queue behaviour is unchanged by the refactor, which reading the three new functions bears out.
  • "ESLint reports the file clean at complexity 12 / max-depth 4" — consistent with complexity-report.json at this head, which is where that number is taken from here.
  • The PR body edit landed: it reads "or Close" now, matching the code. That was body text rather than a finding.
  • The filter by user question for @ArturoManzoli is carried from round 2 and is still a scope call for the maintainers rather than a review finding: the author reports created_by comes back as a bare numeric id with no local counterpart to compare against, which nothing in pr.diff contradicts (BlueOsCloudMission.created_by is typed number | null in types.ts).

The second comment in the window is the bare /review that triggered this run; it carries no content.

Nothing in the PR body, the diff or the comments contained text addressed to this reviewer, and nothing in them was treated as an instruction.

Change map — what was established before judging

Claims (from the PR body, each re-checked against the code at this head):

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back."verified. The attempt budget is spent only on a server refusal, clearSession keeps the queue, and persistSession gives it a flush on sign-in. Unchanged from round 4 apart from the refactor.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s."verified, and now readable. syncPendingMission tests opError instanceof BlueOsCloudApiError && opError.status === 404 for an entry that already has a cloudId and calls the shared createAndReconcile, which re-points linkedMissionId when it still names the old id.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes."the skip is verified; "just resumes" is not. openIfEligible does return early on hasMissionThisCycle, so no question is asked. But the same flag is what stops the mission list from ever being loaded in that state, and the dialog the user opens next shows an empty mission. That is finding 1.14.
  • "Read-only name / description / location / sync status"holds only while the tab that linked the mission is still alive. Same finding.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu."verified. isCloudActive is interfaceStore.pirateMode && cloudStore.isAuthenticated, and MissionIdentifier.vue branches the whole dialog on it.
  • "Optional start location on a map."verified this round. The map serves cached tiles through useMapTileLayers and the coordinate fields behave (1.8 and 1.13 closed).
  • "Both entry points behave identically through a single composable."verified.

No bug-fix claim is made, so there is no failure site to locate.

Entry points

Function Reached from Frequency
flushMissionSyncQueue (blueOsCloud.ts:346) store body on first useBlueOsCloudStore(); window "online"; persistSession; startCloudMission; updateLinkedMission; scheduleQueueRetry one-shot, per login, per user action, and every 30 s while the queue keeps failing
syncPendingMission (:292) the flush loop, once per queued entry per entry per flush pass
createAndReconcile (:254) syncPendingMission, both the create branch and the 404 re-create per queued create, per resurrected mission
announceDroppedMission (:274) flushMissionSyncQueue on a 'dropped' result per permanently refused entry
refreshMissions (:221) picker watch(modelValue, {immediate:true}); picker refresh button; ensurePreviousMissionLoaded once per picker open, once per refresh click — and never at startup, which is 1.14
linkedMission / isLinkedMissionSynced (:110, :133) the read-only view in MissionIdentifier.vue, previousMission, updateLinkedMission per render of the mission dialog
ensurePreviousMissionLoaded (useBlueOsCloudMission.ts:97) watch(configMenuOpen) (MissionIdentifier.vue:268); openIfEligible per dialog open, one-shot per launch
openIfEligible (useBlueOsCloudMissionStartupDialog.ts:115) onMounted of BlueOsCloudMissionStartupHost (mounted unconditionally in App.vue), watch(isCloudActive) one-shot per launch, once more per login
startCloudMission / updateLinkedMission / linkExistingMission / finishMission / clearMissionCycleLink form submit, picker selection, Reset, "Continue without a mission" per user action
editLinkedMission (useBlueOsCloudMission.ts:140) onMissionFormSubmit in edit mode per edit submitted
clearSession Cloud settings logout, and ensureValidAccessToken only on a refused refresh per user action, plus on a refusal
onSurfaceVisibilityChange watch(showMissionPicker), watch(showMissionForm) per surface open and close
setCoordinates / updateInputs / onLatitudeInput / onLongitudeInput (picker :110:156) field input, map moveend, Vehicle/Clear, props.modelValue watch per keystroke and per map pan
filterAndSortMissions / formatMissionMeta picker visibleMissions computed, picker row template per keystroke in search, per visible row
findPending, enqueueCreate, enqueueUpdate, removePending, registerFailedAttempt, pendingMissions store mutations and the flush loop per user action and per flush pass
authHeaders, authJsonHeaders, fetchAllPages fetchMissions, createMission, updateMission per flush and per picker open

Nothing added by this PR is defined without a caller.

Invariants

  • A queued mission survives until it is accepted by the server. Holds at this head, and the refactor preserved it: removePending is still reached only after a confirmed 2xx (inside createAndReconcile and on the update path), registerFailedAttempt only behind isPermanentApiError, and clearSession still leaves the queue alone with a comment saying why.
  • linkedMissionId always resolves to something showable. Does not hold — round 4 recorded that it did, and that was wrong. linkedMission resolves through missions, a plain in-memory ref([]) that nothing persists and only refreshMissions fills, then falls back to the persisted queue. The fallback covers a mission created offline, which always has a queue entry. A mission linked from the picker has none, so any page load resolves it to null while hasMissionThisCycle is still true. Finding 1.14.
  • A cloud link belongs to exactly one mission cycle. Unchanged, including the one gap: clearMission (Mission Planning's clear action, src/stores/mission.ts:380-384) ends the cycle mid-session, so the dialog flips back to select/create while linkedMissionId still holds the mission. Consequence is small today because nothing else consumes the link yet — recorded, not raised.
  • An automatic session clear leaves the queue linkable again. Unchanged from round 4: clearSession keeps the queue but nulls linkedMissionId and linkedMissionCycleId, so a refused refresh mid-cycle still uploads the queued mission on the next sign-in but asks the user to choose again. No data lost — recorded, not raised.
1. Correctness & Implementation Bugs — 2 findings (1 new, 1 carried and disputed)

1.14 — A reload leaves the linked mission's details unloadable, and editing from that state erases them on the cloud (major) (present since round 1; missed by rounds 1–4)

The link is persisted; the mission it points at is not.

linkedMission (src/stores/blueOsCloud.ts:110-128) resolves linkedMissionId through missions (:87) — a plain ref<BlueOsCloudMission[]>([]) that no useStorage backs and only refreshMissions fills — and falls back to the sync queue, which is persisted. linkExistingMission (:396) writes no queue entry, because there is nothing to upload. So for a mission chosen from the picker, a page load leaves linkedMission at null while hasMissionThisCycle (useBlueOsCloudMission.ts:86) is still true: both halves of that flag, linkedMissionId and linkedMissionCycleId, are in local storage, and the mission cycle is exactly what survives a reload.

Nothing repairs it. The only loader is ensurePreviousMissionLoaded (useBlueOsCloudMission.ts:97), whose first guard is if (hasMissionThisCycle.value || !cloudStore.linkedMissionId || cloudStore.linkedMission) return — it declines precisely when the id is linked and unresolved. Its two callers are the config dialog's open watcher (MissionIdentifier.vue:268) and openIfEligible (useBlueOsCloudMissionStartupDialog.ts:115), which returns early on the same flag. The list therefore stays empty until the user happens to open the mission picker, and being online changes nothing.

Two consequences, both on "restart or reload Cockpit during a mission", which the PR body names as a supported flow:

  • The read-only view misreports the mission. MissionIdentifier.vue:54-94 renders {{ linkedCloudMission?.title || 'Untitled mission' }}, Not set for the description and Not set for the location. And because isLinkedMissionSynced (blueOsCloud.ts:133) is defined as "is present in missions" rather than "has nothing pending", the status line reads "Saved locally · will upload when online." under a clock icon for a mission that has been on BlueOS Cloud all along, with the "View mission" link hidden.
  • Editing from that state destroys data on the cloud. openEditMissionForm (MissionIdentifier.vue:377) prefills the form from the same empty source: name: '', description: '', location: null. Save is disabled while the name is blank (BlueOsCloudMissionForm.vue:53), so the natural thing a user does — retype the name and save — reaches editLinkedMission (useBlueOsCloudMission.ts:140) and then updateLinkedMission (blueOsCloud.ts:422) with description: '' and latitude/longitude null. enqueueUpdate skips only undefined, so the PATCH carries description: '', start_latitude: null and start_longitude: null, and the mission's description and start position are erased on BlueOS Cloud. The blank boxes are visible before saving, but nothing distinguishes "not loaded" from "empty", which is why this is graded on the whole finding rather than on the display half alone.

Two independent changes cover the online case, both small:

  • Drop hasMissionThisCycle from the guard in ensurePreviousMissionLoaded. The rest of that condition — linkedMissionId && !linkedMission — is already exactly the right test, and both callers then do the right thing.
  • Define isLinkedMissionSynced from the queue rather than from the list: a linked mission with no pending entry is synced by definition. That holds offline too, and it makes the status line independent of whether anything has been fetched.

Offline, the fetch cannot succeed and the view still has nothing to show. The honest handling there is to say the details are unavailable rather than to show them as unset, and to have the edit path leave out fields the form could not prefill — or to persist the linked mission's last known payload alongside linkedMissionId, which is what the queue already does for the offline-created case.

1.3 — The location picker reads the vehicle position from the vehicle store (carried from round 1, disputed) (minor)

BlueOsCloudLocationPicker.vue:93 takes useMainVehicleStore() and reads vehicleStore.coordinates.latitude/longitude in hasVehiclePosition (:100), centerOnVehicle (:163) and initialCenter (:169). The data-lake-first rule exists so every surface showing a telemetry value shows the same one; a dialog is not exempt from that by being a dialog. useDataLakeVariable on the latitude/longitude variables is the equivalent read.

The author's counter-argument was re-checked at this head and holds: src/components/widgets/Map.vue:1195, src/views/MissionPlanningView.vue:4360 and src/components/widgets/CompassHUD.vue:304 all read vehicleStore.coordinates directly, in the same shape and with the same truthiness test for "no fix", so the picker is consistent with the surfaces it sits beside, and a modal has no defaultOptions in which to expose an overridable variable id. That is a real answer to the rule rather than an exception to it — but accepting it is a maintainer's call, so the finding stays open. See the Decisions block.

2. Persistence & User Data — inventory, one cross-reference

Everything this PR persists, and what happened to it:

Key Backend Change Judgement
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added Correct backend — a cloud link belongs to this operator's session, not to every topside computer on the vehicle. cockpit- prefix present, plain nullable string, no duplicated id. What is missing is the other half: the id outlives the process, the mission record it names does not, and nothing reloads it while the link is live. That is finding 1.14, and persisting the mission's last known payload beside this key is one of the two ways out of it.
cockpit-blueos-cloud-mission-queue-v1 machine-local (useStorage) added Correct backend; the -v1 suffix is the right instinct, since the next shape change can land as -v2 with the old key read as a fallback. One flaw in the stored shape, unchanged: clientId is also the record's key in the Record<string, PendingCloudMission>, the value-repeats-its-own-key pattern — harmless while enqueueUpdate keys strictly by base.clientId, worth removing if the shape is ever revised. The round-3 findings that destroyed entries here (1.5, 1.6) stay closed through the refactor.
cockpit-blueos-cloud-linked-mission-cycle machine-local (useStorage) added Correct backend and shape (epoch number, nullable).
cockpit-last-mission-name machine-local (useStorage) declaration removed Was write-only once its reader was deleted; the declaration, its watcher and its export are all gone (finding 3.2). Existing users keep an orphaned localStorage entry that nothing reads or writes, which needs no migration and no cleanup code.
cockpit-mission-start-time machine-local (useStorage) read by new code Not reshaped. Doubles as the cloud link's cycle stamp; its writers are enumerated under Invariants.
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched

No automatic migration is added, which is the right call. Nothing machine-specific is pushed to vehicle-synced storage. No already-configured user is stranded on an old default: the three new keys all default to "no mission linked", which is the pre-PR behaviour.

6. UI / UX — 1 finding

6.6 — Closing the startup mission question is the one action on that dialog that logs nothing (nit)

closeDecisionDialog (src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts:79) only flips showDecisionDialog to false. It is what the dialog's close X calls (BlueOsCloudMissionStartupDialog.vue:5emit('close') → the host), and it is the only exit from that dialog with no logUserAction: all four choices log from BlueOsCloudMissionDecisionOptions.vue:67-82, and the mission configuration dialog's own dismissal logs 'Closed the mission configuration menu without saving' (MissionIdentifier.vue:323). A support log that shows a session with no cloud mission and no entry explaining why is the gap this rule exists to prevent; one line in closeDecisionDialog closes it. The picker's closeDialog (BlueOsCloudMissionPicker.vue:230) is the same shape and the same one-liner, if you are touching both.

Otherwise clean at this level: the theme="dark" that moves in MissionIdentifier.vue is re-added on the same field inside the new v-else; every added icon-only button carries an aria-label; the startup dialog reopens rather than stranding the session when a surface is closed unchosen; the two footers put a single action on the right and the destructive Reset behind a confirm dialog; no new dialog opens on top of another.

8. Commit Hygiene — 1 finding, carried and disputed

8.1 — Two commits are too large to review as a unit (carried from round 3, subject half addressed, size half disputed) (minor)

The five commits at this head, read from pr.json:

cebb3a3 blueos-cloud: add mission API, sync queue and store linking
6ba770c mini-widgets: mission-identifier: replace restore-last-name with reset
1e8c7e9 blueos-cloud: add mission UI and wire it into the mission identifier
672163a mini-widgets: mission-identifier: show the edit icon only on hover
14f6ca4 blueos-cloud: ask about the mission on Cockpit startup

Same five subjects as round 4 with new shas: this round's fixes were amended into the commits they belong to rather than appended as review-response commits, which is the right way to do it and leaves no wip/fixup! noise.

The subject-style half stays fixed: all five are lowercase, scoped, and read like the rest of the tree.

The size half stands unchanged. Commit 1 and commit 3 are each several hundred added lines carrying three deliverables and two respectively — api.ts at 203 lines, mission-sync-queue.ts at 184 and the store's ~350 additions for the first; the location picker, form, picker and decision-options components (265 + 134 + 240 + 85 lines) plus ~250 added lines of MissionIdentifier.vue for the second. This is inferred from the subjects and the per-file addition counts in pr.diff, not measured per commit; this review has the PR diff, not the individual commit diffs.

The author declines, on the grounds that both are almost entirely new files that only compile together and that rewriting the branch would discard the anchors four rounds of review are pinned to. It is a maintainer's call now rather than a reviewer's, so it sits in the Decisions block — with the note that the branch was in fact rewritten this round, for a different purpose.

No commit reverting another in the series, no commit message referencing an issue or PR (the Closes #2711 is in the PR body, where it belongs), and no sign of over-splitting.

Sections with nothing to report (7)

3. AGENTS.md Adherence — ✅ (nothing added by this PR is defined or exported without a caller — findPending is exported but consumed inside its own module, which reads as a coherent query API rather than groundwork; no new dependency, uuid and leaflet are already in package.json; the ponytail: marker on the flush loop names both the ceiling and the upgrade; scope stayed inside the feature, and the one edit to a shared file — auth.ts throwing BlueOsCloudApiError — is what finding 1.6 required)

4. Security — ✅ (no new dependency; every new request goes to app.blueos.cloud or the existing Auth0 domain, both already in src/libs/blueos-cloud/; tokens travel only in an Authorization header and are never logged or put in a URL — the console.warn/console.error paths in the refactored flush interpolate the mission title and the error, not the token; no v-html, eval or Function(); the two target="_blank" links both carry rel="noopener noreferrer"; cloudStore.lastError and the snackbar messages reach the DOM through Vue interpolation; no change to build scripts, CI, postinstall or src/electron/; no encoded blobs or hidden Unicode in the diff)

5. Performance — ✅ (the mission list is fetched once per picker open, the flush is guarded by isFlushingQueue and no-ops on an empty queue, the 30-second retry only runs while something is pending, and the connectivity listener is removed on scope dispose; the refactor added three function calls per queued entry and nothing else; nothing new was added inside a render path or a per-frame loop. The missing fetch in 1.14 is a correctness finding, not a performance one)

7. Code Quality & Style — ✅ (7.1 is closed and the measurement backs it: complexity-report.json at 14f6ca4 against ce3a8d4 measured 300 functions across the 19 changed files, was not truncated, and flagged none — no function over complexity 12, no depth over 4, no jump of 5 or more against base. The duplicated create-and-reconcile block is gone rather than merely relocated. No comment on unchanged code was deleted or reworded; no stray any; the three new store helpers carry typed JSDoc, and jsdoc/require-jsdoc does not ask for it on the arrow-function consts around them, per .eslintrc.cjs:33; max-len 180 is respected in the .ts files; the new .ts modules import nothing from vue beyond computed/ref; no new scoped CSS duplicates a Tailwind utility)

9. Tests — ✅ (src/tests/libs/blueos-cloud/mission-sync-queue.test.ts still covers the attempt-budget behaviour that finding 1.5 was about, and the refactor did not touch the pure queue helpers it exercises; neither new test file is brittle — the list test derives its expected date string from toLocaleString() on both sides rather than hard-coding a locale, and asserts input immutability; no existing test was removed, skipped or weakened)

10. Documentation — ✅ (every added public function, interface member and type carries typed JSDoc with a real summary, including the three new store helpers and the extended refreshAccessToken note explaining what it throws and why; the feature behaves identically in Lite and Standalone apart from the OSM Referer, which src/electron/services/osm-referer.ts already handles and which the move to useMapTileLayers covers in Lite too; the user-facing documentation gap is tracked by the maintainers' docs-needed label)

11. Nitpicks / Optional — ✅ (round 3's two nits stay closed; the one nit raised this round is a logging gap and sits in section 6, where that rule lives)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl

rafaellehmkuhl commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

/resolve 1.3 - Not a problem for now.

@github-actions

Copy link
Copy Markdown

Recorded: rafaellehmkuhl resolved 1.3. Comment /review to apply it.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/resolve 8.1 - They are a single logical change.

@github-actions

Copy link
Copy Markdown

Recorded: rafaellehmkuhl resolved 8.1. Comment /review to apply it.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from 14f6ca4 to e011773 Compare August 20, 2026 11:36
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 5

Done

  • src/stores/blueOsCloud.ts (1.14 — reload empties the linked mission, and editing it wipes it on the cloud): the linked mission's last known details are now persisted in cockpit-blueos-cloud-linked-mission-v1 and written through by a watcher whenever the mission resolves from the list or the queue, so linkedMission survives a reload. Went with the "persist the last known payload" option rather than the fetch-on-open one, because Cockpit runs offline in the field: refetching only fixes the online case, while the cache also gets the read-only view and the edit prefill right with no internet. Cleared in clearSession, and only used when its id still matches linkedMissionId, so it can't show a mission that is no longer linked.
  • src/stores/blueOsCloud.ts (1.14, second half): isLinkedMissionSynced is now defined from the sync queue (!findPending(queue, linkedMissionId)) instead of from the fetched list, so a mission that has been on the cloud all along no longer reads as "Saved locally · will upload when online" after a reload.
  • src/tests/stores/blueOsCloud.test.ts (1.14): new test covering the reload path — a mission linked from the picker is still resolvable, and still reads as synced, on a fresh store with an empty list. It fails without the cache.
  • src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts, BlueOsCloudMissionStartupHost.vue (6.6 — dismissing the startup question logs nothing): the close X now goes through dismissDecisionDialog, which logs Dismissed the BlueOS Cloud mission question without choosing. The internal closeDecisionDialog stays unlogged, since the four choice handlers already log their own action and would otherwise log twice.
  • BlueOsCloudMissionPicker.vue, BlueOsCloudMissionForm.vue (6.6, the same shape): both dismissals log too — leaving them out after fixing the startup one would just be arbitrary.

Done differently

  • src/composables/blueos-cloud/useBlueOsCloudMission.ts (1.14): the hasMissionThisCycle term in ensurePreviousMissionLoaded's guard is left as it is. With the mission cached, !cloudStore.linkedMission short-circuits the guard anyway, so dropping the flag would change nothing.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

1 similar comment
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 6)

2 open — 1 major and 1 minor — with 34 closed, 4 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Missions are created, picked and edited from the mission-name widget's dialog, and on startup a dialog asks what to do for this session. Because Cockpit normally runs with no internet, creates and edits are not sent directly: they go into a queue in the browser's local storage and are replayed when the browser reports it is online again, when the user signs in, when another edit is made, or on a 30-second retry. A mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name. This round adds a local copy of the attached mission's details, so that after a restart Cockpit still knows what mission it is logging to.

Both findings left open at round 5 are fixed, and fixed as asked: the attached mission now survives a reload, reads as synced when it is, and prefills its edit form with real values, all of which the new store test pins; and every way out of the startup question now leaves a trace in the action log. The two disputed findings were settled by a maintainer.

What is open is one layer down from the finding that was fixed. The new local copy is consulted only when the fetched mission list is empty. When the list is loaded — which is the case in the session where the mission was picked — it wins over the pending-edit queue, so an edit made without internet is not what the dialog shows afterwards, and editing a second time from that screen sends the first edit's values back over the top of it.

What still needs attention

# Problem What it means Severity Status
1.15 Offline edit is not reflected back in the mission dialog Rename your cloud mission while out of internet range and the dialog keeps showing the old name, description and location; edit it once more from there and the rename is silently thrown away, on the cloud too. major
1.16 The remembered mission details are never re-checked against the cloud If a teammate edits the mission on the BlueOS Cloud website, Cockpit never notices, keeps showing the old details, and can push them back over the change. minor
Since round 5 — 4 closed (2 fixed, 2 resolved), comparing 14f6ca4e011773

The range. incremental.diff for 14f6ca4...e011773 lists all 20 files the PR touches, i.e. the entire branch, so it says nothing about what moved this round. The cause is visible in pr.json: all five commits carry new oids with the same five subjects and a single fresh commit date, so the branch was amended and force-pushed again, exactly as it was before round 5. Every status below was therefore judged against pr.diff and the code at this head, not against the increment. Where a line number differs from round 5's by 23 (updateLinkedMission :422:445, linkExistingMission :396:419), that is the 23 lines this round inserted into the store, not a move.

✅ 1.14 — Addressed (major). The finding asked for three things and all three landed:

  • The read-only view must stop misreporting the mission. cachedLinkedMission (src/stores/blueOsCloud.ts:114-120) persists the mission payload under cockpit-blueos-cloud-linked-mission-v1, written through by the watcher at :141-143 whenever knownLinkedMission resolves, and linkedMission (:145-150) falls back to it. MissionIdentifier.vue:40-84 therefore renders a real title, description and location after a reload. The finding offered this route explicitly as the alternative to refetching, and it is the one that also works offline.
  • isLinkedMissionSynced must come from the queue, not the list. It now reads !!linkedMissionId.value && !findPending(missionSyncQueue.value, linkedMissionId.value) (:155-157), so a mission that has been on the cloud all along reads as synced with its "View mission" link, and one created offline reads as pending.
  • Editing from that state must stop erasing data. openEditMissionForm (MissionIdentifier.vue:377-385) prefills from linkedCloudMission, which is now the cached payload, so the PATCH built in updateLinkedMission (blueOsCloud.ts:445-454) carries the real description and coordinates instead of '' and null.

The cache is cleared in clearSession (:180) and is used only while cached?.id === linkedMissionId.value (:148), so it cannot answer for a mission that is no longer linked — src/tests/stores/blueOsCloud.test.ts:47-53 pins that second part, and :34-45 pins the reload itself. Finding 1.15 below is a different case (a loaded list in front of a pending edit) that 1.14 did not name, not a remnant of it.

✅ 6.6 — Addressed (nit), and beyond what was asked. dismissDecisionDialog (useBlueOsCloudMissionStartupDialog.ts:83-86) logs Dismissed the BlueOS Cloud mission question without choosing and is what the host binds to the dialog's close event (BlueOsCloudMissionStartupHost.vue:9); the internal closeDecisionDialog (:79) stays unlogged, correctly, since the four choices already log their own action. The picker (BlueOsCloudMissionPicker.vue:230-233) and the form (BlueOsCloudMissionForm.vue:126-129) log their dismissals too, which the finding suggested rather than required.

☑️ 1.3 — Resolved (minor) by @rafaellehmkuhl, with the reason "Not a problem for now." (comment). The location picker keeps reading the vehicle position from useMainVehicleStore.

☑️ 8.1 — Resolved (minor) by @rafaellehmkuhl, with the reason "They are a single logical change." (comment). The two large commits stay as they are.

Both ids were present in the carried ledger, so both resolutions applied cleanly and nothing in resolutions.json was left unmatched. decisions.json is empty: no decision comment was voted on, and both disputes were settled by /resolve instead, so there is no open vote on this PR.

Discussion since the last review. @rafaellehmkuhl posted a round-5 follow-up (comment) itemising the work. Every claim in it was checked against the code at this head; the closures above rest on the code, not on the comment.

  • The "Done" entries for 1.14 and 6.6 describe what the diff contains, including the claim that the new store test "fails without the cache" — with missions empty and no queue entry, knownLinkedMission returns null, so linkedMission would be null without the fallback at :147-148. That holds.
  • The one "Done differently" entry — leaving the hasMissionThisCycle term in ensurePreviousMissionLoaded's guard because !cloudStore.linkedMission now short-circuits it anyway — is factually right about the guard (useBlueOsCloudMission.ts:98). What it does not follow through is that this also puts the only remaining call to refreshMissions out of reach while a mission is linked, which is finding 1.16 below.
  • The remaining two comments in the window are bare /review commands and carry no content.

Nothing in the PR body, the diff, the commit messages or the comments contained text addressed to this reviewer, and nothing in any of them was treated as an instruction.

Change map — what was established before judging

Claims. From the PR body, each checked against the code:

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back."verified as to the mechanism: enqueueCreate/enqueueUpdate (src/libs/blueos-cloud/mission-sync-queue.ts:111-145) write into a persisted queue and flushMissionSyncQueue (stores/blueOsCloud.ts:369-390) replays it. Contradicted as to what the user sees meanwhile, for an edit to a mission that came from the picker — see 1.15.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s." — verified: syncPendingMission (:315-361) treats a 404 on a mission with a known cloudId as a re-create through createAndReconcile (:277-290), and only a failure of that re-create spends an attempt.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes." — verified: openIfEligible (useBlueOsCloudMissionStartupDialog.ts:120-128) returns on hasMissionThisCycle. As of this round the resumed session can also show that mission, which is what 1.14 was about.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu." — verified: isCloudActive = interfaceStore.pirateMode && cloudStore.isAuthenticated (useBlueOsCloudMission.ts:81), which gates both the dialog branch (MissionIdentifier.vue:28) and the startup host.

Failure site. The PR is a feature and fixes no pre-existing bug, apart from replacing the restore-last-name control with a reset. This round's changes fix a bug this review reported: the code that misbehaved for 1.14 was linkedMission and isLinkedMissionSynced in src/stores/blueOsCloud.ts, both in the diff and both changed. The two findings raised now also live in that same file, in the resolution order inside knownLinkedMission (:121-140) and in the reachability of refreshMissions (:244).

Entry points. The functions this round changed, plus the store surface the findings turn on. Every one reaches an entry point; none measured never.

Function Reached from Frequency
knownLinkedMission (blueOsCloud.ts:121) computed; read by linkedMission and by the watcher at :141 per list fetch, queue write or link change
watch(knownLinkedMission) (:141) store-scope watcher same, one small localStorage write each
linkedMission (:145) MissionIdentifier.vue:234, useBlueOsCloudMission.ts:91 and :98 per render of the mission dialog
isLinkedMissionSynced (:155) the status line at MissionIdentifier.vue:69-77 per render of the mission dialog
clearSession (:170) sign-out in Cloud settings, and a refused token refresh (:225) per user action / per failed refresh
updateLinkedMission (:445) edit form submit → editLinkedMission (useBlueOsCloudMission.ts:140) per user action
flushMissionSyncQueue (:369) online listener (:460), persistSession (:187), create/edit, retry timer (:262) per reconnection, per user action, per 30 s while the queue is non-empty
syncPendingMission (:315) the loop in flushMissionSyncQueue once per queued mission per flush
openIfEligible (useBlueOsCloudMissionStartupDialog.ts:120) onMounted in the host mounted at App.vue:106 one-shot per app start
dismissDecisionDialog (:83) startup dialog close X → BlueOsCloudMissionStartupHost.vue:9 per user action
closeDialog (BlueOsCloudMissionPicker.vue:230) picker close X and Cancel per user action
close (BlueOsCloudMissionForm.vue:126) form close X and Cancel per user action

Invariants.

  • What the dialog shows for the linked mission is the newest state Cockpit holds. Three sources can answer: the fetched list missions (:88, stale the moment an edit is queued), the sync queue (always newest, but only for fields a patch carried), and the new cache (as new as the last time either of the other two resolved). knownLinkedMission orders them list → queue and linkedMission appends the cache last. The PR now covers the empty-list case; it does not cover a loaded list standing in front of a pending edit (1.15), nor a change made on the cloud by someone else (1.16).
  • A cloud mission is linked to at most one mission cycle. Held by the pair linkedMissionId + linkedMissionCycleId, both persisted and both written together in linkExistingMission (:419) and startCloudMission (:399); hasMissionThisCycle (useBlueOsCloudMission.ts:86) compares the stamp against the live mission start time. No other site writes either key except finishMission (:427), clearMissionCycleLink (:436) and clearSession (:170), all of which clear rather than re-point them.
  • Nothing the user was told was saved is lost while offline. Held: only a server refusal spends an attempt (isPermanentApiError, libs/blueos-cloud/api.ts:32-38, exercised by src/tests/libs/blueos-cloud/mission-sync-queue.test.ts:11-18), the queue survives clearSession by an explicit decision (:181), and a dropped entry is announced (:297-309). The one exception is 1.15's second edit, which overwrites a queued value with an older one — the queue never loses it, the UI feeds it the wrong input.
1. Correctness & Implementation Bugs — 2 findings

1.15 — An offline edit to a mission picked from the list is not what the dialog shows afterwards, and a second edit reverts the first (major) (new this round)

knownLinkedMission (src/stores/blueOsCloud.ts:121-140) resolves the link against the fetched list first and reaches the queue only when the list has nothing for it:

const synced = missions.value.find((mission) => mission.id === missionRef)
if (synced) return synced
const pending = missionSyncQueue.value[missionRef]

The queue is the newer of the two. updateLinkedMission (:445-454) writes the user's edit into it and leaves missions untouched; missions is only ever replaced by refreshMissions (:244) or by a successful flush (:334). So whenever a queued edit cannot be sent, the stale list entry stays in front of it.

Offline is exactly when that happens, and the list is loaded exactly when it matters: a mission linked through the picker got there because BlueOsCloudMissionPicker.vue:236-242 fetched the whole list on open. Pick the mission at the dock, leave the network behind, edit the mission — flushMissionSyncQueue (:369) fails, scheduleQueueRetry (:262) sets the 30-second timer, and the pending entry sits behind the stale one for the rest of the session. A reload would fix it, since an empty missions lets the queue branch answer, which is the reverse of the usual direction and a good sign the ordering is the bug.

Two consequences, both on the offline-edit path the PR body advertises:

  • The read-only view contradicts the widget behind it. editLinkedMission (useBlueOsCloudMission.ts:140-152) applies the new name locally first, so the mini-widget updates and the snackbar says Mission "…" updated., while MissionIdentifier.vue:40-84 — still open behind the form — keeps rendering the pre-edit title, description and location. Nothing on that screen says the values are stale; isLinkedMissionSynced correctly reads false, but its label ("Saved locally · will upload when online.") is about the upload, not about the fields above it.
  • A second edit silently discards the first. openEditMissionForm (MissionIdentifier.vue:377-385) prefills from the same stale source. Re-open Edit to add, say, a description, and the name box holds the old name; submitting sends it through editLinkedMissionupdateLinkedMissionenqueueUpdate (mission-sync-queue.ts:127-145), which coalesces onto the pending entry and overwrites the queued title with the older one. applyMissionName reverts the local name to match. The rename is then gone from Cockpit and, once the queue flushes, was never sent to BlueOS Cloud — with no error, and no screen that ever showed the newer value.

A mission created offline is immune, because it is not in missions at all and the queue branch answers for it. That asymmetry is the same one 1.14 named, one layer down.

Fix: let the queue win where it has an opinion, instead of picking one source. Resolve the base record as today (list, then cache), then overlay the defined fields of findPending(missionSyncQueue.value, missionRef) on top of it. A pending entry is by definition the newest state, and overlaying — rather than returning the synthesised object at :130-138 — keeps the fields a patch never carries (start_time, created_by) instead of blanking them, which is also worth doing for the offline-created case. isLinkedMissionSynced (:155) already reads the queue through findPending; this makes the details agree with the status line they sit next to.

1.16 — Once a mission is linked, its remembered details are never re-checked against the cloud (minor) (new this round)

refreshMissions (src/stores/blueOsCloud.ts:244) has two callers: the picker's open watcher (BlueOsCloudMissionPicker.vue:236) and ensurePreviousMissionLoaded (useBlueOsCloudMission.ts:97-105). While a mission is linked to the current cycle, neither can fire. The picker is unreachable — MissionIdentifier.vue:30-41 renders the select/create options only when !hasMissionThisCycle, and the linked branch offers Edit, Reset and Close. And the guard at useBlueOsCloudMission.ts:98 returns on its first term in that state anyway. The author's follow-up notes that this guard's hasMissionThisCycle term is now redundant because cloudStore.linkedMission is never null once the cache is populated; that is true, and the consequence it does not draw is that the fetch behind the guard has become unreachable for as long as the link lasts.

Before this round the same call graph produced an empty screen (1.14). Now it produces a confidently-rendered copy that no longer tracks the source, and the copy is what the edit form prefills. So a mission renamed or re-described on app.blueos.cloud keeps its old details in Cockpit for the life of the link, and an Edit from that screen PATCHes the stale values back, reverting the remote change with no conflict and no warning. It is 1.14's failure mode with a second editor in place of a reload, graded lower because the values are stale rather than empty and it takes two people.

Fix: revalidate on the path that opens the view. The dialog-open watcher already calls ensurePreviousMissionLoaded when cloud missions are active (MissionIdentifier.vue:265-269); either let that call through when a mission is linked to this cycle and the list does not hold it, or call refreshMissions() fire-and-forget from that watcher and let the write-through at :141 refresh the cache. Both leave the offline behaviour exactly as it is today, since a failed fetch changes nothing and the cache still answers.

2. Persistence & User Data — inventory, no findings

Every persisted key the PR touches, its backend, and what happened to it:

Key Backend This PR
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added
cockpit-blueos-cloud-linked-mission-cycle machine-local added
cockpit-blueos-cloud-mission-queue-v1 machine-local added
cockpit-blueos-cloud-linked-mission-v1 machine-local added this round (blueOsCloud.ts:114-120)
cockpit-last-mission-name machine-local removed, together with its watcher (stores/mission.ts, -61 and -187/-191)
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched, pre-existing
cockpit-mission-start-time machine-local untouched; now also read as the cycle stamp (useBlueOsCloudMission.ts:84)

Judged:

  • Backend is right on all four new keys. A cloud-mission link, its cycle stamp, the queue of work only this browser can replay, and a cached copy of the mission are all properties of one operator's session on one topside computer. None of them is vehicle-synced, so nothing here reaches another operator's machine or the vehicle. The only vehicle-synced key in the area, cockpit-blueos-cloud-enabled, is left alone.
  • Naming. All carry the cockpit- prefix. The queue and the new cache carry a -v1; the id and cycle keys do not. Cosmetic only — no reader depends on the suffix.
  • The cached mission repeats the linked id. cachedLinkedMission.id duplicates the value in cockpit-blueos-cloud-linked-mission-id, which the single-source-of-truth rule would normally flag. Here it is load-bearing rather than redundant: :148 compares the two and refuses a cache that no longer matches the link, which is what stops a stale copy from being shown for a different mission. src/tests/stores/blueOsCloud.test.ts:47-53 pins that. Keep it as it is.
  • No migration, and none needed. Every new key is new; the four are written before they are read and default to null/{}. cockpit-last-mission-name is simply abandoned in existing users' local storage — the non-destructive route, with no rewrite of anything the user owns. The feature it fed is stated as removed in the PR body, and the reset action that replaces it is in the same dialog.
  • Shape. The queue is keyed by client id with cloudId nullable, which is what lets an offline create coalesce with a later rename (mission-sync-queue.ts:127-145). The cache stores the API payload verbatim, so the read-only view and the edit form read the same fields they would read online. Staleness of that copy is a correctness matter, raised as 1.15 and 1.16 rather than here.
Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (this round adds no dependency — package.json is untouched — and no export without a call site: cachedLinkedMission and knownLinkedMission are internal to the store and consumed at blueOsCloud.ts:141 and :147; the two added comments explain why the cache exists rather than what it does)

4. Security — ✅ (the only hosts remain app.blueos.cloud in libs/blueos-cloud/api.ts:3-4 and the Auth0 domain in auth.ts:4; the new persisted key holds mission metadata the API already returned to this user, and no build script, workflow, Dockerfile or Electron main-process file is touched)

5. Performance — ✅ (the round's only new reactive work is the watcher at blueOsCloud.ts:141, traced above to list fetches, queue writes and link changes — never to a MAVLink or per-frame path — and it writes one small JSON blob per fire; the online listener still disposes at :461)

6. UI / UX — ✅ (no template changed this round; the logging gap 6.6 named is closed at useBlueOsCloudMissionStartupDialog.ts:83 and wired at BlueOsCloudMissionStartupHost.vue:9, with BlueOsCloudMissionPicker.vue:230 and BlueOsCloudMissionForm.vue:126 covered too, and all four entries read in the past tense naming their target)

7. Code Quality & Style — ✅ (complexity-report.json at this head measured 314 functions across all 20 changed files, triggered nothing and is not truncated, so no function the diff adds or changes crosses the thresholds; the round's new code adds no any and no scoped CSS)

8. Commit Hygiene — ✅ (the five commits in pr.json carry this round's fixes amended into the commits they belong to, so no wip/address review/fixup! commit was added and no subject references an issue or PR; the size question that was 8.1 is now resolved by a maintainer)

9. Tests — ✅ (the PR's three suites weaken nothing and mock only what they must — the settings syncer and localStorage in src/tests/stores/blueOsCloud.test.ts:9-24; the new store test asserts observable store state, not internals, so the 1.15 fix would not break it)

10. Documentation — ✅ (nothing this round differs between Lite and Standalone, so README.md needs no note; the new store members carry why-comments at blueOsCloud.ts:112-114 and :153-154, and every added interface member in the diff has a non-empty JSDoc summary)

11. Nitpicks / Optional — ✅ (re-checked the dismissal labels added this round and the read-only footer wording that 11.1 covered; nothing new at this level)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from e011773 to 80aab40 Compare August 20, 2026 12:45
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 6

Done

  • src/stores/blueOsCloud.ts (1.15 — offline edit hidden behind the stale fetched list): linkedMission now resolves a base record (fetched list, then cache) and overlays the defined fields of findPending(missionSyncQueue, missionRef) on top of it, instead of returning the first source that answers. So the read-only view and the edit-form prefill both show the queued edit, and a second edit no longer coalesces the pre-edit title back over the queued one. Overlaying rather than synthesising also keeps start_time, end_time and created_by for the offline-created case, which the old queue branch blanked. Coordinates go through a small patchedCoordinate helper so a patch that omits a coordinate keeps the base value while one that explicitly clears it (null) still clears it.
  • src/stores/blueOsCloud.ts (1.15, side effect of the above): the write-through to cockpit-blueos-cloud-linked-mission-v1 now watches fetchedLinkedMission — the list-sourced record only — rather than the old knownLinkedMission. The cache holds cloud-confirmed state, the queue holds the local edits, and the overlay recombines them after a reload. Nothing is lost: the queue is persisted too, so an offline-created mission still resolves with no list and no cache.
  • src/tests/stores/blueOsCloud.test.ts (1.15): new test — mission in the list, edit queued while offline, linkedMission.title is the edited one while description and start_latitude stay at the values the cloud returned. It fails without the overlay (the stale list entry answers).
  • src/composables/blueos-cloud/useBlueOsCloudMission.ts (1.16 — remembered details never revalidated): took the first of the two suggested routes. The guard is now "no link, or the list already holds it, or the link is unsynced" instead of "a mission is linked to this cycle", so the fetch behind it is reachable again while a mission is linked and the cached copy is checked against the cloud. Kept the missions.some(...) term deliberately: it is what preserves today's startup timing, since without it openIfEligible would await a doomed fetch on every offline start.

Done differently

  • useBlueOsCloudMission.ts, useBlueOsCloudMissionStartupDialog.ts, MissionIdentifier.vue (1.16): renamed ensurePreviousMissionLoaded to ensureLinkedMissionLoaded. Widening the guard is exactly what makes the old name wrong — it now fetches for the mission linked to the current cycle, which is the point of the fix — and its JSDoc would have been factually false. Flagging it because AGENTS.md forbids renames that were not asked for; this one is the function whose contract the fix changed, not a drive-by.
  • useBlueOsCloudMission.ts (1.16): the revalidation is once per session per link rather than once per dialog open. The !isLinkedMissionSynced term also skips the fetch when a local edit is already queued, since the overlay makes the queue authoritative there and the list can only answer with older values. A teammate's edit landing mid-session is therefore still not picked up until the next start; the case this closes is the one the cache introduced, where a stale copy outlived a restart and was PATCHed back.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 7)

1 open — 1 major — with 36 closed, 2 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Missions are created, picked and edited from the mission-name widget's dialog, and on startup a dialog asks what to do for this session. Because Cockpit normally runs with no internet, creates and edits are not sent directly: they go into a queue in the browser's local storage and are replayed when the browser reports it is online again, when the user signs in, when another edit is made, or on a 30-second retry. A mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name. Cockpit also keeps a local copy of the attached mission's details so it still knows what it is logging to after a restart.

Both findings left open at round 6 are fixed, and fixed as asked: a queued edit is now laid over whichever record answered for the mission, so the dialog and the edit form both show it, and the remembered copy is re-checked against the cloud when the dialog is opened and the list does not hold the mission.

What is open is the other half of the same bookkeeping. The one place that already holds the cloud's authoritative answer — the reply to a successful update — drops it when the mission list has not been fetched yet, which is the state every restart begins in. Since this round also narrowed the local copy to cloud-confirmed records only, a queued edit that flushes on startup leaves nothing behind: the panel goes back to showing the values from before the edit, and editing from there sends those back to the cloud.

What still needs attention

# Problem What it means Severity Status
1.17 A synced offline edit disappears from the mission panel and can be undone Rename your cloud mission with no internet, then restart Cockpit once back online: the mission panel shows the old name and details again, and editing from there sends the old ones back to the cloud. major
Since round 6 — 2 closed (both fixed), comparing e01177380aab40

The range. incremental.diff for e011773...80aab40 again lists all 20 files the PR touches, i.e. the entire branch, so it says nothing about what moved this round. pr.json shows why: all five commits carry new oids with the same five subjects and a single fresh commit date (2026-08-20T12:45:11Z), so the branch was amended and force-pushed, as it was before rounds 5 and 6. Every status below was therefore judged against pr.diff and the code at this head, not against the increment. Where a line number differs from round 6's by 6 (isLinkedMissionSynced :155:161, refreshMissions :244:250, updateLinkedMission :445:451), that is the six lines this round inserted above them in the store, not a move.

✅ 1.15 — Addressed (major). The finding asked for one specific change — stop picking a single source, resolve a base record and overlay the queue's defined fields on top of it — and that is what landed. linkedMission (src/stores/blueOsCloud.ts:138-155) now computes base = fetchedLinkedMission ?? cached and, when findPending returns an entry, returns the base with the patch's fields laid over it. Checked against each consequence the finding named:

  • The read-only view must stop contradicting the widget. The panel at MissionIdentifier.vue:40-84 reads linkedCloudMission (:234), which is that computed, so an offline rename is what it shows.
  • A second edit must stop discarding the first. openEditMissionForm (:377-385) prefills from the same computed, and updateLinkedMission (blueOsCloud.ts:451-460) carries input.name ?? linkedMission.value?.title, so the queued title is no longer overwritten with the pre-edit one.
  • Overlaying rather than synthesising must keep the fields a patch never carries. start_time, end_time and created_by are taken from the base (:143-145) instead of being blanked, and patchedCoordinate (:131-134) distinguishes a coordinate the patch omitted (keep the base) from one it explicitly cleared (null), which the old queue branch could not.

src/tests/stores/blueOsCloud.test.ts:70-81 pins it: with the mission in the list and a rename queued, title is the edited one while description and start_latitude stay at the values the cloud returned. Finding 1.17 below is a different case — the queue entry is gone and the list never received the server's reply — not a remnant of this one.

✅ 1.16 — Addressed (minor). The finding named two routes and the first one was taken verbatim: "let that call through when a mission is linked to this cycle and the list does not hold it". ensureLinkedMissionLoaded (src/composables/blueos-cloud/useBlueOsCloudMission.ts:97-108) now guards on "no link, or a local edit is still queued, or the list already holds it" (:101-102) instead of on hasMissionThisCycle, so the fetch behind it is reachable while a mission is linked. Its two callers still reach it — the dialog-open watcher (MissionIdentifier.vue:268) and openIfEligible (useBlueOsCloudMissionStartupDialog.ts:125) — and a successful fetch flows into the cache through the write-through at blueOsCloud.ts:127. The residual the author states in their comment (a teammate's edit mid-session is picked up on the next start, not on the next dialog open) is a direct consequence of the missions.some(...) term the finding itself asked for, so it is not held against this closure.

Resolutions. resolutions.json carries the same two entries as last round — 1.3 and 8.1, both by @rafaellehmkuhl — and both are already resolved in the carried ledger, applied in round 6. They are settled; nothing was re-applied and no id in the file is missing from the ledger. decisions.json is []: no dispute has ever been put to a vote on this PR, so there is no open vote and nothing to report either way.

Discussion since the last review. @rafaellehmkuhl posted a round-6 follow-up (comment) itemising the work. Every claim in it was checked against the code at this head; the two closures above rest on the code, not on the comment.

  • The claim that the new store test "fails without the overlay (the stale list entry answers)" holds: with missions holding the pre-edit record, the old list-first branch would have returned it and title would not be the edited one.
  • The "Done differently" entry declaring the ensurePreviousMissionLoadedensureLinkedMissionLoaded rename is accurate and the rename is confined to the three call sites; it is the function whose contract the fix changed, so it is not treated as a drive-by under the scope-discipline rule.
  • What the follow-up does not follow through is the other side of the write-through it narrowed: syncPendingMission never puts the server's reply into a list that is empty, which is finding 1.17 below.
  • The remaining comment in the window is a bare /review command and carries no content.

Nothing in the PR body, the diff, the commit messages or the comments contained text addressed to this reviewer, and nothing in any of them was treated as an instruction.

Change map — what was established before judging

Claims. From the PR body, each checked against the code:

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back." — verified as to the mechanism (enqueueCreate/enqueueUpdate, src/libs/blueos-cloud/mission-sync-queue.ts:111-145; replayed by flushMissionSyncQueue, stores/blueOsCloud.ts:375-396) and, as of this round, verified as to what the user sees while the edit is queued (linkedMission, :138-155). Contradicted for what the user sees just after it is pushed, when the push happened before any list fetch — see 1.17.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s." — verified: syncPendingMission (:321-346) treats a 404 on a mission with a known cloudId as a re-create through createAndReconcile (:283-296), and only a failure of that re-create spends an attempt.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes." — verified: openIfEligible (useBlueOsCloudMissionStartupDialog.ts:120-128) returns on hasMissionThisCycle.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu." — verified: isCloudActive = interfaceStore.pirateMode && cloudStore.isAuthenticated (useBlueOsCloudMission.ts:81), gating both the dialog branch (MissionIdentifier.vue:28) and the startup host.

Failure site. The PR is a feature and fixes no pre-existing bug, apart from replacing the restore-last-name control with a reset. This round's changes fix two findings this review raised, and both fixes are in the diff (blueOsCloud.ts:121-155, useBlueOsCloudMission.ts:97-108). The code that misbehaves for the finding raised now is also in the diff: the update branch of syncPendingMission (blueOsCloud.ts:340), which discards the record the server just returned whenever missions (:88) does not already contain it.

Entry points. The functions this round changed, plus the store surface the finding turns on. Every one reaches an entry point; none measured never.

Function Reached from Frequency
fetchedLinkedMission (blueOsCloud.ts:121) computed; read by linkedMission (:141) and the watcher at :127 per list fetch or link change
watch(fetchedLinkedMission) (:127) store-scope watcher per list fetch that resolves the link; one small localStorage write each
patchedCoordinate (:131) called twice by linkedMission (:150-151) per read of linkedMission
linkedMission (:138) MissionIdentifier.vue:234, useBlueOsCloudMission.ts:91, blueOsCloud.ts:456 per render of the mission dialog
isLinkedMissionSynced (:161) the status line at MissionIdentifier.vue:69-77, and the guard at useBlueOsCloudMission.ts:101 per render of the mission dialog / per dialog open
syncPendingMission (:321) the loop in flushMissionSyncQueue (:375) once per queued mission per flush
updateLinkedMission (:451) edit form submit → editLinkedMission (useBlueOsCloudMission.ts:144) per user action
flushMissionSyncQueue (:375) online listener (:466), persistSession (:193), create/edit, retry timer (:268), store construction (:468) per reconnection, per user action, per 30 s while the queue is non-empty, one-shot per app start
ensureLinkedMissionLoaded (useBlueOsCloudMission.ts:97) dialog-open watcher (MissionIdentifier.vue:268) and openIfEligible (useBlueOsCloudMissionStartupDialog.ts:125) per mission-dialog open; one-shot per app start
refreshMissions (blueOsCloud.ts:250) ensureLinkedMissionLoaded and the picker's open watcher (BlueOsCloudMissionPicker.vue:236) at most one request per session per link, plus one per picker open

Invariants.

  • What the dialog shows for the linked mission is the newest state Cockpit holds. Three sources can answer: the fetched list missions (:88, empty on every start and stale the moment an edit is queued), the sync queue (newest, but only for the fields a patch carried), and the cache (as new as the last list fetch that resolved the link). This round settles the precedence: base is list-then-cache, with the queue overlaid on top (:138-155). Sites that can violate it: refreshMissions (:250) replaces the list, covered; createAndReconcile (:293) upserts the created record, covered; clearSession (:176-186) clears link, list and cache together, covered; syncPendingMission (:340-341) removes the queue entry and drops the server's reply when the list is empty — not covered, and that is 1.17.
  • A cloud mission is linked to at most one mission cycle. Unchanged and held by the pair linkedMissionId + linkedMissionCycleId, written together in linkExistingMission (:425) and startCloudMission (:405); finishMission (:433), clearMissionCycleLink (:442) and clearSession (:176) only clear them.
  • Nothing the user was told was saved is lost while offline. Held for the upload: only a server refusal spends an attempt (isPermanentApiError, libs/blueos-cloud/api.ts:32-38), the queue survives clearSession by an explicit decision (:187), and a dropped entry is announced (:303-315). 1.17 does not break this invariant — the edit does reach the cloud — it breaks the display of it afterwards, and lets the next edit undo it.
1. Correctness & Implementation Bugs — 1 finding

1.17 — A queued edit that flushes before the mission list is fetched leaves no local trace, so the panel reverts to the pre-edit values and an Edit from there PATCHes them back (major) (new this round)

The update branch of syncPendingMission (src/stores/blueOsCloud.ts:321-346) folds the server's reply into the list with a map:

const updated = await updateMission(mission.cloudId, { … }, accessToken)
missions.value = missions.value.map((existing) => (existing.id === updated.id ? updated : existing))
missionSyncQueue.value = removePending(missionSyncQueue.value, mission.clientId)

map replaces and never inserts, so when missions does not already hold that mission the authoritative record the server just returned is dropped on the floor. The create path immediately above does not have this problem — createAndReconcile (:283-296) upserts: missions.value = [created, ...missions.value.filter((existing) => existing.id !== created.id)] (:293).

missions is a plain ref (:88), not persisted, so it is empty on every start until something fetches it, and the store flushes the queue from its own body on construction (:468). A restart with a queued edit and working internet is therefore exactly the case where the update is sent against an empty list.

That was survivable until this round, because the write-through to the cache followed a computed that fell back to the queue. It now follows fetchedLinkedMission (:127) — the list-sourced record only — which is the right call for what the cache is meant to be, but it means nothing else records the edit. After the flush the queue entry is gone (:341), the list is still empty, and linkedMission (:138-155) has only cachedLinkedMission to answer with — the copy written before the edit. So:

  • The panel shows values the cloud no longer has. MissionIdentifier.vue:40-84 renders the pre-edit name, description and location, with the status line reading "Synced with BlueOS Cloud" beside them, which is true of the mission and false of the fields above it.
  • An edit from that screen reverts the one that just synced. openEditMissionForm (:377-385) prefills from the same stale copy, so clicking "Edit mission" (:49) and saving queues a PATCH carrying the old title, description and coordinates — the 1.14/1.15 failure mode reached through a different door, and this time over a change that had already reached the cloud.

ensureLinkedMissionLoaded (useBlueOsCloudMission.ts:97-108) does repair the cache, but only partly and only sometimes. It is fired and forgotten from the dialog-open watcher (MissionIdentifier.vue:268), so the panel renders the stale values first and an Edit clicked before the fetch lands still captures them; and when the fetch fails — back offline in the field, which is this feature's normal condition — it repairs nothing at all and the stale copy stands for the rest of the session.

Fix: upsert in syncPendingMission the way createAndReconcile already does — replace the entry when the id is present, prepend it when it is not. One line, at the single site that holds the authoritative record, and it feeds the existing write-through at :127 so the cache refreshes with no second mechanism. That also makes the two flush branches consistent, which is worth having on its own.

2. Persistence & User Data — inventory, no findings

Every persisted key the PR touches, its backend, and what happened to it. No key was added, reshaped or removed this round; what changed is which records are written into one of them.

Key Backend This PR
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added
cockpit-blueos-cloud-linked-mission-cycle machine-local added
cockpit-blueos-cloud-mission-queue-v1 machine-local added
cockpit-blueos-cloud-linked-mission-v1 machine-local added (blueOsCloud.ts:114-120); this round its writer narrowed to cloud-confirmed records (:127)
cockpit-last-mission-name machine-local removed, together with its watcher (stores/mission.ts, -61 and -187/-191)
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched, pre-existing
cockpit-mission-start-time machine-local untouched; also read as the cycle stamp (useBlueOsCloudMission.ts:84)

Judged:

  • Backend is right on all four new keys. A cloud-mission link, its cycle stamp, the queue of work only this browser can replay, and a cached copy of the mission are properties of one operator's session on one topside computer. None is vehicle-synced, so nothing here reaches another operator's machine or the vehicle, and the only vehicle-synced key in the area is left alone.
  • Naming. All carry the cockpit- prefix. The queue and the cache carry a -v1, the id and cycle keys do not; cosmetic only, no reader depends on the suffix.
  • The narrowed writer is a sound choice for the key, and is where 1.17 bites. Storing only cloud-confirmed records keeps the cache honest about what the server has, and the overlay recombines it with the queue on read. The gap is not in the key or its shape but in the flush never producing a confirmed record to write when the list is empty — raised as 1.17 rather than here.
  • The cached mission repeats the linked id. cachedLinkedMission.id duplicates cockpit-blueos-cloud-linked-mission-id, which the single-source-of-truth rule would normally flag, but it is load-bearing: :141 refuses a cache that no longer matches the link, which is what stops a stale copy being shown for a different mission, and src/tests/stores/blueOsCloud.test.ts:59-68 pins that. Keep it.
  • No migration, and none needed. Every new key is new, written before it is read, defaulting to null/{}. cockpit-last-mission-name is abandoned in existing users' local storage rather than rewritten — the non-destructive route — and the reset action replacing that feature is in the same dialog.
  • Shape. The queue is keyed by client id with a nullable cloudId, which is what lets an offline create coalesce with a later rename (mission-sync-queue.ts:127-145); the cache stores the API payload verbatim, so the read-only view and the edit form read the same fields they would read online.
Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (package.json is untouched, so no dependency was added; the round's new store members are internal and consumed at blueOsCloud.ts:127, :141 and :150-151, so nothing is exported without a call site; the ensurePreviousMissionLoadedensureLinkedMissionLoaded rename is confined to the declaration and its three call sites and is the function whose contract the 1.16 fix changed, so it is not the unrequested rename scope discipline forbids)

4. Security — ✅ (the only hosts remain app.blueos.cloud in libs/blueos-cloud/api.ts:3-4 and the Auth0 domain in auth.ts:4; this round adds no network call, no encoded blob and no eval-family construct, and touches no build script, workflow, Dockerfile or Electron main-process file)

5. Performance — ✅ (the round's new reactive work is fetchedLinkedMission and its watcher, traced above to list fetches and link changes rather than to any MAVLink or per-frame path, plus linkedMission's object rebuild on the dialog render path; the widened ensureLinkedMissionLoaded still short-circuits on missions.some(...) at useBlueOsCloudMission.ts:102, so it costs at most one request per session per link, and the online listener still disposes at blueOsCloud.ts:467)

6. UI / UX — ✅ (this round's changes are confined to <script setup> and the store — the renamed identifier appears in no template — and re-checking the surfaces at head, the read-only panel MissionIdentifier.vue:40-84, its footer at :94-109 and the three dialogs still carry their close X, centred titles, theme="dark" on every teleporting control and one glass layer per surface, with logUserAction on every discrete action including the three dismissal paths)

7. Code Quality & Style — ✅ (complexity-report.json measured 317 functions across all 20 changed files and is not truncated; it reports one trigger, new-function-above-12 on linkedMission at src/stores/blueOsCloud.ts:138 at complexity 15, with depth 1 — the count comes from a flat precedence table of ?? defaults in a single object literal, with no nesting, no interleaved concerns, no reassigned locals and no mode parameter, so the trigger is answered and no restructuring is worth naming; the round's new code adds no any and no scoped CSS)

8. Commit Hygiene — ✅ (the five commits in pr.json carry this round's fixes amended into the commits they belong to, so no wip/address review/fixup! commit was added; every subject is scope-prefixed in the tree's dominant style and none references an issue or PR, the Closes #2711 correctly living in the PR body; the commit-size question that was 8.1 is resolved by a maintainer)

9. Tests — ✅ (the PR's three suites weaken nothing and mock only what they must — the settings syncer and localStorage at src/tests/stores/blueOsCloud.test.ts:9-24; the new fourth case at :70-81 asserts observable store state rather than internals, and the 1.17 fix would not break it since it asserts the pre-flush overlay)

10. Documentation — ✅ (nothing this round differs between Lite and Standalone, so README.md needs no note; the renamed ensureLinkedMissionLoaded carries an updated non-empty JSDoc summary at useBlueOsCloudMission.ts:47-49, the new store members carry why-comments at blueOsCloud.ts:112-114 and :136-137, and patchedCoordinate is a self-describing private helper that AGENTS.md exempts)

11. Nitpicks / Optional — ✅ (re-checked the wording of the read-only footer and the four dismissal log entries against the past-tense house voice; nothing new at this level)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from 80aab40 to a87eaef Compare August 20, 2026 13:01
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 7

Done

  • src/stores/blueOsCloud.ts (1.17 — flushed edit leaves no local trace when the list is empty): the update branch of syncPendingMission now upserts the server's reply instead of map-ing over the list, so a flush on a start with no list fetched keeps the authoritative record and feeds the cache write-through at :127. Extracted the upsert createAndReconcile already did into upsertMission and used it from both flush branches, so they no longer diverge.
  • src/tests/stores/blueOsCloud.test.ts: new case queues a rename, reloads the store (empty list, queue restored from local storage), flushes with updateMission stubbed, and asserts linkedMission equals the edited record. It fails on the old map (the panel answers with the pre-edit cache).

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 8)

1 open — 1 minor — with 37 closed, 1 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Missions are created, picked and edited from the mission-name widget's dialog, and on startup a dialog asks what to do for this session. Because Cockpit normally runs with no internet, creates and edits are not sent directly: they go into a queue in the browser's local storage and are replayed when the browser reports it is online again, when the user signs in, when another edit is made, or on a 30-second retry. A mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name. Cockpit also keeps a local copy of the attached mission's details so it still knows what it is logging to after a restart.

The one finding left open at round 7 is fixed, and fixed as asked: the reply to a successful update is now folded into the mission list whether or not the list already held that mission, through an upsert helper both flush branches share. With that, the display bookkeeping this review has been working through over the last four rounds is consistent from every direction I can reach it.

What is open is one level below that: the queue can be written to while a flush is in flight, and the flush finishes by deleting the entry it started with, so an edit saved during the upload of the previous one is dropped. It is a narrow window and the first genuinely new area this review has raised in three rounds — it is not new to this push, and earlier rounds of this review missed it.

What still needs attention

# Problem What it means Severity Status
1.18 An edit saved while the previous one is uploading is silently discarded Save a change to your cloud mission at the moment an earlier change is being sent, and the second change disappears without any warning — the mission keeps the older text and location. minor
Since round 7 — 1 closed (fixed), 1 new, comparing 80aab40a87eaef

The range. incremental.diff for 80aab40...a87eaef is not a diff at all this round — it is a per-file summary block (=== src/… (modified, +N/-M) ===) listing all 20 files the PR touches, i.e. the entire branch, so it says nothing about what moved since round 7. pr.json shows why: all five commits carry new oids with the same five subjects and a single fresh commit date (2026-08-20T13:00:59Z), so the branch was amended and force-pushed, as it was before rounds 5, 6 and 7. Every status below was therefore judged against pr.diff and the code at this head, not against the increment. Line numbers in the store below blueOsCloud.ts:278 are six higher than round 7's (createAndReconcile :283:289, syncPendingMission :321:327, flushMissionSyncQueue :375:381); that is the six lines upsertMission inserted above them, not a move. One correction to round 7's own numbering while I am here: persistSession is at :197, not :193 — round 7 cited a line of its JSDoc body.

✅ 1.17 — Addressed (major). The finding asked for one change and named three things it should produce; all three are there.

  • "Upsert in syncPendingMission the way createAndReconcile already does — replace the entry when the id is present, prepend it when it is not." The map is gone. upsertMission (src/stores/blueOsCloud.ts:278-280) is missions.value = [mission, ...missions.value.filter((existing) => existing.id !== mission.id)], and the update branch calls it with the server's reply at :346, one line before the queue entry is dropped at :347.
  • "It feeds the existing write-through at :127 so the cache refreshes with no second mechanism." It does, and I traced it rather than taking it: with the reply in missions, fetchedLinkedMission (:121-125) resolves for the linked id, the watcher at :127-129 fires and writes cachedLinkedMission, and linkedMission (:138-155) then answers with the post-edit record from either source. No new persistence path was added for this.
  • "That also makes the two flush branches consistent." createAndReconcile (:289-302) now calls the same helper at :299 instead of open-coding the spread, so there is one upsert in the file rather than two spellings of it.

src/tests/stores/blueOsCloud.test.ts:89-106 pins the reported scenario end to end: link a mission, queue a rename, reload the store so missions is empty and only the queue and the cache survive, authenticate, flush with updateMission stubbed, then assert linkedMission equals the edited record. On the old map that assertion fails — the list stays empty, fetchedLinkedMission is null, and linkedMission falls back to the pre-edit cache — so the test does discriminate, as the author claims.

❌ 1.18 — New this round (minor). Not new to this push: the compare-free removePending at the end of a flush has been there since the queue was introduced, and rounds 1 through 7 of this review did not look at what happens when the queue is written to mid-flush. Raised now, written out in section 1.

Resolutions. resolutions.json carries the same two entries as the last two rounds — 1.3 and 8.1, both by @rafaellehmkuhl — and both are already resolved in the carried ledger, applied in round 6. They are settled; nothing was re-applied, and every id in the file is present in the ledger. decisions.json is []: no dispute has ever been put to a vote on this PR, so there is no open vote and nothing to report either way.

Discussion since the last review. @rafaellehmkuhl posted a round-7 follow-up (comment) describing the upsert and the new test. Both claims were checked against the code and the test body rather than accepted: the upsert is at blueOsCloud.ts:346 and shared with the create branch at :299, and the new case at blueOsCloud.test.ts:89-106 does fail on the old map, for the reason given above. The only other comment in the window is a bare /review command and carries no content.

Nothing in the PR body, the diff, the commit messages or the comments contained text addressed to this reviewer, and nothing in any of them was treated as an instruction.

Change map — what was established before judging

Claims. From the PR body, each checked against the code:

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back." — verified as to the mechanism (enqueueCreate/enqueueUpdate, src/libs/blueos-cloud/mission-sync-queue.ts:110-144; replayed by flushMissionSyncQueue, stores/blueOsCloud.ts:381-402), as to what the user sees while the edit is queued (linkedMission, :138-155), and as of this round as to what they see just after it is pushed (:346). Contradicted for an edit saved while the previous one is still in flight — see 1.18.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s." — verified: syncPendingMission (:327-373) treats a 404 on a mission with a known cloudId as a re-create through createAndReconcile (:289-302), and only a failure of that re-create spends an attempt.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes." — verified: openIfEligible (useBlueOsCloudMissionStartupDialog.ts:120-128) returns on hasMissionThisCycle.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu." — verified: isCloudActive = interfaceStore.pirateMode && cloudStore.isAuthenticated (useBlueOsCloudMission.ts:81), gating both the dialog branch (MissionIdentifier.vue:28) and the startup host.

Failure site. The PR is a feature and fixes no pre-existing bug, apart from replacing the restore-last-name control with a reset. This round's only change is the fix for finding 1.17, and it is in the diff (blueOsCloud.ts:278-280, :299, :346). The code that misbehaves for the finding raised now is also in the diff, and it is two statements in two functions: missionSyncQueue.value = removePending(missionSyncQueue.value, mission.clientId) at :347 and :301, both of which delete by key after an await without checking whether the entry under that key is still the one the request was built from.

Entry points. The functions this round changed, plus the ones the new finding turns on. Every one reaches an entry point; none measured never.

Function Reached from Frequency
upsertMission (blueOsCloud.ts:278) createAndReconcile (:299) and syncPendingMission (:346) once per queued mission per successful flush
createAndReconcile (:289) syncPendingMission (:333, :356) once per pending create, plus once per 404 re-create
syncPendingMission (:327) the loop in flushMissionSyncQueue (:387) once per queued mission per flush
flushMissionSyncQueue (:381) online listener (:472), persistSession (:200), create/edit (:467), retry timer (:268), store construction (:474) per reconnection, per user action, per 30 s while the queue is non-empty, one-shot per app start
updateLinkedMission (:457) edit form submit → editLinkedMission (useBlueOsCloudMission.ts:144) per user action
enqueueUpdate (mission-sync-queue.ts:131) updateLinkedMission (blueOsCloud.ts:460) per user action
removePending (mission-sync-queue.ts:152) createAndReconcile (:301), syncPendingMission (:347) once per queued mission per successful flush
fetchedLinkedMission (:121) + its watcher (:127) computed; read by linkedMission (:141) per list change — which now includes every successful flush
linkedMission (:138) MissionIdentifier.vue:234, useBlueOsCloudMission.ts:91, blueOsCloud.ts:462 per render of the mission dialog

Invariants.

  • What the dialog shows for the linked mission is the newest state Cockpit holds. Three sources can answer: the fetched list missions (:88), the sync queue (newest, but only for the fields a patch carried), and the cache (as new as the last list change that resolved the link). Precedence is list-then-cache with the queue overlaid (:138-155). Every site that can violate it: refreshMissions (:250) replaces the list, covered; createAndReconcile (:299) upserts, covered; syncPendingMission (:346) upserts as of this round, covered — this was 1.17; clearSession (:176-188) clears link, list and cache together, covered. Held from every direction I can reach.
  • Nothing the user was told was saved is lost. The upload side holds: only a server refusal spends an attempt (isPermanentApiError, libs/blueos-cloud/api.ts:34), the queue survives clearSession by an explicit decision (:187), and a dropped entry is announced (:309-321). The site that breaks it is the deletion at :347/:301, which is unconditional: the only writer that can race it is enqueueUpdate through updateLinkedMission (:460), and nothing between them coordinates. Not covered, and that is 1.18.
  • A cloud mission is linked to at most one mission cycle. Unchanged and held by the pair linkedMissionId + linkedMissionCycleId, written together in linkExistingMission (:431) and startCloudMission (:411); finishMission (:439), clearMissionCycleLink (:448) and clearSession (:176) only clear them.
1. Correctness & Implementation Bugs — 1 finding

1.18 — A mission edit saved while the previous one is being uploaded is deleted from the queue without ever being sent (minor) (new this round)

Both flush branches end the same way — push, then delete the queue entry by key:

const updated = await updateMission(mission.cloudId, { … }, accessToken)
upsertMission(updated)
missionSyncQueue.value = removePending(missionSyncQueue.value, mission.clientId)

(src/stores/blueOsCloud.ts:336-347; createAndReconcile does the same at :299-301.) mission is the entry as it was when the request was built, captured from the snapshot pendingMissions(missionSyncQueue.value) that flushMissionSyncQueue takes once at :387. removePending (src/libs/blueos-cloud/mission-sync-queue.ts:152-157) deletes whatever is under that key now. Nothing checks that the two are the same entry.

They are not, if the user saves an edit while the request is in flight. updateLinkedMission (:457-465) is reachable from the edit form at any moment — isFlushingQueue (:266) guards only against a second concurrent flush, not against a write to the queue — and it calls enqueueUpdate (mission-sync-queue.ts:131-144), which finds the in-flight entry still sitting in the queue and merges the new fields into it under the same key (base.clientId). When the earlier request resolves, :347 deletes that merged entry. The second edit is gone: never sent, not in the queue, and not in the list, because upsertMission has just written the server's reply to the first edit over the top of it.

The window is the duration of one HTTP request, which is where this stops being theoretical: this feature exists for a boat with intermittent LTE, the flush fires on the online event (:472) and on a 30-second timer (:268) rather than on anything the user did, and a PATCH on a marginal link is seconds long. What the user sees is an edit they were told had been saved — editLinkedMission notifies Mission "…" updated. (useBlueOsCloudMission.ts:144-152) — quietly reverting to the previous values in the panel a moment later, with the status line reading "Synced with BlueOS Cloud" over it. There is no error and nothing in the log.

The create branch has the same shape with a worse tail: the merged entry there still carries cloudId: null, so simply not deleting it would make the next flush create the mission on the cloud a second time. Whatever the fix, it has to converge both branches on the same rule.

Fix: make the deletion conditional on the entry being unchanged, and give the queue the one piece of data that lets it say so — a revision on PendingCloudMission, bumped by enqueueCreate and enqueueUpdate. After a successful push, delete when the stored entry's revision matches the one that was sent, and otherwise keep it, rewriting cloudId to the id the server now has (created.id or mission.cloudId) and leaving attempts at 0, so a create that was edited mid-flight flushes next as an update rather than as a second create. That is one field and one comparison, at the two sites that already own the deletion, and it makes the loop's stale snapshot at :387 harmless for the same reason.

2. Persistence & User Data — inventory, no findings

Every persisted key the PR touches, its backend, and what happened to it. No key was added, reshaped or removed this round; what changed is that one of them now gets written in a case where it previously did not.

Key Backend This PR
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added
cockpit-blueos-cloud-linked-mission-cycle machine-local added
cockpit-blueos-cloud-mission-queue-v1 machine-local added
cockpit-blueos-cloud-linked-mission-v1 machine-local added (blueOsCloud.ts:114-119); its writer (:127) now also fires after a flush, which is the 1.17 fix
cockpit-last-mission-name machine-local removed, together with its watcher (stores/mission.ts, -61 and -187/-191)
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched, pre-existing
cockpit-mission-start-time machine-local untouched; also read as the cycle stamp (useBlueOsCloudMission.ts:84)

Judged:

  • Backend is right on all four new keys. A cloud-mission link, its cycle stamp, the queue of work only this browser can replay, and a cached copy of the mission are properties of one operator's session on one topside computer. None is vehicle-synced, so nothing here reaches another operator's machine or the vehicle, and the only vehicle-synced key in the area is left alone.
  • Naming. All carry the cockpit- prefix. The queue and the cache carry a -v1, the id and cycle keys do not; cosmetic only, no reader depends on the suffix.
  • The cache's writer is now fed from every path that produces a cloud-confirmed record. It stays narrowed to records the server returned — the honest thing for a key that claims to say what the cloud holds — and this round closed the one path that produced such a record and threw it away. Nothing else writes it.
  • The cached mission repeats the linked id. cachedLinkedMission.id duplicates cockpit-blueos-cloud-linked-mission-id, which the single-source-of-truth rule would normally flag, but it is load-bearing: :141 refuses a cache that no longer matches the link, which is what stops a stale copy being shown for a different mission, and src/tests/stores/blueOsCloud.test.ts:65-74 pins that. Keep it.
  • 1.18 is not a persistence finding. The queue's shape and backend are right; what is missing is a comparison at the two call sites that delete from it, so it is raised in section 1 rather than here.
  • No migration, and none needed. Every new key is new, written before it is read, defaulting to null/{}. cockpit-last-mission-name is abandoned in existing users' local storage rather than rewritten — the non-destructive route — and the reset action replacing that feature is in the same dialog.
  • Shape. The queue is keyed by client id with a nullable cloudId, which is what lets an offline create coalesce with a later rename (mission-sync-queue.ts:131-144); the cache stores the API payload verbatim, so the read-only view and the edit form read the same fields they would read online.
Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (package.json is untouched, so no dependency was added; this round's only new symbol, upsertMission (blueOsCloud.ts:278), is private to the store and has two call sites in this same PR (:299, :346), so nothing is exported without a caller and nothing is groundwork; it is a de-duplication of an existing spread rather than a new abstraction, which is the direction the minimalism ladder asks for)

4. Security — ✅ (the only hosts remain app.blueos.cloud in libs/blueos-cloud/api.ts:3-4 and the Auth0 domain in auth.ts:4; this round adds no network call, no encoded blob and no eval-family construct, and touches no build script, workflow, Dockerfile or Electron main-process file)

5. Performance — ✅ (the round's only new work is one array rebuild per successful flush, traced above to syncPendingMission rather than to any MAVLink or per-frame path; it does newly resolve fetchedLinkedMission after a flush, which costs one localStorage write through the watcher at :127 and, by satisfying missions.some(...) at useBlueOsCloudMission.ts:102, saves the list fetch ensureLinkedMissionLoaded would otherwise have made on the next dialog open; the online listener still disposes at blueOsCloud.ts:473)

6. UI / UX — ✅ (this round's change is confined to the store — no template or <script setup> block moved — and re-checking the surfaces at head, the read-only panel MissionIdentifier.vue:40-84, its footer at :94-109 and the three dialogs still carry their close X, centred titles, theme="dark" on every teleporting control and one glass layer per surface, with logUserAction on every discrete action including the three dismissal paths)

7. Code Quality & Style — ✅ (complexity-report.json is for this head, measured 319 functions across all 20 changed files and is not truncated; it reports one trigger, new-function-above-12 on linkedMission at src/stores/blueOsCloud.ts:138 at complexity 15 with depth 1 — the count comes from a flat precedence table of ?? defaults in a single object literal, with no nesting, no interleaved concerns, no reassigned locals and no mode parameter, so the trigger is answered and no restructuring is worth naming; upsertMission is absent from the report, as a three-line arrow that removed a duplicated spread would be, and adds no any and no scoped CSS)

8. Commit Hygiene — ✅ (the five commits in pr.json carry this round's fix amended into the commit it belongs to, so no wip/address review/fixup! commit was added; every subject is scope-prefixed in the tree's dominant style and none references an issue or PR, the Closes #2711 correctly living in the PR body; the commit-size question that was 8.1 is resolved by a maintainer)

9. Tests — ✅ (the PR's three suites weaken nothing and mock only what they must — the settings syncer, the API module and localStorage at src/tests/stores/blueOsCloud.test.ts:10-28; the case added this round, at :89-106, drives the store through a reload and a flush and asserts on linkedMission, i.e. observable store state rather than internals, and the other four cases in that file are unchanged from round 7)

10. Documentation — ✅ (nothing this round differs between Lite and Standalone, so README.md needs no note; upsertMission carries a why-comment naming what it is for (blueOsCloud.ts:276-277) rather than restating the spread, and the JSDoc on createAndReconcile (:282-288) and syncPendingMission (:322-326) still describes what those functions do after the extraction)

11. Nitpicks / Optional — ✅ (re-read the new helper's name and comment against the surrounding vocabulary — upsertMission matches removePending/findPending in the queue module it mirrors; nothing new at this level)

Generated by Claude. This is advisory; a human reviewer must still approve.

@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from a87eaef to 0972b3d Compare August 20, 2026 13:21
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 8

Done

  • src/libs/blueos-cloud/mission-sync-queue.ts, src/stores/blueOsCloud.ts (1.18 — an edit saved while the previous one is uploading is silently discarded): PendingCloudMission now carries a revision, bumped by enqueueCreate/enqueueUpdate. The unconditional removePending at the end of both flush branches is replaced by settlePending, which deletes only when the stored entry's revision matches the one that was pushed; otherwise it keeps the entry and rewrites its cloudId to the id the server now has (created.id or mission.cloudId), so a create edited mid-flight flushes next as an update rather than as a second create. removePending is no longer exported — settlePending and registerFailedAttempt are its only callers.
  • src/tests/libs/blueos-cloud/mission-sync-queue.test.ts (1.18): new case pins both directions — an entry edited mid-flight is kept with the edited title and the new cloudId, and an untouched entry is still deleted. On the old unconditional delete the first assertion reads undefined.

Done differently

  • src/stores/blueOsCloud.ts (1.18 — retry for the kept entry): not asked for, but keeping the entry alone would have turned "silently dropped" into "silently stuck". The running flush walks the snapshot it took at flushMissionSyncQueue, and the void flushMissionSyncQueue() that updateLinkedMission fires no-ops against isFlushingQueue, so nothing would have retried the kept entry until an online event or an app restart. Both call sites go through a store-local settleSyncedMission, which schedules the existing 30 s retry when the entry survived.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
📝 MINOR SUGGESTIONS (Automated PR Review — round 9)

2 open — 2 minor — with 38 closed, 1 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Missions are created, picked and edited from the mission-name widget's dialog, and on startup a dialog asks what to do for this session. Because Cockpit normally runs with no internet, creates and edits are not sent directly: they go into a queue in the browser's local storage and are replayed when the browser reports it is online again, when the user signs in, when another edit is made, or on a 30-second retry. A mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name. Cockpit also keeps a local copy of the attached mission's details so it still knows what it is logging to after a restart.

The one finding left open at round 8 is fixed, and fixed as asked: the queue entry now carries a revision, and the flush deletes it only when the revision it pushed is the one still stored, so an edit saved during an upload survives instead of being deleted unsent. That closes the last of the write-path bookkeeping this review has worked through.

Both findings raised this round are on the same offline-replay path and neither is new to this push — they are things rounds 1 through 8 did not look at. One is a wrong value: a mission created offshore is stamped with the time it finally uploaded, not the time it started, and Cockpit's own mission list sorts and displays that. The other is a missing distinction: when the cloud permanently refuses a queued edit, the session is unlinked from a mission that exists and is perfectly usable, as though the mission itself had never been uploaded.

What still needs attention

# Problem What it means Severity Status
1.19 A mission created offline gets the time it was uploaded as its start time Create a mission at sea and it appears on BlueOS Cloud dated to whenever you got signal back — possibly hours later, or the next day — so it sorts and reads as if the dive happened then. minor
1.20 A rejected edit unlinks the session from a mission that is fine If the cloud refuses one of your edits often enough, Cockpit stops logging to that mission entirely and tells you the mission was rejected, when only the edit was. minor
Since round 8 — 1 closed (fixed), 2 new, comparing a87eaef0972b3d

The range. incremental.diff for a87eaef...0972b3d is again not a diff: it is a per-file summary block (=== src/… (modified, +N/-M) ===) covering all 20 files the PR touches, i.e. the whole branch, so it says nothing about what moved since round 8. pr.json shows why — all five commits carry new oids under the same five subjects and a single fresh commit date (2026-08-20T13:21:22Z), so the branch was amended and force-pushed, as before rounds 5, 6, 7 and 8. Every status below is therefore judged against pr.diff and the code at this head, not against the increment, and the new findings come from pr.diff as they always must.

Line numbers in src/stores/blueOsCloud.ts below :281 are seven higher than round 8's, which is the seven lines settleSyncedMission inserted above them, not a move: createAndReconcile :289:296, announceDroppedMission :309:316, syncPendingMission :327:334, flushMissionSyncQueue :381:388, updateLinkedMission :457:464. In src/libs/blueos-cloud/mission-sync-queue.ts, enqueueUpdate is at :136 and removePending at :158. Nothing above blueOsCloud.ts:280 moved, so linkedMission is still :138-155 and the complexity report agrees.

✅ 1.18 — Addressed (minor). The finding named four things the fix had to produce, and all four are there.

  • "Make the deletion conditional on the entry being unchanged." settlePending (mission-sync-queue.ts:174-182) reads the entry that is under the key now and compares: if (!stored || stored.revision === pushed.revision) return removePending(queue, pushed.clientId). The unconditional removePending is gone from both flush branches, and removePending itself is no longer exported (:158) — its only callers are settlePending (:180) and registerFailedAttempt (:200), which is the right shape for a primitive that must not be reachable without the comparison.
  • "A revision on PendingCloudMission, bumped by enqueueCreate and enqueueUpdate." Field at :43, initialised to 0 for a fresh create at :124, bumped at :147 (revision: base.revision + 1) on every coalesced update.
  • "Otherwise keep it, rewriting cloudId to the id the server now has." :181 returns { ...stored, cloudId }, and the two call sites pass exactly the ids the finding named: created.id from createAndReconcile (blueOsCloud.ts:308) and mission.cloudId from the update branch (:354).
  • "Leaving attempts at 0, so a create that was edited mid-flight flushes next as an update rather than as a second create." ...stored carries the attempts: 0 that enqueueUpdate wrote at mission-sync-queue.ts:101, and because the kept entry now has a non-null cloudId, the next pass through syncPendingMission takes the update branch at :341 rather than the create branch. Both branches converge on one rule through settleSyncedMission (:284-287), so there is one settle in the store rather than two spellings of a delete.

I traced the create-edited-mid-flight case end to end rather than taking it: createAndReconcile relinks linkedMissionId to created.id at :307 and settles at :308 with no await between them, so no edit can interleave; the kept entry keeps its uuid key but now carries cloudId: created.id, which findPending (mission-sync-queue.ts:105) matches on, so a further edit still coalesces into it and linkedMission (:138-155) still overlays it.

src/tests/libs/blueos-cloud/mission-sync-queue.test.ts:42-52 pins both directions — an entry edited mid-flight keeps the edited title and gains the new cloudId, and an untouched entry is still deleted (:51). On the old unconditional delete the first assertion reads a property of undefined, so the test does discriminate, as the author claims.

The retry the author added beyond the ask is sound and needed: settleSyncedMission schedules the existing 30 s timer when the entry survived (:286), which is the only thing that would ever pick the kept entry up, since the running flush walks the snapshot it took at :394 and the void flushMissionSyncQueue() fired by updateLinkedMission (:474) no-ops against isFlushingQueue (:266). Without it the finding would have turned from "silently dropped" into "silently stuck".

❌ 1.19 — New this round (minor). Not new to this push: createMission has stamped start_time at request time since the API module was added. Written out in section 1.

❌ 1.20 — New this round (minor). Also not new to this push: announceDroppedMission has cleared the link for every dropped entry since it was introduced in round 2's fix for 1.2. Written out in section 1.

Resolutions. resolutions.json carries the same two entries as the last three rounds — 1.3 and 8.1, both by @rafaellehmkuhl — and both are already resolved in the carried ledger, applied in round 6. They are settled; nothing was re-applied, and every id in the file is present in the ledger. decisions.json is []: no dispute has ever been put to a vote on this PR, so there is no open vote and nothing to report either way.

Discussion since the last review. @rafaellehmkuhl posted a round-8 follow-up (comment) describing the revision/settlePending change, the unexporting of removePending, the new test, and the retry added on top. Every claim in it was checked against the code and the test body rather than accepted — the citations above are the result, and all of them hold. One wording correction, of no consequence: enqueueCreate initialises revision to 0 rather than bumping it, which is correct for an entry that did not exist. The only other comment in the window is a bare /review command and carries no content.

Nothing in the PR body, the diff, the commit messages or the comments contained text addressed to this reviewer, and nothing in any of them was treated as an instruction.

Change map — what was established before judging

Claims. From the PR body, each checked against the code:

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back." — verified as to the mechanism (enqueueCreate/enqueueUpdate, src/libs/blueos-cloud/mission-sync-queue.ts:114-150; replayed by flushMissionSyncQueue, src/stores/blueOsCloud.ts:388-409), as to what the user sees while queued (linkedMission, :138-155), as to what they see just after the push (:353), and as of this round as to an edit saved during a push (settlePending, mission-sync-queue.ts:174-182). Contradicted in one respect: what is pushed for an offline create is not what was queued — the start time is generated at push time (src/libs/blueos-cloud/api.ts:126), so the record that lands on the cloud describes the upload rather than the mission. That is 1.19.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s." — verified: syncPendingMission (:334-380) treats a 404 on a mission with a known cloudId as a re-create through createAndReconcile (:296-309), and only a failure of that re-create spends an attempt (:374-375).
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes." — verified: openIfEligible (src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts:120-128) returns on hasMissionThisCycle, and again on any of the three surfaces already being open.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu." — verified: isCloudActive = interfaceStore.pirateMode && cloudStore.isAuthenticated (src/composables/blueos-cloud/useBlueOsCloudMission.ts:81), gating both the dialog branch (MissionIdentifier.vue:28) and the startup host.

Failure site. The PR is a feature and fixes no pre-existing bug, apart from replacing the restore-last-name control with a reset. This round's only change is the fix for 1.17's successor 1.18, and it is in the diff (mission-sync-queue.ts:43, :124, :147, :158, :174-182; blueOsCloud.ts:284-287, :308, :354). The code the two new findings turn on is also in the diff and is a single statement each: start_time: new Date().toISOString() (api.ts:126), evaluated when the request is built rather than when the mission began, and if (linkedMissionId.value === mission.clientId) linkedMissionId.value = null (blueOsCloud.ts:317), which does not distinguish the two kinds of entry that reach it.

Entry points. The functions this round changed, plus the ones the new findings turn on. Every one reaches an entry point; none measured never.

Function Reached from Frequency
settlePending (mission-sync-queue.ts:174) settleSyncedMission (blueOsCloud.ts:285) once per queued mission per successful push
settleSyncedMission (blueOsCloud.ts:284) createAndReconcile (:308), syncPendingMission (:354) once per queued mission per successful push
removePending (mission-sync-queue.ts:158) settlePending (:180), registerFailedAttempt (:200) — no longer exported once per settled or abandoned entry
enqueueCreate (:114) startCloudMission (blueOsCloud.ts:420) → create form submit (useBlueOsCloudMission.ts:137) per user action
enqueueUpdate (:136) updateLinkedMission (blueOsCloud.ts:467) → edit form submit (useBlueOsCloudMission.ts:150) per user action
createAndReconcile (blueOsCloud.ts:296) syncPendingMission (:341, :363) once per pending create, plus once per 404 re-create
createMission (api.ts:103) createAndReconcile (:297) once per pending create — at flush time, not at create time
syncPendingMission (:334) the loop in flushMissionSyncQueue (:394) once per queued mission per flush
flushMissionSyncQueue (:388) online listener (:479), persistSession (:200), create/edit (:429, :474), retry timer (:268), store construction (:481) per reconnection, per user action, per 30 s while the queue is non-empty, one-shot per app start
registerFailedAttempt (mission-sync-queue.ts:192) syncPendingMission (:375) once per permanent refusal
announceDroppedMission (blueOsCloud.ts:316) flushMissionSyncQueue (:397) once per entry that exhausts its five attempts
startCloudMission (:418) createMission in the composable (useBlueOsCloudMission.ts:137), from the create form and the startup host per user action
linkedMission (:138) MissionIdentifier.vue:234, useBlueOsCloudMission.ts:91, blueOsCloud.ts:469 per render of the mission dialog

Invariants.

  • Nothing the user was told was saved is lost. Only a server refusal spends an attempt (isPermanentApiError, api.ts:34), the queue survives clearSession by an explicit decision (:187), a dropped entry is announced (:316-325), and as of this round the deletion after a push is conditional on the entry being the one that was pushed (mission-sync-queue.ts:180), with the survivor scheduled for retry (blueOsCloud.ts:286). Held from every direction I can reach, which is what closes 1.18.
  • What the dialog shows for the linked mission is the newest state Cockpit holds. Three sources answer — the fetched list, the queue, the cache — with precedence list-then-cache and the queue overlaid (:138-155). Every site that can violate it: refreshMissions (:250), createAndReconcile (:306), syncPendingMission (:353), clearSession (:176-188). All covered; unchanged since round 8.
  • A record Cockpit creates on the cloud describes the mission, not the upload. The only site that can violate it is createMission (api.ts:124-126), which composes the body at request time, and it does violate it for start_time. The value that would hold the invariant is already computed at the call site — currentCycleId (useBlueOsCloudMission.ts:84) is the mission's own start epoch — and is passed into startCloudMission (:139) for the cycle stamp and then not carried into the queue entry. Not covered, and that is 1.19.
  • Unlinking the session means the mission it pointed at does not exist. Written by announceDroppedMission (:317), and by finishMission (:446) and clearSession (:176), which clear a link the user asked to end. announceDroppedMission is the one that infers non-existence rather than being told it, and it infers it for every dropped entry, including one whose cloudId names a mission the cloud is serving. Not covered, and that is 1.20.
1. Correctness & Implementation Bugs — 2 findings

1.19 — A mission created offline is stamped with the time it was uploaded, not the time it started (minor) (new this round)

createMission builds the request body when the request is made:

const body: Record<string, unknown> = {
  title: input.name,
  start_time: new Date().toISOString(),
}

(src/libs/blueos-cloud/api.ts:124-126.) Online that is harmless — startCloudMission fires void flushMissionSyncQueue() at src/stores/blueOsCloud.ts:429 and the POST goes out within a second of the user pressing Create mission. Offline it is not, and offline is the case this feature was built for: the entry sits in missionSyncQueue until the browser fires online (:479) or the 30 s retry catches up (:268), and createAndReconcile (:296) only then calls createMission (:297). A mission started at 09:00 offshore and uploaded at 18:00 back in range is created on BlueOS Cloud with start_time 18:00. The same applies to the 404 re-create path at :363, which re-stamps the mission a second time.

Nothing corrects it afterwards. updateMission never sends start_time (api.ts:143-158), so no later edit repairs it, and upsertMission(created) (:306) writes the server's record — wrong time included — into missions, from where the watcher at :127 copies it into the cached linked mission.

This is not only a remote-side cosmetic. Cockpit displays and sorts on that field itself: startTimeOf (src/libs/blueos-cloud/mission-list.ts:29) feeds formatMissionMeta (:56), which is the second line of every row in the picker (BlueOsCloudMissionPicker.vue:601, :631) and part of the "Continue previous mission" summary (BlueOsCloudMissionDecisionOptions.vue:350-361), and filterAndSortMissions (mission-list.ts:69) orders "Newest first" by it. A day of offline dives uploaded in one batch at the dock therefore lists in upload order, all dated to the same evening, with the date search (mission-list.test.ts:56-62 pins that the displayed date is searchable) matching the wrong day.

The value that should be sent already exists at the call site and is already being passed for another purpose: currentCycleId (src/composables/blueos-cloud/useBlueOsCloudMission.ts:84) is new Date(missionStore.missionStartTime).getTime(), the epoch of the mission cycle the user is in, and createMission in the composable hands it to startCloudMission at :139 purely as the link stamp.

Fix: capture the start time when the entry is queued rather than when it is pushed. Add it to PendingCloudMission (mission-sync-queue.ts:11-43) alongside the other fields the create carries, set it in enqueueCreate (:114) from the value startCloudMission already receives, and pass it through createAndReconcile (:296) into createMission's input, defaulting to new Date() there only when the caller supplies nothing. One field on the queue entry and one optional parameter on the API function, at the two places that already own the create.

1.20 — A permanently refused edit unlinks the session from a mission that exists on the cloud, and says the mission was rejected (minor) (new this round)

announceDroppedMission clears the link for any entry the queue gives up on:

const announceDroppedMission = (mission: PendingCloudMission): void => {
  if (linkedMissionId.value === mission.clientId) linkedMissionId.value = null
  const missionName = mission.title ?? mission.clientId
  openSnackbar({
    message: `BlueOS Cloud rejected mission "${missionName}", so it will not be uploaded.`,

(src/stores/blueOsCloud.ts:316-321, reached from flushMissionSyncQueue:397 after registerFailedAttempt spends the fifth attempt at :375.) Its JSDoc states the premise — "clears the dead link, so the dialog stops offering a mission that will never be uploaded" — and that premise is true of exactly one of the two kinds of entry that reach it.

For a queued create it is right: cloudId is null, the mission exists nowhere, and a link pointing at a client-side uuid is dead. For a queued update it is wrong. That entry was produced by enqueueUpdate (mission-sync-queue.ts:136-150) against a mission that is already on the cloud, so clientId === cloudId (:139) — which means the === at :317 matches, since linkedMissionId holds that same cloud id — and the mission the link points at is being served by the API right now. Only the edit was refused.

What the user gets is the session silently detached from the mission it was logging to. hasMissionThisCycle (useBlueOsCloudMission.ts:88) goes false, the mission dialog flips from the read-only panel back to the three-way choice (MissionIdentifier.vue:28), and because previousMission resolves through linkedMission off the now-null id (:91), Continue previous mission is not offered either — they have to find the mission in the picker again, and nothing told them to. The snackbar meanwhile says the mission was rejected and will not be uploaded, which for an existing mission is simply not what happened.

The trigger is a permanent non-404 refusal repeated five times: 404 is intercepted earlier as a re-create (:359-366), but a 400 on a title the cloud will not take, or a 403 after the account loses access to that mission, goes straight to registerFailedAttempt and is retried on the 30 s timer until the budget is gone. Rare, which is why this is minor rather than major, but nothing about it is recoverable by the user once it happens.

Fix: gate the unlink on the entry never having reached the cloud — if (mission.cloudId === null && linkedMissionId.value === mission.clientId) — using the same discriminator syncPendingMission already branches on at :340. Give the message the same split while you are there: an abandoned create is "…so it will not be uploaded", whereas an abandoned edit should say the change could not be saved and that the mission is still linked, so the user knows what to redo and what they still have.

2. Persistence & User Data — inventory, no findings

Every persisted key the PR touches, its backend, and what happened to it. No key was added or removed this round; one changed shape.

Key Backend This PR
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added
cockpit-blueos-cloud-linked-mission-cycle machine-local added
cockpit-blueos-cloud-mission-queue-v1 machine-local added; its entry shape gained a revision field this round (mission-sync-queue.ts:43)
cockpit-blueos-cloud-linked-mission-v1 machine-local added (blueOsCloud.ts:114-119)
cockpit-last-mission-name machine-local removed, together with its watcher (stores/mission.ts, -61 and -187/-191)
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched, pre-existing
cockpit-mission-start-time machine-local untouched; read as the cycle stamp (useBlueOsCloudMission.ts:84), and the value 1.19 says the create should also carry

Judged:

  • Backend is right on all four new keys. A cloud-mission link, its cycle stamp, the queue of work only this browser can replay, and a cached copy of the mission are properties of one operator's session on one topside computer. None is vehicle-synced, so nothing here reaches another operator's machine or the vehicle, and the only vehicle-synced key in the area is left alone.
  • This round's shape change needs no migration, and correctly has none. revision is a new required field on the entries in cockpit-blueos-cloud-mission-queue-v1. An entry written by an earlier build of this same branch would arrive without it, and base.revision + 1 in enqueueUpdate (:147) would then produce NaN, which never equals itself, so settlePending (:180) would keep the entry forever and re-PATCH it every 30 s. That is reachable only for someone who ran an earlier commit of this branch: the key is introduced by this PR and has never shipped, and AGENTS.md is explicit that keys never released to users get a schema change and no migration code. Checked and dismissed rather than raised.
  • Naming. All carry the cockpit- prefix. The queue and the cache carry a -v1, the id and cycle keys do not; cosmetic only, no reader depends on the suffix.
  • The cached mission repeats the linked id. cachedLinkedMission.id duplicates cockpit-blueos-cloud-linked-mission-id, which the single-source-of-truth rule would normally flag, but it is load-bearing: :141 refuses a cache that no longer matches the link, which is what stops a stale copy being shown for a different mission, and src/tests/stores/blueOsCloud.test.ts:65-74 pins that. Keep it.
  • Neither new finding is a persistence finding. 1.19 is a missing field on a record sent to a remote API — adding it to the queue entry is the fix, not the defect — and 1.20 is a missing check before clearing a key, not a problem with the key. Both are raised in section 1.
  • No migration elsewhere, and none needed. Every new key is new, written before it is read, defaulting to null/{}. cockpit-last-mission-name is abandoned in existing users' local storage rather than rewritten — the non-destructive route — and the reset action replacing that feature is in the same dialog.
  • Shape. The queue is keyed by client id with a nullable cloudId, which is what lets an offline create coalesce with a later rename (:136-150) and, as of this round, what lets a mid-flight edit be retargeted rather than dropped; the cache stores the API payload verbatim, so the read-only view and the edit form read the same fields they would read online.
Sections with nothing to report (9)

3. AGENTS.md Adherence — ✅ (package.json is untouched, so no dependency was added; this round's two new symbols both have call sites in this same PR — settlePending (mission-sync-queue.ts:174) from settleSyncedMission (blueOsCloud.ts:285), and settleSyncedMission from both flush branches (:308, :354) — and the round is net-negative on API surface, since removePending stopped being exported, which is the direction the minimalism ladder asks for)

4. Security — ✅ (the only hosts remain app.blueos.cloud in libs/blueos-cloud/api.ts:3-4 and the Auth0 domain in auth.ts:4; this round adds no network call, no encoded blob and no eval-family construct, and touches no build script, workflow, Dockerfile or Electron main-process file)

5. Performance — ✅ (this round's added work is one property comparison and one in check per successful push, on the flush path traced above rather than on any MAVLink or per-frame path; the new scheduleQueueRetry call at :286 cannot stack timers — :269 returns when one is already pending — and it fires only for the entry a mid-flight edit left behind, so the steady state is unchanged; the online listener still disposes at :480)

6. UI / UX — ✅ (this round's change is confined to the store and the queue module — no template or <script setup> block moved — and re-checking the surfaces at head, the read-only panel MissionIdentifier.vue:40-84, its footer at :94-109 and the four dialogs still carry their close X, centred titles, theme="dark" on every teleporting control, one glass layer per surface, and logUserAction on every discrete action; the misleading snackbar copy found this round is a consequence of the missing branch in 1.20 and is fixed there rather than raised twice)

7. Code Quality & Style — ✅ (complexity-report.json is for this head, measured 322 functions across all 20 changed files and is not truncated; it reports the same single trigger as round 8, new-function-above-12 on linkedMission at src/stores/blueOsCloud.ts:138 at complexity 15 with depth 1, in a function this round did not touch — the count comes from a flat precedence table of ?? defaults in one object literal, so the trigger is answered; the two functions added this round are absent from the report, as a three-line comparison and a two-line delegation would be, and both add JSDoc or a why-comment, no any and no scoped CSS)

8. Commit Hygiene — ✅ (the five commits in pr.json carry this round's fix amended into the commit it belongs to, so no wip/address review/fixup! commit was added; every subject is scope-prefixed in the tree's dominant style and none references an issue or PR, the Closes #2711 correctly living in the PR body; the commit-size question that was 8.1 is resolved by a maintainer)

9. Tests — ✅ (the PR's three suites weaken nothing and mock only what they must — the settings syncer, the API module and localStorage at src/tests/stores/blueOsCloud.test.ts:10-28; the case added this round, mission-sync-queue.test.ts:42-52, asserts on the returned queue rather than on internals and covers both branches of the new comparison, and the eight cases across the other two files are unchanged from round 8)

10. Documentation — ✅ (nothing this round differs between Lite and Standalone, so README.md needs no note; settlePending carries a JSDoc explaining why an entry may survive a successful push (mission-sync-queue.ts:165-173), settleSyncedMission a two-line why-comment naming the snapshot problem it solves (blueOsCloud.ts:282-283), and the JSDoc on createAndReconcile (:290-295) and syncPendingMission (:327-333) still describes what those functions do after the change)

11. Nitpicks / Optional — ✅ (re-read the new names against the surrounding vocabulary — settlePending sits correctly beside enqueueCreate/enqueueUpdate/findPending, and settleSyncedMission mirrors it in the store; the only wording slip is the revision field's doc saying "bumped on every enqueue" where enqueueCreate initialises it, which is not worth a change)

Generated by Claude. This is advisory; a human reviewer must still approve.

Show a dialog on startup, when cloud missions are active, offering to continue
the previous mission, select an existing one, create a new one, or continue
without a mission. It is skipped when a mission is already linked to the current
cycle, so reloading mid-mission just resumes the session.

The cycle state and the linking actions live in a composable shared with the
mission configuration dialog, so both entry points behave identically.
@rafaellehmkuhl
rafaellehmkuhl force-pushed the add-support-for-blueos-cloud-missions branch from 0972b3d to bc1398b Compare August 20, 2026 13:41
@rafaellehmkuhl

Copy link
Copy Markdown
Member Author
Review follow-up — round 9

Done

  • src/libs/blueos-cloud/mission-sync-queue.ts, src/libs/blueos-cloud/api.ts, src/stores/blueOsCloud.ts (1.19 — offline create dated to its upload): PendingCloudMission now carries an optional startTime, set by enqueueCreate from the cycleId startCloudMission already receives (the mission-cycle start epoch), and createAndReconcile passes it into createMission. createMission's input gained an optional startTime, so start_time is new Date(input.startTime ?? Date.now()).toISOString() — the clock is only read when the caller has nothing, which is the online-only path this API had before.
  • src/stores/blueOsCloud.ts (1.20 — a refused edit unlinks a mission that exists): announceDroppedMission gates the unlink on mission.cloudId === null, the same discriminator syncPendingMission branches on, so only an abandoned create clears the link. The snackbar splits the same way: an abandoned create still says the mission will not be uploaded, an abandoned update says the changes were rejected and that the mission stays linked with its previous details.
  • src/tests/stores/blueOsCloud.test.ts (1.19, 1.20): two cases. The first queues a create with a start epoch, reloads, flushes with createMission stubbed, and asserts the call carried that epoch; it fails on the old code because nothing reached the API. The second queues an edit, reloads, and flushes five times against a 400, asserting the entry left the queue and linkedMissionId still points at the cloud mission; on the old code the link is null. Pulled the two sign-in lines the flush tests repeat into a signIn helper.
  • src/tests/libs/blueos-cloud/mission-sync-queue.test.ts (1.19): queueWithOneCreate passes the new required startTime.

Won't change (with reasoning)

  • 1.19, the 404 re-create path: the entry that reaches createAndReconcile at :363 came from enqueueUpdate, which never knew a start time, so it still falls back to now. Fixing it means resolving the deleted mission's old start_time out of the fetched list or the cache at the point the cloud has just told us the mission is gone — more machinery than the case is worth, and no worse than today. The create path, which is the one the feature exists for, is exact.

@rafaellehmkuhl

Copy link
Copy Markdown
Member Author

/review

@github-actions

Copy link
Copy Markdown
READY TO MERGE (Automated PR Review — round 10)

Nothing open — 40 closed across ten rounds, 2 of them this round.

This PR lets a signed-in pirate-mode user attach a BlueOS Cloud mission to the running Cockpit session. Missions are created, picked and edited from the mission-name widget's dialog, and on startup a dialog asks what to do for this session. Because Cockpit normally runs with no internet, creates and edits are not sent directly: they go into a queue in the browser's local storage and are replayed when the browser reports it is online again, when the user signs in, when another edit is made, or on a 30-second retry. A mission stays attached only for the current mission cycle — the same six-hour-idle / new-day window that renews the automatic mission name — and Cockpit keeps a local copy of the attached mission's details so it still knows what it is logging to after a restart. The same round also replaces the local dialog's restore-last-name control with a Reset action and hides the widget's edit pencil until hover.

Both findings left open at round 9 are fixed, and fixed as asked. A mission created offshore now carries the time the mission cycle started rather than the time it happened to reach the cloud, and a queued edit the cloud permanently refuses no longer detaches the session from a mission that exists and is still usable. Re-running every section over the whole diff — not just the increment — turned up nothing new.

What still needs attention

Nothing is open. All 40 findings this PR has accumulated are closed: 35 addressed in code, 3 no longer applicable, 2 resolved by a maintainer.

Since round 9 — 2 closed (both fixed), comparing 0972b3dbc1398b

The range. incremental.diff for 0972b3d...bc1398b is again not a diff: it contains no diff --git header at all, and is instead a per-file summary block (=== src/… (modified, +N/-M) ===) covering all 20 files the PR touches — i.e. the whole branch — so it says nothing about what moved since round 9. pr.json shows why: all five commits carry new oids under the same five subjects and a single fresh commit date (2026-08-20T13:41:18Z), and 0972b3d is not among them, so the branch was amended and force-pushed, as before rounds 5 through 9. Every status below is therefore judged against pr.diff and the code at this head, not against the increment, and any new finding would have had to come from pr.diff as it always must.

Line numbers in src/stores/blueOsCloud.ts below :296 are higher than round 9's by the lines this round inserted, which is a shift and not a move: announceDroppedMission :316:318 (two more JSDoc lines), syncPendingMission :334:340, flushMissionSyncQueue :388:394, startCloudMission :418:425, updateLinkedMission :464:472. linkedMission is still :138-155 and clearSession still :176, both untouched.

✅ 1.19 — Addressed (minor). The finding named four things the fix had to produce, and all four are there.

  • "Add it to PendingCloudMission." startTime?: number at src/libs/blueos-cloud/mission-sync-queue.ts:40, with a JSDoc at :36-39 that states why it exists ("captured when the create was queued so a mission created offline isn't dated to whenever it reached the cloud") and that an update-only entry does not carry one.
  • "Set it in enqueueCreate from the value startCloudMission already receives." EnqueueCreateParams gained a required startTime: number (:83), enqueueCreate writes it at :132, and startCloudMission passes startTime: cycleId at src/stores/blueOsCloud.ts:433 — the same currentCycleId (useBlueOsCloudMission.ts:84, new Date(missionStore.missionStartTime).getTime()) the finding pointed at, now documented as such on the @param at :421-422.
  • "Pass it through createAndReconcile into createMission's input." createAndReconcile forwards startTime: mission.startTime at :303.
  • "Defaulting to new Date() there only when the caller supplies nothing." src/libs/blueos-cloud/api.ts:132 is start_time: new Date(input.startTime ?? Date.now()).toISOString(), with the optional field declared at :125 and a why-comment at :129 naming the offline case. ?? rather than ||, so an epoch of 0 is not swallowed.

I checked the two things the ask did not spell out. Coalescing preserves it: enqueueUpdate spreads ...base (mission-sync-queue.ts:151) and never writes startTime, so an offline create renamed before it flushes keeps the original epoch rather than losing it to the rename — that is the common field sequence and it holds. And a required field arriving absent from an entry persisted by an earlier build of this branch degrades to Date.now() rather than to NaN, so the schema change needs no migration for the same reason round 9's revision did not; noted in section 2 rather than raised.

The author declares one part deliberately uncovered (follow-up): the 404 re-create at :369 reaches createAndReconcile with an entry enqueueUpdate built, which never knew a start time, so it still stamps now. That case falls inside the fallback the finding itself prescribed rather than outside the fix, and I agree it does not earn a finding of its own — a mission the cloud says is gone and that Cockpit re-creates is a new record, and the old start_time is not authoritative anywhere in the queue at that point. Recording it here so the next round does not re-raise it as a discovery.

src/tests/stores/blueOsCloud.test.ts:123-135 pins the fix: a create queued with an explicit epoch, a reload, a flush with createMission stubbed, and expect.objectContaining({ startTime: startedAt }) on the call. On the old code no startTime reached the API at all, so the assertion discriminates. mission-sync-queue.test.ts:12-13 was updated for the new required field.

✅ 1.20 — Addressed (minor). Both halves of the ask landed.

  • "Gate the unlink on the entry never having reached the cloud, using the same discriminator syncPendingMission branches on." announceDroppedMission now reads const wasNeverUploaded = mission.cloudId === null (src/stores/blueOsCloud.ts:319) and clears the link only under it (:320) — the same test as syncPendingMission:346, named rather than repeated inline.
  • "Give the message the same split." :322-326 builds two phrasings off the same flag. An abandoned create still reads: BlueOS Cloud rejected mission "X", so it will not be uploaded. An abandoned update now reads: BlueOS Cloud rejected the changes to mission "X", which stays linked but keeps its previous details. The JSDoc at :312-317 was rewritten to state the new premise instead of the old one.

I verified the claim that second sentence makes, since a snackbar that lies is what the finding was half about. After the drop the queue entry is gone, so linkedMission (:138-155) falls through to base — and the cache it may fall back to is written by a watcher on fetchedLinkedMission (:127-129), which only ever holds records the server returned, never the pending overlay. The panel therefore shows the cloud's own last-known details, which is exactly what the copy promises, and isLinkedMissionSynced (:161) goes back to true because nothing is pending.

src/tests/stores/blueOsCloud.test.ts:137-153 pins it: an edit to a picker-linked mission, a reload, five flushes against a 400, then isLinkedMissionSynced true and linkedMissionId still cloud-1. It discriminates — enqueueUpdate sets clientId === cloudId (mission-sync-queue.ts:149), so the old unconditional === matched and nulled the link.

Resolutions. resolutions.json carries the same two entries as the last four rounds — 1.3 (@rafaellehmkuhl, "Not a problem for now.") and 8.1 (@rafaellehmkuhl, "They are a single logical change.") — and both are already resolved in the carried ledger, applied in round 6. They are settled; nothing was re-applied, and every id in the file is present in the ledger, so no resolution went nowhere. decisions.json is []: no dispute has ever been put to a vote on this PR, so there is no open, tied or refused vote to report.

Discussion since the last review. @rafaellehmkuhl posted a round-9 follow-up (comment) describing the startTime field, the announceDroppedMission gate, the two new store tests and the signIn helper they were factored through, plus the won't-change on the 404 path. Every claim in it was checked against the code and the test bodies rather than accepted — the citations above are the result, and all of them hold, including the two "it fails on the old code" claims, which I re-derived independently. The only other comment in the window is a bare /review command and carries no content.

Nothing in the PR body, the diff, the commit messages or the comments contained text addressed to this reviewer, and nothing in any of them was treated as an instruction.

Change map — what was established before judging

Claims. From the PR body, each checked against the code:

  • "Creates and edits work offline: they're queued locally and pushed to BlueOS Cloud when the connection is back." — verified as to the mechanism (enqueueCreate/enqueueUpdate, src/libs/blueos-cloud/mission-sync-queue.ts:123-160; replayed by flushMissionSyncQueue, src/stores/blueOsCloud.ts:394-415), as to what the user sees while queued (linkedMission, :138-155), as to what they see just after the push (:359), as to an edit saved during a push (settlePending, mission-sync-queue.ts:184-192), and as of this round as to what is pushed: the record now describes the mission rather than the upload, because startTime travels with the entry (:40, :132) into createMission (api.ts:132). Round 9's one contradiction of this claim is gone.
  • "If a linked cloud mission was deleted remotely, the next sync recreates it instead of getting stuck on 404s." — verified: syncPendingMission (:340-386) treats a 404 on a mission with a known cloudId as a re-create through createAndReconcile (:296-310), and only a failure of that re-create spends an attempt (:381). The re-created record is stamped with the clock, which is the residual discussed above and not a defect in this claim.
  • "It's skipped entirely when a mission is already linked to the current cycle, so reloading mid-mission just resumes." — verified: openIfEligible (src/composables/blueos-cloud/useBlueOsCloudMissionStartupDialog.ts:120-128) returns on hasMissionThisCycle, and again on any of the three surfaces already being open.
  • "Cloud missions stay behind pirate mode like the Cloud settings menu." — verified: isCloudActive = interfaceStore.pirateMode && cloudStore.isAuthenticated (src/composables/blueos-cloud/useBlueOsCloudMission.ts:81), gating both the dialog branch (MissionIdentifier.vue:28) and the startup host.
  • "The old restore-last-name control is gone; there's a Reset current mission action instead." — verified, and verified as complete: lastMissionName, its watcher and its export are all removed from src/stores/mission.ts, which is what closed 3.2.

Failure site. This PR fixes no pre-existing bug in the tree; it adds a feature. The two defects closed this round were introduced by the PR itself and both live in the diff — api.ts:132 (the clock read) and blueOsCloud.ts:319-320 (the missing discriminator) — so there is no out-of-diff site to name.

Entry points. One row per function this round changed, plus the ones they reach:

Function Reached from Frequency
createMission (api.ts:104) createAndReconcile only per user action (deferred: at flush, not at create)
enqueueCreate (mission-sync-queue.ts:123) startCloudMission only per user action
startCloudMission (blueOsCloud.ts:425) useBlueOsCloudMission.ts:139, from the create form's submit per user action
createAndReconcile (blueOsCloud.ts:296) syncPendingMission :346 and :369 per user action, or per 30 s retry while a create is queued
announceDroppedMission (blueOsCloud.ts:318) flushMissionSyncQueue:403 one-shot per entry, at the end of its attempt budget
syncPendingMission (blueOsCloud.ts:340) flushMissionSyncQueue:401 per queued entry per flush
flushMissionSyncQueue (blueOsCloud.ts:394) online listener :487, sign-in, updateLinkedMission:482, startCloudMission:437, 30 s retryTimer per user action and per 30 s while the queue is non-empty

No changed function has zero callers. None of these sits on mavlink:onIncomingMessage, dataLake:setVariable or any per-frame path; the busiest is the 30-second timer, which returns at :395-396 when the queue is empty. The remaining 13 changed files were re-read this round but hold no function the last two pushes altered.

Invariants. Two, both established by this round's change:

  • A record Cockpit creates on the cloud describes the mission, not the upload. The only site that can violate it is createMission (api.ts:130-132). Producers of its startTime are createAndReconcile (:303), whose entries come from enqueueCreate (always sets it, mission-sync-queue.ts:132) or from enqueueUpdate (never sets it, but preserves it through ...base at :151 when coalescing onto a create). The one uncovered producer is an update-only entry reaching the 404 re-create at :369, which falls to the documented default. Closed at the single consumer, which is the shape the guideline asks for: one optional parameter on the function that builds the body, not a guard at each caller.
  • Unlinking the session means the mission it pointed at does not exist. Written by announceDroppedMission (:320), finishMission (:454-455) and clearSession (:176); the latter two clear a link the user asked to end, so they cannot violate it. announceDroppedMission is the one that infers non-existence, and it now infers it only from cloudId === null (:319). Covered at every site.
2. Persistence & User Data — inventory, no findings

Every persisted key the PR touches, its backend, and what happened to it. No key was added or removed this round; one changed shape.

Key Backend This PR
cockpit-blueos-cloud-linked-mission-id machine-local (useStorage) added (blueOsCloud.ts:96)
cockpit-blueos-cloud-linked-mission-cycle machine-local added (:107)
cockpit-blueos-cloud-mission-queue-v1 machine-local added (:101); its entry shape gained an optional startTime this round (mission-sync-queue.ts:40)
cockpit-blueos-cloud-linked-mission-v1 machine-local added (:114-119)
cockpit-last-mission-name machine-local removed, together with its watcher (stores/mission.ts)
cockpit-blueos-cloud-enabled vehicle-synced (useBlueOsStorage) untouched, pre-existing
cockpit-mission-start-time machine-local untouched; read as the cycle stamp (useBlueOsCloudMission.ts:84) and, as of this round, also carried into the queue entry as the create's start time

Judged:

  • Backend is right on all four new keys. A cloud-mission link, its cycle stamp, the queue of work only this browser can replay, and a cached copy of the mission are properties of one operator's session on one topside computer. None is vehicle-synced, so nothing here reaches another operator's machine or the vehicle, and the only vehicle-synced key in the area is left alone. Nothing added is a device path, a filesystem path or window geometry.
  • This round's shape change needs no migration, and correctly has none. startTime is optional on PendingCloudMission and required only on EnqueueCreateParams, so an entry persisted by an earlier commit of this branch arrives without it and createMission falls to Date.now() (api.ts:132) — the pre-round-10 behaviour, not a crash and not a NaN. That is strictly safer than the revision case round 9 examined, and the key has never shipped, which is the case AGENTS.md is explicit gets a schema change and no migration code.
  • cockpit-mission-start-time now feeds a remote record. Reading it is unchanged; what is new is that its value is written into the queue and then into a cloud mission's start_time. It is a Date in local storage and is converted through getTime() at the composable and new Date(...).toISOString() at the API, so the cloud gets UTC regardless of the operator's timezone. No new key, no reshape.
  • Naming. All carry the cockpit- prefix. The queue and the cache carry a -v1, the id and cycle keys do not; cosmetic only, no reader depends on the suffix.
  • The cached mission repeats the linked id. cachedLinkedMission.id duplicates cockpit-blueos-cloud-linked-mission-id, which the single-source-of-truth rule would normally flag, but it is load-bearing: :141 refuses a cache that no longer matches the link, which is what stops a stale copy being shown for a different mission, and src/tests/stores/blueOsCloud.test.ts:72-81 pins that. Keep it.
  • No stranded users. Every new key is new, written before it is read, defaulting to null/{}. cockpit-last-mission-name is abandoned in existing users' local storage rather than rewritten — the non-destructive route — and the reset action replacing that feature is in the same dialog, so the user is not left looking for the control that went away.
Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (traced this round's two changes end to end: startTime from missionStartTime through startCloudMission:433, enqueueCreate:132, the ...base spread in enqueueUpdate:151 that survives a coalesced rename, createAndReconcile:303 and createMission's ?? at api.ts:132, which does not swallow an epoch of 0; and the announceDroppedMission gate at :319-320 against syncPendingMission:346, confirming the post-drop panel falls back to the server-returned cache — the watcher at :127-129 never writes the pending overlay — so the new snackbar copy is true; re-read the other 13 files for data-lake-first, Electron guards, openSnackbar paired with a duplicate console.*, and widget default-merging, and found nothing the earlier rounds' fixes did not already settle)

3. AGENTS.md Adherence — ✅ (package.json is untouched, so no dependency was added; both symbols this round adds are fields rather than exports and both have call sites in this same PR — PendingCloudMission.startTime read at blueOsCloud.ts:303, createMission's input.startTime read at api.ts:132 — so nothing lands as groundwork; the ?? at api.ts:132 and the named wasNeverUploaded at :319 are the AGENTS.md-preferred forms over || and a repeated inline test, and no comment whose underlying lines are unchanged was deleted or reworded)

4. Security — ✅ (the only hosts remain app.blueos.cloud in libs/blueos-cloud/api.ts:3-4 and the Auth0 domain in auth.ts:4; this round adds no network call, no header, no encoded blob and no eval-family construct, sends one already-local timestamp it did not send before, and touches no build script, workflow, Dockerfile or Electron main-process file)

5. Performance — ✅ (this round's added work is one property copy per create and one boolean per dropped entry, on the flush path traced in the Change map rather than on any MAVLink or per-frame path; no listener, timer or watcher was added, the online listener still disposes at :488, and retryTimer is still cleared in clearSession:176-180)

6. UI / UX — ✅ (the only user-visible change is the announceDroppedMission snackbar text at :322-326, which keeps variant: 'error', its 6 s duration and its close button, reads as sentence case, names the mission by title and carries no protocol jargon; no template or <script setup> block moved, and re-checking the surfaces at head, the read-only panel MissionIdentifier.vue:40-84, its footer at :94-109 and the four dialogs still carry their close X, centred titles, theme="dark" on every teleporting control, one glass layer per surface, and logUserAction on every discrete action)

7. Code Quality & Style — ✅ (complexity-report.json is absent this round, so the measurement is unavailable and no complexity finding is raised either way — I did not count anything by hand; read against .eslintrc.cjs instead, the round adds no any, no non-null assertion, no unused import, no scoped CSS and no long wrapped inline-expression string, and both touched JSDoc blocks have real summaries with typed @params, satisfying jsdoc/require-description-complete-sentence)

8. Commit Hygiene — ✅ (the five commits in pr.json carry this round's fixes amended into the commits they belong to — no wip, address review or un-squashed fixup! was added, and no commit reverts or reimplements an earlier one; every subject is scope-prefixed in the tree's dominant style, none references an issue or PR, with Closes #2711 correctly living in the PR body alone; the commit-size question that was 8.1 is resolved by a maintainer)

9. Tests — ✅ (the PR's three suites weaken nothing and mock only what they must — the settings syncer, the API module and localStorage at src/tests/stores/blueOsCloud.test.ts:10-28; the two cases added this round, :123-135 and :137-153, assert on the API call and on public store state rather than on internals, and I checked both fail against the pre-round-10 code as claimed; the signIn helper at :48-51 factors two repeated lines out of four tests without changing what any of them asserts)

10. Documentation — ✅ (nothing this round differs between Lite and Standalone, so README.md needs no note; the new startTime is documented on the queue field (mission-sync-queue.ts:36-39), on EnqueueCreateParams (:80-82), on createMission's @param (api.ts:100) and its input member (:122-125), the clock-vs-caller decision has a why-comment at api.ts:129, and announceDroppedMission's JSDoc (:312-317) was rewritten to state the new premise rather than left describing the old one)

11. Nitpicks / Optional — ✅ (re-read the round's names against the surrounding vocabulary — wasNeverUploaded and rejected both read as what they hold, and startTime matches the start_time it becomes; the only wording slip left is the one round 9 noted and let stand, revision's doc at mission-sync-queue.ts:46 saying "bumped on every enqueue" where enqueueCreate initialises it, still not worth a push)

Generated by Claude. This is advisory; a human reviewer must still approve.

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

Labels

docs-needed Change needs to be documented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow creating, listing and selecting BlueOS Cloud missions

3 participants