Follow-up to f2b533af41 (#9096). Two changes:
1. Store-owned pruning choke point. The preserve-set invariant (current
client + latest full-state author) was duplicated at five caller sites
— exactly how #9089/#9096 happened. OperationLogStoreService now owns
it in pruneClockForStorage; setVectorClock, saveStateCache, and
commitFileSnapshotBaseline prune internally, and callers (snapshot,
compaction, hydrator, sync-hydration, server-migration) pass raw
clocks. This also covers the previously unpruned setVectorClock site
in _restorePreservedLocalOps. Importing limitVectorClockSize outside
the store (client wrapper or @sp/sync-core) now fails lint
(no-restricted-imports, sabotage-verified on both import routes).
2. Atomic mergeRemoteOpClocks. The merge was a read-compute-put across
separate transactions reading the per-tab cache — two tabs could
interleave and lose clock entries. It is now one readwrite
transaction with a fresh in-transaction read of the durable clock,
mirroring the reducer checkpoint (regression test proves the stale-
cache lost update).
Pruning behavior tests move to the store spec (caller specs mock the
store and can no longer observe pruning); caller-level prune tests are
deleted, the sync-hydration case becomes a wiring test. Docs updated.
The client pruned its durable vector clock with uploader-only protection,
so once 21+ client ids accumulated after a full-state import, the import
author's low-counter entry was evicted. Every subsequent local op then
permanently failed the sync-import filter's knows-import-counter rescue
and was dropped as CONCURRENT on every peer — the client-side ceiling of
the server fix in #9089.
- client limitVectorClockSize wrapper now takes a preserve list, matching
the shared implementation
- calculateRemoteClockMerge (remote merge + reducer checkpoint) preserves
the latest full-state author; an in-batch full-state op supersedes the
stored one, and the checkpoint resolves the author inside its
transaction so a just-rejected import cannot name the protected author
- snapshot save, compaction, hydrator restore, and the sync-hydration
file-snapshot bootstrap protect the author on their durable-clock paths
- docs: add the missing calculateRemoteClockMerge prune site, correct the
stale RepairOperationService rows (repair ships the full clock), and
reword the sync-core pruning comment to the real invariant
* fix(sync): deduplicate surgical sync retries
Persist split-file configuration and acknowledge operation IDs already committed remotely after a lost upload response.
* fix(sync): preserve import author during clock pruning
Keep the active causal full-state author in oversized stored clocks and reuse the stored protected IDs when classifying response-loss retries.
* test(sync): harden supersync failure coverage
Exercise real upload endpoints and exact operation IDs across response loss, validation failures, schema blockers, full-state boundaries, vector pruning, and concurrent edits.
* fix(sync): heal a corrupt primary on duplicate-only uploads
The .bak recovery path caches the CORRUPT primary's rev precisely so this
cycle's conditional overwrite repairs sync-ops.json. The duplicate-retry
short-circuit read that cache and returned before the write, leaving the
primary corrupt whenever the recovered buffer already held every pending
op. Flag the recovered entry and let those uploads fall through.
Also stop synthesising a serverSeq for ops already in the buffer: the
field is optional, the upload and download paths number ops differently,
and a mixed batch could hand two ops the same value.
* perf(sync): look the full-state author up once per upload
Batch upload is off by default, so the guarded batch path was not the one
serving production: the serial path queries the causal full-state author
per op inside a single transaction, and a clock of 21-50 entries passes
validation and trips the guard on every one of them. Memoize the author
per transaction and resolve it lazily, so only an op whose clock actually
overflows pays, and only once. This also retires the batch pre-scan and
its loop-carried author, leaving both paths on one mechanism.
Report the lookup through ProcessOperationResult so the upload summary
stops under-reporting round-trips, and record why reconstructing the
stored protected set loosens id-collision detection.
* test(sync): wait for the committed title in renameTask
renameTask blurred the textarea, slept 300ms and returned without ever
checking the rename landed. Blur -> dispatch -> re-render outruns that
delay on a loaded machine, so a following sync uploads without the rename
op and the caller asserts against a task that was never renamed — which
is what supersync 3.1 hits on CI but never locally.
Wait for the new title instead, mirroring markTaskDone's done-state wait
and the e2e no-waitForTimeout rule.
* test(sync): dispatch focus so renameTask actually commits
renameTask relied on el.focus() to emit a focus event, but these tests
drive two clients as separate pages and only one page can hold focus, so
on CI the event often never fires. TaskTitleComponent then keeps
_isFocused=false, and resetToLastExternalValueTrigger resets tmpValue to
the stored title on the next task-object emission. Blur therefore
computes wasChanged=false, task.component skips update(), and the rename
is silently dropped without ever becoming an op.
That is supersync 3.1: client A's rename lives only in tmpValue, A syncs
and uploads nothing, B uploads its done op, A downloads it, the task ref
changes and the title reverts to the original — exactly the state the CI
artifact captured. A real user always has real focus, so the app itself
is unaffected.
Dispatch focus explicitly, mirroring the synthetic input/blur already
used here. Also correct the previous commit's claim: the toBeVisible
wait matches tmpValue, a component-local signal rendered in both
template branches, so it never observed the committed title and could
not have fixed this.
* docs: revert incidental prettier reformat of unrelated docs
The master merge ran prettier across files it pulled in, reformatting
three documents this branch has no business touching: markdown table
cell padding plus *emphasis* -> _emphasis_, with no content change.
handover.md documents two unrelated branches entirely.
Restores them to master. vector-clocks.md keeps its edits — those are
this branch's own and describe the pruning protection.
* refactor(sync): drop the full-state author lookup's roundtrip accounting
resolveFullStateAuthor memoizes per transaction, so the lookup it counts
fires at most once per upload — the plumbing existed to report a number
that is always 0 or 1, on a log line already counting dozens. The memo
and its own accounting cancelled out.
Removing didQuery lets resolveFullStateAuthor return string | undefined
and getPruneProtectedIds return string[], instead of both carrying a
tuple purely to feed the counter.
uploadDbRoundtrips and the batch path's own counter are untouched.
* test(sync): assert the committed store title in renameTask
The focus dispatch did not fix supersync 3.1 — the shard failed again
with an identical snapshot (original title, done, rename gone), so that
diagnosis was wrong. Stop guessing at the trigger and make the helper
able to observe the thing in question.
task-title renders tmpValue, a component-local signal, in BOTH its
editing and idle branches. Every DOM assertion here therefore matches as
soon as the synthetic input event fires, whether or not an op was ever
captured — which is why two rounds of "wait for the title" changed
nothing. Read the store instead, via the __e2eTestHelpers.store hook the
timeSpent helper already uses.
This is a diagnostic as much as a fix: it splits the two remaining
explanations. If renameTask now fails, the rename never becomes an op
and the bug is in how the test drives the edit. If it passes and 3.1
still fails at the merge assertion, the op is captured and lost during
sync — a real defect, and the test is right to fail.
* test(sync): move the 3.1 disjoint-merge rewrite out to #9095
3.1 was the last red shard, and it turned out to be right: the
store-backed renameTask passes, so the rename IS captured as an op, and
the test still fails at the merge assertion — the op is committed and
then lost during sync. Filed as #9095.
That bug is pre-existing and cannot be reached by anything in this PR:
the file-based adapter is not used by SuperSync, and the server-side
author memo only engages for clocks over 20 entries where this test
carries about three. 3.1 is also the only test here that exercises
neither of this PR's fixes — it races a title change against a
move-to-done, which is conflict resolution, not retry dedup or clock
pruning. So it moves to #9095 rather than holding verified sync fixes
red.
The rest of the hardening stays: the fault injections whose globs never
matched a real endpoint, the schema-mismatch test that asserted nothing,
and the compaction suite that called an endpoint which never existed are
what actually cover the fixes here.
Restoring the old 3.1 puts a misleading test back, so it now carries a
comment saying why it proves little and where the real one lives. The
strengthened version is kept on test/issue-9095-disjoint-merge-repro.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* fix(sync): fence in-flight sync cycles across config changes (#9074)
Destructive config changes (encryption enable/disable/password change,
provider/account switch) blocked NEW sync cycles but neither drained nor
cancelled a cycle already mid-await, which could then apply remote ops,
upload, acknowledge, or advance the cursor against the new epoch/target
(cross-epoch/cross-provider contamination, mixed-key server history).
Fix, per the issue's KISS sketch:
- Monotonic sync epoch on SyncProviderManager, bumped AFTER each
provider switch / target-moving config write / bypass ingress, and at
runWithSyncBlocked entry. Content-only saves do not bump.
- Every cycle entry point (main sync, immediate upload, WS download,
force upload) captures the epoch with its cycle-guard claim and
threads it as fenceEpoch.
- Provider I/O is fenced in one choke point: getOperationSyncCapable()
returns a per-cycle delegate that re-asserts the epoch before every
provider call (uploads, downloads, all setLastServerSeq cursor
writes). Local writes (apply-lock closures incl. the full-state path,
deferred acks, hydration, migration appends, rejected-ops handling,
raw-rebuild resume) re-assert at the call site.
- Stale completions throw SyncEpochChangedError, handled everywhere as
a benign abort (no error snack; UNKNOWN_OR_CHANGED) — each abort
point lands in a crash-equivalent state by design.
- runWithSyncBlocked is serialized, sets the block flag FIRST, bumps
the epoch, then drains the main sync AND the cycle-guard side
channels (bounded, throws on timeout) — the fence cannot recall
request bytes already on the wire, so destructive remote writes wait
for the stale cycle to settle. ImmediateUploadService now routes
through WrappedProviderService instead of a raw cast.
Closes the audit findings C1-1 (ORCH-1) and C1-2 (SWITCH-2).
* fix(sync): don't bump the sync epoch on first-time setup (#9074)
Every conflict-dialog E2E timed out on `SyncEpochChangedError (1 → 2)`:
the first-ever provider activation and the first-ever config save (no
previous privateCfg, so isSyncTargetChanged reports a target change)
both bumped the epoch, racing the fresh config's first sync into a
spurious abort — the dialog-producing cycle itself was fenced.
First-time setup has no OLD target an in-flight cycle could be running
against (a pre-activation cycle sees getActiveProvider() === null and
exits; a pre-first-save cycle is not ready), so these bumps fence
nothing and only cause false positives. Gate them: bump on a config
save only when a previous config existed, and on activation only when
a previous provider was active. Real switches (X→Y, X→null) and real
target moves still bump; the cache-invalidation emission keeps its
true-on-first-save semantics untouched.
* fix(sync): read the (provider, epoch) fence pair in one sync block (#9074)
The SuperSync provider-switch E2E still aborted its first post-switch
sync: the cycle captured the fence epoch at its guard claim but fetched
the provider object several awaits later, so a switch completing in
between handed the cycle the NEW provider with a STALE epoch — the
fence then aborted a cycle that was actually running against the new
target.
The consistency rule is pair atomicity, not claim atomicity: a switch
swaps the provider object and bumps the epoch in one synchronous block,
so reading getActiveProvider() and syncEpoch in one synchronous block
always yields a consistent pair (old+old aborts on the later bump;
new+new proceeds). Move the capture to the provider read in the main
sync and WS-download cycles (immediate upload aligned for uniformity —
it was same-block already only by accident of having no await between).
The REPAIR paths built their vector clock from the per-tab in-memory
clock cache and then REPLACED the durable clock with it. A stale cache
(another tab advanced the clock) regressed the durable clock, letting
subsequent captures reuse counters already shipped — silently corrupting
cross-device dominance comparisons.
- createRepairOperation: route through
appendMixedSourceBatchSkipDuplicates; the in-transaction rebase makes
regression unrepresentable. State cache stores the clock actually
written.
- replaceRejectedRepair: rebase the replacement clock onto the durable
clock inside its transaction (shared rebaseLocalClockOnDurable helper).
- Drop client-side clock pruning from repair op building: inert under
the rebase, and it dropped client IDs the server still tracks (false
CONCURRENT). Server prunes after conflict detection.
- Rename appendWithVectorClockUpdate -> appendWithVectorClockOverwrite
and document the derivation invariant; the capture path (its only
remaining production caller) already satisfies it.
* fix(sync): freeze the conflict-review producers before the next release
The conflict-review feature (962c5bbeb1) is on master but in no release
tag, so a release cut would take it to the whole stable fleet while it is
still slated for removal. The Task 1 audit found it already ships to two
pre-release cohorts: Snap `edge` is public and built from every master
push (snapd auto-refreshes subscribers), and the Play `internal` track
auto-updates its testers. The data obligation therefore already exists —
the next tag would expand it, not create it.
Journal rows persist the discarded side of a conflict verbatim (entity
titles, arbitrary field values, raw action payloads). They are
device-local, absent from backups/exports, never uploaded, and expire at
14 days / 200 rows, so the recorded decision is: no export owed, and the
store, reader, UI and the SUP_CONFLICT_JOURNAL_CLEARED_BEFORE marker get
deleted together in the rollback.
Freeze both producers at the single production entry point into
autoResolveConflictsLWW:
- disableDisjointMerge: conflicts resolve by whole-entity LWW, which is
the behaviour of every released version to date, so stable gains no
merge behaviour it would later have to be migrated off.
- disableConflictJournal: no new rows are persisted. record() is the
journal's only write and both call sites are now gated, so nothing
produces rows behind the freeze.
Wired at the caller rather than as a global flag so ConflictResolution-
Service keeps the capability for its own tests and the freeze reverts by
deleting two lines. Covered by a spec that fails if either flag is
dropped, since that regression is otherwise silent.
Records the audit in docs/plans/2026-07-16-conflict-review-cohort-audit.md.
* fix(sync): don't offer conflict REVIEW when the journal is empty
The content-conflict banner (#8694) fires off the resolutions, not off the
journal, and its SPAP-15 REVIEW action was unconditional. With the producer
freeze the journal holds no rows, so REVIEW landed the user on the review
page's empty state — a dead end at the exact moment they are worried about a
discarded edit. v18.14.0 ships this banner WITHOUT the action (it has no
navigateToReview at all), so the next tag would have taken a permanently
broken affordance to the whole stable fleet — the opposite of what the freeze
is for.
Gate the action on the journal actually holding unreviewed rows. The count
signal reads fresh: the journal loop awaits record() (which refreshes it)
before the notification step. This also fixes a latent case that predates the
freeze — _journalResolution swallows record() failures by contract, so a
transient IndexedDB error already produced the same dead end. Mirrors
maybeShowSummaryBanner, which already stays silent on an empty journal.
Also cover the freeze's callee-side gates, which had none: the guard spec in
remote-ops-processing.service.spec.ts only asserts the production caller
PASSES the flags. Deleting `if (!options.disableConflictJournal)` in
autoResolveConflictsLWW left all 292 specs green while the fleet silently
resumed persisting the discarded side of every conflict. Both new gates are
falsification-verified: each mutation fails exactly its own spec.
* refactor(sync): simplify the conflict REVIEW action gate
Use a plain ternary to undefined instead of a conditional spread: the banner
component truthy-checks `@if (banner.action)` and exactOptionalPropertyTypes
is off, so `action: undefined` is identical to omitting the key — and reads
as the same thing. Trim the rationale comment to the load-bearing why.
No behaviour change; the gate's spec is unchanged and still passes.
* fix(sync): give the integration spec's journal mock its count signal
The content-conflict banner now reads `conflictJournal.unreviewedCount()` to
decide whether to offer REVIEW, but conflict-resolution-persistence.integration
.spec.ts mocks ConflictJournalService with `['record']` only, so resolution threw
`unreviewedCount is not a function` and took the #8997 project-recovery test down
with it.
Provide a real `signal(0)` via createSpyObj's properties arg rather than a spy:
unreviewedCount is a signal on the service, so this keeps the mock honest to the
type it stands in for.
Caught by CI, not locally: the earlier run covered only the four spec files the
change touched, and this mock lives in a fifth. Full suite now green, 12928 +
12914 across both TZ variants.
* test(sync): drop the redundant journal-on control test
'journals resolutions by default' duplicated coverage that already exists: the
#8956 multi-entity test asserts record() is called twice on the default path, so
it already fails if journaling breaks entirely — which was the only thing my
control guarded against (the freeze test passing vacuously).
Collapse the leftover one-test describe and its two single-use helpers back into
a flat spec.
Full suite green: 12927 + 12913 across both TZ variants.
* docs(sync): add simplification roadmap
* docs(sync): strengthen simplification roadmap
* docs(sync): simplify sync implementation plan
Prioritize deletion, atomic single-tab ownership, and full-sync trigger consolidation while deferring compatibility removals and speculative abstractions.
* docs(sync): make simplification roadmap compatibility-safe
Narrow deletion work behind persisted-data, provider-eligibility, delivery, and request-cost gates. Keep schema readers and separate unrelated startup and maintenance correctness projects.
* docs(sync): fold code-verified review findings into plan
* docs(sync): align simplification plan with latest changes
* docs(sync): correct trigger-branch claim and add footprint estimate
* docs: cap services at 1000 LOC
* chore(lint): warn when a service exceeds 1000 lines
* chore(lint): raise service size cap to 1200 lines
* docs(sync): re-align simplification plan to master 6fefd741c5
* docs(sync): fold #9054 footprint auth into plan contracts
Master advanced 3 commits past the plan's 6fefd741c5 baseline. Exactly
one touches a task surface: #9054 (authenticated LWW project-move
footprint, GHSA-8pxh) landed in conflict-resolution.service.ts — the
file Task 6 carves up.
Contract 14 documented only #9045's decrypt-path check and emphasised
the weaker half ("synthetic ops are intentionally exempt; that exemption
must survive the disjoint-merge removal"). Read alone, that could bless
an op.entityIds read while removing disjoint-merge, reopening the vector
one merge removed. #9045's exemption sentence is still accurate at
master, so 14 was incomplete rather than wrong.
Name the invariant #9054 established: getTaskProjectMoveEntityIds
re-derives footprints from the authenticated payload.projectMoveFootprint
(LWW) / actionPayload.projectMoveSubTaskIds + actionPayload.task.id (raw
TASK_SHARED_UPDATE), never the plaintext envelope, and that one choke
point serves the disjoint-merge, local-win and superseded-op callers.
Also fence #9054 in §4 and re-align the baseline to 7ef7e69e96 (#9052
orphan-task navigation and #9056 locale touch no task surface).
* chore(lint): make the service size cap enforce on new services
'warn' could not enforce what AGENTS.md asserts. `ng lint` runs the
@angular-eslint builder, whose maxWarnings defaults to -1 and is not
overridden in angular.json, so warnings never fail a build: a new
1300-line service merged green. The documented flip-to-'error' gate
("once every *.service.ts is under the cap") needed
conflict-resolution.service.ts to go 3934 -> 1200, i.e. the whole
7-task simplification plan, so it would realistically never flip.
Ratchet instead: 'error' on **/*.service.ts, with the 8 pre-existing
offenders grandfathered to non-failing warnings at their real size, so
the debt stays visible and the list can only shrink.
Verified: a new over-cap service errors (exit 1); all 8 grandfathered
warn (exit 0); full `npm run lint:ts` is exit 0 with exactly 8 warnings
and 0 errors, which proves the list is complete — a missed offender
would fail the build. Every >1200-line @Injectable in the repo is named
*.service.ts, so the glob has no coverage hole, and .service.spec.ts is
exempt (checked against the 7797-line conflict-resolution spec).
Also correct AGENTS.md "1200 LOC": max-lines defaults to
skipComments/skipBlankLines false, so the cap counts physical lines.
Under the ISO 8601 option, dateTimeLocale is the 'sv' sync sentinel, so any
spelled-out weekday/month name rendered with currentLocale() prints in Swedish
regardless of UI language. Follow-up to #9013/#9055 covering the surfaces those
PRs didn't: spelled-out names now follow the UI language (isoTextLocale), while
numeric dates and clock times keep currentLocale().
- scheduled-list-page: locale signal -> isoTextLocale ?? currentLocale (all 6
localeDate labels are spelled-out; fixes the reproduced 'ons, 15 juli' leak)
- dialog-focus-session-edit.formatSelectedDate: weekday+month long
- habit-tracker.dateRangeLabel: month short
- scheduled-date-group.pipe: work-view group-header tooltip weekday
- worklog (formatDayStr via mapArchiveToWorklog): day-header weekday
Inlines the isoTextLocale ?? currentLocale expression (not #9055's textLocale)
so this PR shares no files with #9055 and is conflict-free / mergeable in any
order. Adds an ISO regression test to scheduled-date-group.pipe.spec and updates
mocks so the new isoTextLocale() calls resolve. Also adds docs/handover.md.
* fix(tasks): decode multipart and transfer-encoded eml bodies #8975
Dropping a real-world .eml (e.g. saved from Outlook) onto the add-task
button created a title-only task: the parser only accepted a single,
unencoded text/plain body, but most clients send multipart/alternative
(plain + HTML) with quoted-printable or base64 transfer encoding on the
plain part.
eml-parser.ts now walks multipart/* structures (bounded recursion depth)
for the first supported text/plain leaf, and decodes quoted-printable/
base64 transfer encodings on it. HTML bodies and non-UTF-8/ASCII
charsets are still never decoded, matching the existing threat model
for this untrusted, inert note content.
* fix(tasks): keep unsupported-encoding eml test accurate after decode support
The service-level test for "unsupported body encoding" used a base64
fixture, which the eml-parser fix now legitimately decodes. Switch it
to a genuinely unsupported transfer-encoding token, and add a
dedicated test asserting base64 bodies decode and wrap in the notes
code fence as expected.
* fix(tasks): skip Content-Disposition: attachment parts in eml import
_extractPlainText() picked the first supported text/plain leaf in wire
order regardless of Content-Disposition, so a text/plain attachment
could be imported as the note when the real body was HTML, or shadow
the real body if it appeared first in the multipart structure. Skip
any part (leaf or multipart container) marked as an attachment before
inspecting it further.
Addresses review feedback from @johannesjo on PR #8999.
* fix(tasks): treat non-inline disposition and legacy name= as attachment
Content-Disposition is optional (legacy mail marks a filename via
Content-Type's name= parameter instead), and RFC 2183 §2.8 requires
any disposition type other than inline — recognized or not — to be
treated as attachment. The previous fix only matched the literal
token "attachment", so a text/plain part identified solely by a
legacy name= parameter, or one with an unrecognized disposition type
(e.g. x-download), still shadowed the real body.
_isAttachmentPart() now treats any present disposition other than
inline as an attachment, and falls back to the Content-Type name=
hint only when Content-Disposition is absent entirely.
Addresses further review feedback from @johannesjo on PR #8999.
* fix(tasks): recognize RFC 2231 name*/name*0 attachment filename hints
The legacy Content-Type name= fallback (used when Content-Disposition
is absent) only matched the literal key "name", missing RFC 2231's
encoded (name*) and continuation (name*0, name*0*, name*1, ...)
spellings of the same parameter. A part identified solely by a
continued name*0/name*1 filename hint slipped through undetected and
could be imported as the note instead of producing a title-only task.
_parseContentType() now matches any RFC 2231 spelling of name via a
presence-only regex; the value is never decoded or reassembled since
only the filename hint's existence matters.
Addresses further review feedback from @johannesjo on PR #8999.
* fix(tasks): recognize RFC 2231 filename*/filename*0 disposition hints
Content-Disposition's hasFilename fallback (used when the disposition
type token fails to parse, e.g. a type-less "; filename=...") only
matched the literal key "filename=", missing RFC 2231's encoded
(filename*) and continuation (filename*0, filename*0*, ...) spellings
of the same parameter -- the mirror of the name*/name*0 gap fixed for
Content-Type.
_parseContentDisposition() now matches any RFC 2231 spelling of
filename via a presence-only regex, symmetric to _NAME_PARAM_KEY_RE.
Addresses further review feedback from @johannesjo on PR #8999.
* docs(plugins): add microsoft 365 calendar provider plan
* docs(plugins): add microsoft 365 calendar provider plan
* docs(ios): plan internal testflight builds
* fix(sync): prevent file-based sync data loss
Preserve post-snapshot operations and stage remote baselines until durable apply.
Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.
Refs #8960
* fix(sync): avoid biased conflict recommendations
Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.
* test(sync): assert options arg on split-file processRemoteOps
The split-file snapshot path now routes post-snapshot ops through
_processRemoteOpsWithStartupCleanup, which calls processRemoteOps with an
(empty) options object. Update the assertion to match the 2-arg call so the
unit suite passes.
* refactor(sync): dedupe strong-ETag regex, document snapshot-op boundary
- Extract the duplicated RFC 7232 strong-entity-tag pattern into a single
STRONG_ETAG_RE constant with a comment noting why the char class is safe to
interpolate into an If-Match header (no CR/LF header splitting).
- Explain why sv === undefined ops are classified as snapshot-included: they are
legacy migration ops fully contained in the snapshot, and _validateSnapshotRef
enforces a clock-EQUAL boundary. No behavior change.
* fix(tasks): make REST project moves atomic
Capture affected subtasks in the persisted update action so project moves replay consistently, and repair stale project and section references during archive and restore.
Fixes#8983
* fix(tasks): harden project move replay
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* @
feat: suppress idle dialog during focus mode sessions
Add a new setting (default: ON) to suppress the idle time dialog when a
focus mode session is actively running. The idle popup no longer
interrupts Pomodoro, Flowtime, or Countdown sessions.
Changes:
- Add isSuppressIdleDuringFocusMode field to IdleConfig
- Add checkbox to Settings → Time Tracking → Idle Handling
- Skip idle trigger in idle.effects.ts when focus session is active
- Add translations for all 28 languages
Closes: #1834
References: #1676
@
* feat: add opt-in setting to suppress idle dialog during work sessions
Address review feedback: narrow scope, default OFF, fix existing-user config merge.
Changes:
- default: isSuppressIdleDuringFocusMode changed from true to false (opt-in)
- reducer: deep-merge idle section on loadAllData so the new field defaults
properly for users with persisted idle configs
- i18n: revert all non-English translation files, keep only en.json
- effects: fix Prettier/ESLint indentation
- tests: add reducer test for idle section merging + idle-effects spec
- docs: add setting to Settings-and-Preferences.md
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: repair idle-effects test harness for CI
- Use ReplaySubject<void>(1) so onReady$ replay survives late subscription
- Provide LOCAL_ACTIONS token + provideMockActions
- Fix TS2740 type mismatch on chromeInterfaceMock.onReady$
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: add project-scoped Angular MCP
* chore: update npm for release-age policy
* fix(sync): preserve LWW outcomes across clients
Distinguish replacement snapshots from partial merge operations, protect device-local sync configuration, recreate winning deletes, and resolve every conflicted entity in bulk operations.
Fixes#8956
* fix(sync): harden mixed LWW conflict replay
Preserve unaffected remote and local bulk intents, keep device-local sync settings during replacement replay, and gate replacement semantics behind schema v3.
* fix(sync): recover subtask subtree and harden LWW replay follow-ups
Follow-up hardening for #8956 after multi-agent review:
- Recreate a locally-winning parent's subtasks when a remote bulk delete
is a mixed winner. The full remote delete is applied (cascade-deleting
the parent's subtasks via handleDeleteTasks) but only the parent had a
compensation op, so the subtree was silently lost across devices.
- extractUpdateChanges: scan array-valued payload props instead of guessing
`${payloadKey}s`, so irregular bulk keys (e.g. taskUpdates) no longer
return {} and drop a remote winner's changes.
- Degrade gracefully instead of throwing when a remote update wins over a
local delete with no reconstructable base entity, matching the
single-entity path (a permanent sync wedge is worse than the bounded
divergence it already accepts).
- Remove dead code (unused `deleting` set + zero-caller wrapper), use the
Set-based scoped-bulk-delete filter, type meta via LwwUpdateMode, and
restore the withLocalOnlySyncSettings rationale comment.
Adds regression tests for the subtree-recovery and irregular-bulk-key paths.
* fix(sync): preserve conflict outcomes during replay
Persist replacement LWW operations and bulk-delete snapshots so reconstructed state matches the result applied live. Bump the op-log DB version to prevent older clients from opening the incompatible schema.
* fix(sync): persist conflict outcomes atomically
Write remote losers, local compensations, and final remote winners in one IndexedDB transaction so crashes cannot expose a partial replay order. Add real-store coverage for live/replay equivalence and transaction rollback.
* fix(sync): preserve multi-entity conflict recovery
* fix(sync): close review gaps in multi-entity conflict recovery
- recreate a winning parent's subtasks when a remote DELETE loses
outright (single-entity or all-local-win bulk), so clients that
applied the delete and status-blind hydration replay converge
- apply the combined resolution batch in durable seq order so a
pending row reused from a prior failed attempt replays identically
live and after a crash
- restamp converted remote updates carrying the v3 replacement
envelope to the current schema version
- strip the virtual TODAY tag from LWW task payloads and shallow-merge
patch-mode singleton payloads instead of replacing feature state
- pin the server snapshot fast-path spec to CURRENT_SCHEMA_VERSION
(fixes the CI failure from the v2-to-v3 bump)
* test(sync): pin outright-losing delete convergence across clients
Three-way real-reducer/real-store convergence for the pure-loser path
(live == restart replay == originating client), covering both the
same-batch recreate exemption and the cross-batch recreate path.
Verified to fail against the pre-fix service.
* fix(sync): enforce conflict-journal retention mid-session, not only at start
pruneOnStart (14 days / newest 200) ran only at startup, so a long-lived
session could grow SUP_CONFLICT_JOURNAL unboundedly. record() now checks
the store count (cheap) and, when it exceeds JOURNAL_MAX_ENTRIES plus a
slack of JOURNAL_PRUNE_SLACK, runs the same age+count prune — amortized
to once every JOURNAL_PRUNE_SLACK records. The prune core is extracted
and shared with pruneOnStart; the observe-only never-throw contract of
record() is unchanged.
SPAP-36 (deferred LOW from PR #8874 review)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): await tx.done with the deletes in the conflict-journal prune
An aborted prune delete transaction rejects both the delete requests and
tx.done. Awaiting them separately (Promise.all(deletes), then await tx.done)
leaves tx.done's rejection unhandled once the delete aggregate rejects first, so
it escapes as a global unhandled rejection during active conflict resolution.
Await both in one Promise.all so tx.done always has a handler. Adds an
aborted-transaction regression test (fails on the old sequencing) and updates
the retention model comment + doc to reflect mid-session pruning.
Addresses maintainer review on #8948.
SPAP-36
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): prevent destructive full-state sync races
Use causal ordering and server-sequence preconditions for full-state operations, surface snapshot rejection, deduplicate migrations, and retain queued WebSocket downloads.\n\nFixes #8959
* fix(sync): block uploads after rejected imports
Keep incremental operations behind a durable barrier when a local import or restore is rejected. Release the barrier only after a newer full-state snapshot is accepted, and surface the blocked state instead of reporting sync success.
* fix(sync): harden causal repair recovery
Persist repair base cursors across the client, provider, and server so stale snapshots are rejected before quota or history mutation and legacy repairs cannot poison restore state.
Atomically rebase stale repairs after downloading the missing suffix, preserve rejected-import barriers and WebSocket watermarks, and cover PostgreSQL serialization.
* fix(sync): harden repair recovery edge cases
Block dependent uploads after any rejected full-state operation, reset negotiated capabilities across provider config changes, and bound WebSocket download retries.
Update sync test doubles for causal repairs and the expanded duplicate-operation identity.
* fix(sync): reject forged encrypted full-state operations
Validate decrypted import and repair payloads against the full-state schema so authenticated ordinary operations cannot be promoted into destructive full-state operations.
Closes#8905
* fix(sync): preserve compatible encrypted full-state payloads
Normalize only known wire and legacy omissions on the validation copy so stripped device-local settings and pre-section backups are not mistaken for metadata tampering.
* test(sync): cover encrypted full-state round trips
* fix(sync): reject disjoint merges for multi-entity ops
Scope conflict field extraction and journal titles to the actual entity. Fall back to whole-op LWW whenever either side is multi-entity so sibling updates are never falsely reported as preserved.\n\nFixes #8944.
* fix(sync): harden multi-entity conflict resolution
* fix(sync): preserve offline operations and harden auth
Accept operations from long-offline clients without rewriting their timestamps, and retain a replayable full-state base during cleanup. Neutralize registration account discovery and suppress repeated login and recovery token emails with atomic claims.
Fixes#8961
* fix(sync): harden auth and retention edge cases
Make unauthenticated auth responses neutral across delivery and resend failures, isolate WebAuthn ceremonies, and consume login and recovery tokens atomically. Add monitored handling for histories without a replay base and strengthen regression coverage.
Refs #8961
* perf(sync): reduce cleanup replay-base queries
Reuse the maintained full-state sequence marker during retention cleanup while preserving a query fallback for legacy and stale-marker rows. Clarify no-base warnings and strengthen exact auth-response and recovery transaction tests.
Refs #8961
* fix(sync): reject non-integer op timestamps before BigInt persistence
A non-integer or non-finite client timestamp passed the schema
(timestamp is z.number(), not .int()) and threw at BigInt() during
upload, aborting the whole batch as an unstructured 500. Reject it in
validateOp as a per-op INVALID_TIMESTAMP instead. Op age stays
unbounded so long-offline backlogs are still accepted.
Refs #8961
Update the archive-operation lifecycle docs to the atomic
reducer-checkpoint + clock-merge design, record the IndexedDB v8
downgrade barrier, drop the removed PENDING_OPERATION_EXPIRY_MS
constant, and describe the durable Use-Server-Data Undo across reloads.
* feat(sync): conflict-journal foundation (observe-only)
Add a device-local IndexedDB conflict journal that records every sync-
conflict auto-resolution so the discarded ("losing") side is preserved
and reviewable later. Foundation subtask for the conflict-review epic;
no UI here, verifiable purely by unit tests.
- New SUP_CONFLICT_JOURNAL IndexedDB store (own DB; never touches the
op-log SUP_OPS schema) + ConflictJournalService with record/query API
(unreviewedCount$, list, markKept, markFlipped, getEntry) and 14-day /
200-entry retention pruning, wired to run on app start via APP_INITIALIZER.
- Pure classifier buildConflictJournalEntry maps each resolution to the
agreed taxonomy (newer/tie/delete-wins/noise/clock-corruption-
suspected; disjoint-merge reserved for the next subtask), capturing the
loser's field values verbatim. NOISE_FIELDS is limited to metadata
timestamps (modified/lastModified/created): the list-ordering arrays
carry membership as well as order, so an overlap on them is surfaced as
a reviewable conflict rather than silently classified as noise.
- Emission is strictly observe-only: journaling runs after the LWW plan is
built, wrapped in try/catch (record() swallows its own errors); clock-
corruption attribution uses a WeakSet side-channel tagged at detection.
The existing conflict-resolution suite stays 138/138 green, proving LWW
picks are unchanged. One-sided / sequential / EQUAL updates never become
conflicts and produce zero journal entries.
SPAP-13
* feat(sync): disjoint-field auto-merge for concurrent edits
When two clients concurrently edit the same entity but different fields
(A changes title, B changes notes), whole-entity LWW previously discarded
one side. Keep both when the non-noise changed-field sets are disjoint.
In _resolveConflictsWithLWW, before LWW picks a winner, each CONCURRENT
conflict is tested for merge eligibility (neither side deleting/archiving;
both changed >=1 real field; non-noise field sets disjoint). If eligible,
synthesize a single merged UPDATE op — the current entity overlaid with
the other side's non-noise fields, noise fields resolved by the greater
(timestamp, clientId) — carrying a vector clock that dominates both sides,
so it propagates through normal sync. The resolution is winner 'merged'
and is journaled reason 'disjoint-merge' / status 'info' (not counted as
unreviewed). Any overlap on a non-noise field, or a delete/archive on
either side, falls through to the existing LWW path unchanged.
Convergence: both clients compute the byte-identical merged entity
(disjoint real fields each owned by one side; noise resolved by the same
global tiebreak) with clocks dominating both originals, so the two
independently-synthesized merged ops carry identical full-entity payloads
and re-resolve to the same state via ordinary LWW without re-merging.
No sync-core/protocol change. Existing conflict-resolution suite stays
138/138; SPAP-13 journal specs stay green.
SPAP-14
* feat(sync): sync conflicts review UI (banner, badge, page, flip)
Builds the conflict-review UI on top of the device-local conflict journal.
- Post-sync summary banner "N conflicts auto-resolved (X remote, Y local
won)" with REVIEW / DISMISS, replacing the bare LWW_CONFLICTS_AUTO_
RESOLVED snack at its emission sites; a persistent badge on the sync
icon bound to unreviewedCount$ (survives banner dismiss).
- New /sync-conflicts page: Unreviewed | History tabs, rows grouped by
entity type with winner + reason chips, expandable per-field diff
(LOCAL vs REMOTE, device name + wall-clock time, winner marked),
per-row KEEP / FLIP and bulk KEEP ALL / FLIP ALL → LOCAL / → REMOTE.
History renders merged auto-merges as per-field chips.
- Flip dispatches a normal entity update with the loser's journaled field
values (syncs like a user edit, no history rewind) and marks the entry
flipped; a stale-flip confirm appears (with the current value shown)
when the entity changed since resolution.
- i18n under F.SYNC.CONFLICT_REVIEW.
Delete-restore and archived-entity flip are surfaced as unsupported for
now (an update op can't recreate an absent entity) — follow-up.
SPAP-15
* fix(sync): count disjoint-merge ops in localWinOpsCreated
autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.
Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).
Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.
Addresses review feedback on PR #8874.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(sync): address second-pass review (delete-lost, archive guard test, polish)
Second-pass review follow-ups (johannesjo). The MEDIUM localWinOpsCreated
item was already fixed in 23e9a56.
Important:
- delete-lost classification: when a delete LOSES to a concurrent newer edit,
the loser side is a pure Delete op (no field changes), so it fell through to
`noise`/`info` and never surfaced. Add a `delete-lost` reason classified as
`unreviewed` (inverse of `delete-wins`), checked before the noise fallthrough.
+ regression spec.
- archive-vs-disjoint-edit test (d2): an archive is an UPDATE, so eligibility
does NOT reject it — only the `_isArchivePlan` guard does. New test forces
eligibility TRUE and asserts no merged op is synthesized, so a guard
regression now fails a test (previously nothing covered it).
Minor:
- pruneOnStart: wrap in try/catch (log + return 0, matching record()'s
observe-only contract); reset the poisoned `_initPromise` in `_ensureDb` on
open failure so a transient IndexedDB error can't wedge the service.
- sync-conflicts-page.scss: use `--color-success` token + color-mix tint
instead of hardcoded #4caf50 / rgba(76,175,80,…); drop the dead
`--color-warning` fallback (#e6a817 didn't match the real #ff9800 token).
- main-header badge: matBadgeColor warn -> accent (a resolved count is not an
error); move the count announcement to `matBadgeDescription` and give the
button a stable sync-action aria-label (it was null at count 0, leaving the
icon-only button unnamed).
- remove dead i18n key LWW_CONFLICTS_AUTO_RESOLVED (t.const.ts + en.json).
- add direct unit spec for noiseTiebreakSide (incl. equal-timestamp clientId
determinism) and buildMergedFieldDiffs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(sync): address third-pass review (flip capability, field presence, opaque ops, profile isolation)
Blocking #3 — delete-lost/delete-wins offered a false-success Flip:
canFlip() is now reason/field-aware (refuses delete-lost/delete-wins,
empty loser changes, and relationship-bearing fields whose bare adapter
update would bypass meta-reducer invariants); flip() guards on it and
only marks an entry flipped when an op was actually dispatched.
Blocking #2 — field absent vs side-set-undefined: fieldDiffs now record
per-side presence (localChanged/remoteChanged); loserChangesFor/
winnerChangesFor only emit fields the side actually changed, so Flip no
longer clears winner-only fields and the stale guard no longer compares
undefined. Legacy entries without flags fall back to value-presence
(exact, since op payloads are pure JSON).
Blocking #1 — non-adapter action payloads: per-op extraction now falls
back to capture-time entityChanges (TIME_TRACKING/syncTimeSpent), and
ops with no readable delta (convertToSubTask & co) are "opaque": never
classified noise/info, preserved verbatim as kind:'action' diffs for
review, excluded from flip/stale computations, and never disjoint-merge
eligible (merging would drop the mutation and diverge the two clients).
Profile isolation — switchProfile clears the conflict journal
(ConflictJournalService.clearAll) so a new profile cannot see the
previous profile's entity data or Flip against the wrong dataset.
Also: replace the as-any selector cast with a typed union-member
narrowing, and add the required docs (sync-and-op-log/
conflict-journal-and-review.md incl. the atomicity/no-re-merge
contract, wiki 3.06 + 4.23).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): address fourth-pass review (flip short-syntax, selector throws, restore isolation)
HIGH — a title-only flip matched shortSyntax$'s exact trigger shape, so
#tag/+project/@schedule tokens in the discarded title re-parsed into
cross-entity mutations: the TASK flip now dispatches with
isIgnoreShortSyntax: true (journaled literal value, not user input).
MEDIUM — selectTagById/selectNoteById THROW on a missing entity, so
expanding (or flipping) a TAG/NOTE row whose entity is gone rejected
unhandled before the !current guard: _readCurrentEntity now catches and
returns undefined, and getStaleState/flip degrade gracefully.
MEDIUM — the journal survived backup restores: clearAll() moved to the
BackupService.importCompleteBackup chokepoint, covering every full
dataset replacement (profile switch, JSON import, local-backup restore,
SuperSync restore) instead of only the profile switch.
LOW — ISSUE_PROVIDER's factory selectById is now special-cased (was
rendering the inner selector function as the "current" entity);
schedule/reminder fields (dueDay/dueWithTime/deadline*/reminderId) join
the flip blocklist (renamed FLIP_UNSAFE_FIELDS) since their invariants
live in dedicated flows; loser/winnerChangesFor early-return for merged
entries (their tiebreak diffs carry pickedSide, so the per-diff check
alone did not exclude them).
Docs updated accordingly (dev doc + wiki 3.06/4.23).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): refuse flip for remindAt/deadlineRemindAt (reminder-lifecycle)
FLIP_UNSAFE_FIELDS is a deny-list: any Task field not listed is flippable
via a bare updateTask. reminderId was covered, but the sibling reminder-
lifecycle timestamps remindAt and deadlineRemindAt were not — a conflict on
either alone (e.g. concurrent deadline-reminder-lead edits) would pass
canFlip and, when flipped, write the field without scheduling/cancelling the
actual reminder in ReminderService, leaving a dangling/missing notification.
Add both to FLIP_UNSAFE_FIELDS, document the deny-list drift risk, and pin
the reminder/schedule members with per-field canFlip=false spec cases.
* fix(sync): make disjoint-merge convergent + close flip stale-guard blind spot
Two confirmed cross-device data defects found in the SPAP-14 whole-PR review:
1. Full-entity merged op diverges (CRITICAL). The merged op was a full-entity
snapshot of each client's CURRENT state, so any field NEITHER conflicting
side touched rode along. Under ordinary staggered sync (one client already
applied a third device's edit to that field, the other not yet), the two
clients synthesize different snapshots that tie under LWW at the identical
max(timestamp) and diverge PERMANENTLY. Fix: synthesize a PARTIAL delta
(union of the two sides' changed fields only), derived purely from the ops
so both clients compute the byte-identical map; lwwUpdateMetaReducer applies
it via updateOne (shallow merge), leaving untouched fields alone.
synthesizeMergedEntity -> synthesizeMergedChanges.
Also: detectConflicts emits one conflict per remote op with no per-entity
aggregation, so an entity with >=2 concurrent remote ops synthesized multiple
merged ops whose clocks dominate one another -> a dominated sibling's field
is silently dropped, falsely journaled as 'kept both'. Fix: refuse merge for
any entity with >1 conflict this batch and fall back to whole-entity LWW.
2. Flip stale-guard blind to loser-only fields (HIGH). getStaleState only
compared WINNER-changed fields, but flip writes LOSER-changed fields. A
post-resolution edit to a loser-only field was undetected and silently
overwritten. Fix: also flag stale when a loser-only field flip will write
diverged from the value flip would write; bulk flipAllToSide now SKIPS stale
entries instead of silently applying them.
Adds regression specs for the un-conflicted-field ride-along, multi-remote-op
refusal, and loser-only stale detection (per-entry + bulk). Full conflict suite
green (disjoint-merge 12, ui 24, conflict-resolution 138, journal 20, hook 6,
util 14, review-util 13, superseded 42, banner 3, page 6).
* fix(sync): scope flip stale-guard loser-only check to remote-won entries
Re-review of efe66d7 found the new loser-only stale check false-positives on
LOCAL-won conflicts: there the loser is the REMOTE side, whose value was never
applied (current holds the un-recorded base), so current != flipVal is the
NORMAL post-resolution state, not an edit. That made flipAllToSide silently skip
legitimate local-won entries and single flip nag with a spurious confirm.
Scope loserOnlyStale to winner==='remote' (the only case where the loser's
optimistically-applied local value persists, giving a valid unedited baseline).
Remote-won silent-loss detection (the originally-reported defect) is unchanged.
Loser-only fields on LOCAL-won entries remain undetectable without a journaled
post-resolution baseline — documented as a follow-up. +2 regression specs
(local-won no-false-positive: getStaleState + bulk flip).
* fix(sync): restrict disjoint-merge to types with a RECREATE_FALLBACK
The merged op is a partial delta. If it wins over a concurrent DELETE on a
passive-observer client (one that already applied that delete), it reaches
lwwUpdateMetaReducer's addOne recreate branch WITHOUT passing through the
full-entity reconstruction in _convertToLWWUpdatesIfNeeded (that runs only for
conflict winners, not non-conflicting remote ops). For a type without a
RECREATE_FALLBACK (NOTE/METRIC/TASK_REPEAT_CFG/ISSUE_PROVIDER) the bare partial
addOne yields a Typia-invalid entity -> 'Repair failed' dead-end; the parent's
full-snapshot merged op recreated validly, so the partial delta is a regression
there.
Refuse disjoint-merge for fallback-less types (fall back to whole-entity LWW,
whose local-win op carries a full snapshot). Residual: fallback types can still
recreate with DEFAULT_* backfill diverging in that rare 3-device race — the same
bounded limitation already documented in recreate-fallback.const.ts. +regression
spec (NOTE disjoint conflict -> LWW, not merged).
* fix(sync): harden conflict-journal failure and lifecycle paths
Three hardening improvements from the final review pass:
1. Journal disjoint merges only AFTER the merged op is durably appended
(STEP 3b), not at plan time. A 'merged' entry claims both sides were
kept, which is only true once the op is persisted — an append failure
could previously leave a phantom 'kept both' journal entry. +regression
spec (a6): append failure -> no merged journal entry.
2. Extend the never-throw contract to ALL ConflictJournalService methods.
list() is awaited (via maybeShowSummaryBanner) inside
autoResolveConflictsLWW's notification step — i.e. after ops were
already applied — so a transient IndexedDB failure there failed an
otherwise-completed sync. list -> [], getEntry -> undefined,
markKept/markFlipped swallowed (entry stays unreviewed, user retries).
+spec.
3. Recover from abnormal IndexedDB closure: idb's terminated hook now
drops the memoized _db/_initPromise handles so the next call reopens
instead of failing on a dead connection for the rest of the session
(deferred fourth-pass item). +spec.
Plus: reciprocal note in recreate-fallback.const.ts that membership also
opts a type into disjoint-merge, and doc updates for the new contracts.
* test(sync): pin the terminated-hook wiring in the journal recovery spec
The recovery spec called _resetDbHandles() directly, so removing the
terminated: callback from openDB (functionally reverting the force-close
recovery) kept all tests green. Fire the real 'close' event idb listens
for instead (duck-typed FakeEvent for fake-indexeddb) and assert the
handles were cleared. Mutation-verified: deleting the terminated line
now fails this spec.
* fix(sync): clear conflict journal inside the op-log lock on import
Author-review finding: importCompleteBackup released the OPERATION_LOG
lock before clearAll(), leaving a narrow cross-tab window where a
concurrent conflict resolution's fresh post-import journal entry gets
wiped. Clearing inside the lock serializes the clear strictly before any
post-import journaling. (_resetAllLastServerSeqs is journal-independent
local bookkeeping and keeps its persist-first ordering.)
Also documents the merged-op composition residual from the same review:
a merged partial op is not closed under later whole-op LWW composition
in the no-pending-local concurrent-apply path — verified pre-existing
(branch and behavior identical at the pre-SPAP-14 merge-base with plain
user ops), so recorded as a class-level op-log residual rather than
patched here.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* docs(wiki): document CHROME_BIN setup for unit tests
The pre-push hook runs the Karma unit tests, which need a resolvable
Chrome binary. Without a system Chrome or CHROME_BIN set, git push
fails with "No binary for Chrome browser". Add a section covering both
a system Chrome install and installing Chrome for Testing via
@puppeteer/browsers with CHROME_BIN wired into the shell profile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(wiki): note snap/flatpak caveats for the test browser
Snap Chromium works but isn't auto-detected (set CHROME_BIN=/snap/bin/chromium);
Flatpak is not recommended because karma-chrome-launcher execs the binary
directly and the sandbox blocks Karma's temp profile dir.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Improve documentation around Chromium
* add bash to codeblock
* docs(wiki): fix Chrome-for-Testing install path and Chromium claims
Address review feedback:
- Option A: only real Google Chrome is auto-detected on Linux
(karma-chrome-launcher probes google-chrome/-stable, not chromium);
drop the overstated "finds it automatically" for Chromium.
- Option B: pin `--path "$HOME/.cache/puppeteer"` — the standalone
@puppeteer/browsers CLI defaults to the cwd, so the bare command
downloaded chrome into ./chrome and left CHROME_BIN empty.
- Note macOS differs (binary is "Google Chrome for Testing" in a .app,
so `find -name chrome` is Linux-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Human polish of instructions
* final PR feedback
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A]
* update readme and remove-unused-log-imports.ts
* restore benchmark test and make runnable
* fix(app): hide donation page on macOS
Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds.
* fix(app): harden donation platform gating
Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation.
* fix(sync): defense-in-depth vs entityId retarget on encrypted ops
SuperSync E2EE (AES-256-GCM) covers only op.payload; metadata fields
(entityId, opType, actionType, vectorClock, isPayloadEncrypted, ...)
travel as plaintext and are not bound by the auth tag. A malicious/
compromised sync server or MITM could retag an encrypted LWW-update op
with a different entityId, redirecting the authenticated changes onto an
attacker-chosen entity — convertOpToAction() previously trusted the
tampered entityId over the authenticated payload.id (coercing even a
missing payload.id) and only warned.
At the decrypt boundary (where encryption origin is known) verify that
an in-scope LWW op's authenticated payload carries a string id equal to
op.entityId; otherwise fail closed via a new OperationIntegrityError,
distinct from DecryptError so it does not trigger the enter-password
dialog. The gate mirrors convertOpToAction's predicate (alias resolution
+ singleton exclusion) so the two boundaries cannot drift.
Scoped defense-in-depth for GHSA-8pxh-mgc7-gp3g, NOT full integrity.
Still open (durable AAD-envelope fix): plaintext-injection downgrade via
isPayloadEncrypted=false (needs a download-side mandatory-encryption
guard), opType promotion, entityType swap, vectorClock replay. Correct
the overstated integrity claim in the encryption architecture doc.
* fix(sync): reject plaintext ops when SuperSync encryption is mandatory
The isPayloadEncrypted flag is unauthenticated plaintext metadata, so a
compromised SuperSync server or MITM could set it to false and inject a
fully attacker-authored plaintext op — it would skip decryption AND the
payload/metadata integrity check and be applied verbatim (arbitrary op
forgery). This is a strictly more powerful bypass than the ciphertext
entityId retarget closed previously.
assertOpsEncryptedWhenExpected rejects any inbound plaintext op (download
+ piggyback paths) when encryption is enabled. It gates on config INTENT
(isEncryptionMandatory && isEncryptionEnabled()), not key presence, so it
also fails closed in the dropped-credential state (a !!encryptKey gate
would fail open there). Safe with no legacy-data loss: enabling
encryption deletes the server copy and re-uploads everything encrypted,
so no legitimate plaintext op remains; a never-encrypted account
(isEncryptionEnabled()===false) still accepts plaintext. The SuperSync
op-level twin of the file-based GHSA-vrc7 download guard and the
GHSA-9544 upload guard.
Also give OperationIntegrityError a dedicated sync-wrapper branch: fail
closed with a calm translated message instead of the raw GHSA/technical
string.
Follows up the review of GHSA-8pxh-mgc7-gp3g.
Record the LocalFile check-then-write TOCTOU race as an accepted limitation
and correct stale references in the reliability doc:
- §5 documents the non-atomic rev-check + write as an accepted limitation,
honestly scoping each mitigation: the upload lock only serializes a single
client's own uploads (not across machines); the mismatch-fallback catches only
a concurrent write visible at check time (never force-overwriting), so a write
landing inside the check→write window can still be lost; .bak recovery is
best-effort and only covers a corrupt/interrupted primary, not a valid
concurrent overwrite or a fully-missing one.
- Distinguishes torn writes (prevented on Electron via temp-file + renameSync;
still in-place on Android SAF) from the CAS race (not closed by atomic rename;
needs OS-level CAS, left accepted).
- §1 corrected: _uploadWithRetry()/:474/"retries once" → current
_uploadWithMismatchFallback with _MAX_UPLOAD_RETRIES=2 (3 attempts), and clarify
that genuine concurrency throws immediately rather than exhausting retries.
* style(tasks): elevate add-subtask input
* fix(tasks): parse eml files without external dependency
Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy.
* fix(tasks): isolate and harden eml import
Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage.
* fix(tasks): decode eml headers and harden import parsing
- Decode RFC 2047 encoded-words in the subject and sender name so
international titles show "Grüße" instead of raw "=?UTF-8?...?=".
- Replace the charset regex with a quote-aware Content-Type parser so a
charset inside another quoted parameter can't be mistaken for the real
one, and ignore RFC 822 header comments (e.g. "7bit (comment)").
- Cap the synced title (300) and body (100k) so a crafted .eml can't
amplify into an oversized op (the literal fence can double the body).
- Document parseEml's intentional MIME omissions to prevent a later
"fix" that reopens the untrusted-body attack surface.
* feat(plugins): add Todoist import plugin with Import/Export launcher
* fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening
* fix(plugins): clamp non-finite Todoist durations + review polish
* feat(plugins): add Eisenhower priority mapping to Todoist import
Replace the single "map priorities to p1-p3 tags" checkbox with one
mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix).
The new Eisenhower option reuses SP's built-in urgent/important tags by
title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none),
so imported tasks populate the Eisenhower Matrix board with no new tag
and no new plugin API. Collapsing Todoist's single priority axis onto the
2-D matrix is opinionated, so it stays opt-in and off by default.
Requested in the review of #8882.
* fix(plugins): harden Todoist import data integrity
* fix(plugins): improve Todoist import feedback
* feat(eml): add EML file parsing and task creation on drop
Currently a simple skeleton, needs more work, but should be a good POC before refining.
Fixes#518
* fix(main-header): correct email message formatting and handle EML upload errors
while this commit looks bad, its a first commit before refining this solution and sending the final PR.
Fixes#518
* refactor(eml): move eml-parser to util and tighten isFileEml check
* feat(eml): on eml hover error parse error to log and show a snack message
* bugfix(eml-typecheck): tighten type for 'sender' to not have bugs
* refactor(eml-drop): updated file locations for each file drop
* bugfix(eml-drop): use stable npm package for parsing eml files
* bugfix(eml-drop): remove relevant "dev" flag
* refactor(eml-drop): drop empty emails with a snack instead of throwing
* test(eml-drop): add isFileEml and parseEml unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* rafactor(eml-service): refactor code to be consistent as written in issue number 3
* bugfix(eml-drop): remove file name when geting an error
as written in 1. inside the pr
* docs: update task creation instructions to include .eml file support
* refactor(eml-parser): update import statement for PostalMime to dynamic import
* refactor(eml-drop): enhance file drop handling and improve error logging
* test(eml-drop): add unit tests for EmlDropDirective and EmlDropService
* feat(tasks): save eml body to task notes and harden eml parsing
- capture the parsed plain-text email body into task notes (text/plain
only, never HTML, to avoid injecting untrusted markup into markdown notes)
- pass the File/Blob straight to postal-mime so it applies the message's
own charset/transfer-encoding instead of forcing UTF-8 via file.text()
- use relative import paths to match repo convention
- document why the drop handler relies on the global dragover preventDefault
- update wiki wording + tests for the new add() signature and notes behavior
* fix(tasks): harden eml-to-task against untrusted subject/size
Follow-up to the eml-drop feature from a multi-agent review:
- suppress short-syntax on imported email subjects: email content is
untrusted external input, but TaskService.add() dispatches addTask which
ShortSyntaxEffects parses for #tag/@date/+project/URL tokens — stripping
them from the intended literal "sender: subject" title and mutating
tags/scheduling/attachments. Thread isIgnoreShortSyntax through add()
(matching the existing plugin-bridge pattern) and set it here.
- guard against oversized .eml files (postal-mime parses synchronously on
the main thread and the body syncs to every device) with a 10MB cap +
EML_TOO_LARGE snack.
- trim sender/subject so whitespace-only headers don't bypass the empty
check or block the address fallback.
- drop the stale "add attachment" TODO.
* fix(tasks): log bounded eml parse reason, not the raw error
The source is untrusted email content and the log history is exportable
(rule #9). Log only e.message (postal-mime's throw messages are structural,
so no email content leaks) instead of the raw error object + stack.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* feat(task-repeat): default skip-overdue for new everyday recurring tasks
New recurring configs now default skipOverdue ("Don't let overdue instances
pile up") to ON only for a plain everyday schedule — the "Daily" preset or a
CUSTOM every-single-day cycle. That is the one case where the option is both
useful and provably safe: everyday tasks are the only schedule that actually
piles up (one empty overdue copy per missed day), and today is always
scheduled so a missed instance regenerates the same day and can never
silently vanish (it cannot even drop to zero).
Every other schedule stays OFF — workday/weekly/monthly/yearly and every-N-day
custom cycles keep their one missed occurrence visible, so a real obligation
(pay rent on the 1st, renew the domain) never disappears until its next
occurrence. Deriving the default purely from the effective schedule means the
"Daily" preset and a CUSTOM every-day cycle (the same schedule entered two
ways) get the same default — no "same schedule, different default" surprise.
The default is seeded from the chosen schedule in both config-creation paths:
the repeat dialog (re-derived from the final schedule on save, and skipped
when the user explicitly toggled the Advanced checkbox) and the inline
add-task-bar recurrence. Existing configs and DEFAULT_TASK_REPEAT_CFG
(skipOverdue: false) are unchanged; only newly created configs are affected.
Supersedes the broader daily+Mon-Fri variant, dropping the schedule-change
checkbox re-sync machinery: an Advanced checkbox opened on a Daily config and
then switched may briefly show a stale ON, but save always persists the safe
re-derived value.
* docs(task-repeat): clarify custom every-day default; signpost baseline
Multi-review follow-ups (no behavior change):
- Wiki 2.06 + 4.13: a CUSTOM every-single-day cycle also defaults skipOverdue
ON (it is the same schedule as the Daily preset); "off" now reads "custom
cycles longer than a day" so the docs match getDefaultSkipOverdue.
- Comment on DEFAULT_TASK_REPEAT_CFG.skipOverdue pointing to the schedule-aware
creation default, so the model's `false` baseline is not mistaken for the
effective default.
- Tighten the save() display-gap comment: the cosmetic seeded-ON/persist-OFF
gap applies to any new non-Daily config whose Advanced panel is opened
untouched, not only a Daily→switch.
* refactor(task-repeat): use type-only import in skip-overdue predicate
TaskRepeatCfgCopy is used only in type position; match the codebase's import type convention (58 other files). No behavior change.
* fix(op-log): serialize SQLite adapter transactions on shared connection
The SQLite op-log adapter issued raw BEGIN/COMMIT on the shared
connection with no serialization. Once both stores share one SqliteDb
(the staged native rollout), concurrent operations (capture append,
archive write, compaction) would interleave BEGINs: SQLite has no
nested transactions, so a second BEGIN throws and a bare statement
issued mid-transaction silently joins — and rolls back with — the
foreign transaction, corrupting op-log state.
Funnel every adapter entry point through an internal FIFO queue so a
transaction is exclusive on the connection for its whole BEGIN/COMMIT
and no bare operation interleaves. Transaction-internal work runs
directly on the connection (already holding the slot), so there is no
re-entrancy. Document the mutual-exclusion invariant in the port
contract and add a concurrent-transactions contract test that runs on
both the in-memory fake and real sql.js.
* docs: add complete architecture review report (2026-07-07)
Whole-app architecture review synthesized from eight parallel subsystem
reviews and an adversarial verification pass; findings filed as issues
#8832-#8843, with duplicates cross-referenced rather than re-filed.
* fix(op-log): key the SQLite transaction serializer to the connection
Multi-agent review found the serializer was keyed to the adapter
instance. The native rollout hands the op-log store and archive store
two separate SqliteOpLogAdapter instances over one shared SqliteDb, so
per-instance queues left an op-log BEGIN free to interleave with a
concurrent archive BEGIN on the shared connection — the exact hazard the
serializer targets.
Key the FIFO queue to the connection (WeakMap<SqliteDb>) so every adapter
over one SqliteDb shares one queue. Add a contract test that drives two
adapters over one connection concurrently (verified red with per-instance
keying, green with per-connection). Also: document the re-entrancy
precondition as unenforced (a lint rule, not a runtime flag, is the right
guard — a flag cannot distinguish a re-entrant call from a legal
concurrent one) and correct init/getLastSeq/port-contract doc drift.
The breakpoint table in docs/styling-guide.md had four wrong values
(xxs at 450px, s at 800px, m at 1000px, l at 1200px) and three wrong
names (s instead of sm, m instead of md, l instead of lg) compared to
the actual SCSS variables in _media-queries.scss and CSS custom
properties in _css-variables.scss.
Co-authored-by: Debian <x@zak.linux>
* feat(tasks): move add-task-bar toggles into the actions row
The add-to-top/bottom and search toggles used to overlay the input's
top-right corner. They now sit at the start of the actions row (create
mode) and the search info row (search mode), prepended so they scroll
together with the rest of the row, separated by a dashed divider and
sized to match the neighbouring action chips.
A new ng-content slot on add-task-bar-actions lets the parent project
the toggles into the scrollable row; they are still rendered in the
search row when the action bar is not, so search stays toggleable.
* feat(tasks): wrap long add-task-bar titles onto multiple lines
The title field is now an auto-growing textarea (cdkTextareaAutosize) so a
long title wraps into view instead of scrolling off the right edge, which
is especially helpful on narrow screens. It stays single-line in meaning:
Enter still submits and pasted newlines are collapsed to spaces so no line
break ever reaches the task title.
cdkTextareaAutosize sizes the textarea to rows*line-height and ignores its
own padding, so the vertical breathing room lives on a wrapper element and
the field grows a line at a time. matInput is dropped (there is no
mat-form-field here and it broke the autosize height calc).
E2E selectors that targeted the title by the `input` tag now use the
tag-agnostic `.main-input` class.
* feat(tasks): refine add-task-bar toggles, note field and input autosize
- Move the note control from a labeled chip into an icon toggle in the
left group (search · note · add-to-top/bottom); hidden in search mode.
- Renumber the input shortcuts: Ctrl+1 add-to-bottom, Ctrl+2 search,
Ctrl+3 note, Ctrl+4-9 the action chips.
- Keep the submit (+) button's space reserved while empty via opacity
(visibility is re-asserted by mat-icon's anti-FOUC rule, so it stayed
visible).
- Animate the note field expand/collapse with the shared @expandFade.
- Fix a cdkTextareaAutosize height lag: the directive measures the
textarea with its horizontal padding stripped but its width unchanged,
so padding on the textarea made the measured wrap width wider than the
rendered one and the field grew a few chars after the text had already
wrapped (clipped line + scrollbar). Keep the title and note textareas
padding-free and move the gutters to their wrappers so measured and
rendered wrap widths match.
* feat(tasks): improve add-task-bar a11y, shortcut order and toggle emphasis
- Renumber the input shortcuts to match the on-screen left-to-right order:
Ctrl+1 search, Ctrl+2 note, Ctrl+3 add-to-top/bottom (chips stay Ctrl+4-9);
update the shortcut hints embedded in the tooltips accordingly.
- Add aria-label to every icon-only button and aria-pressed to the search
and note toggles so screen readers announce name and on/off state.
- Take the invisible submit (+) out of the tab order (disabled while empty).
- Guard the note toggle (and its Ctrl+2) to create mode; it is a no-op while
searching, where the note field does not render.
- Escape now dismisses an open task-suggestion list before closing the bar.
- Un-dim the toggles (0.6 + :focus-visible so keyboard focus isn't faint) and
give the submit + full-strength primary tint so the mobile CTA reads as the
primary action.
- Use the edit_note glyph for the note toggle (clearer than a chat bubble).
* refactor(tasks): apply add-task-bar multi-review cleanups
- De-duplicate the tooltip/aria-label expressions with @let (search,
backlog, add-to-bottom) so the two attributes can never drift.
- Split the Ctrl-shortcut map into local toggles (1-3) and action
shortcuts (4-9) so stopPropagation derives from structure instead of a
parallel key array that had to be kept in sync.
- Restore the "note attached" cue: the note toggle carries .has-value
(undimmed + tinted) when a collapsed note still holds text, distinct
from the .active pill shown while the note field is open.
- Remove dead code: the vestigial switch-add-to-btn class on the submit
button, the unused .search-input rule, a stale duplicated SCSS comment,
and the orphaned NOTE_BUTTON translation key.
- Document that the projected .inline-action-controls styles must stay in
the parent stylesheet (ng-content keeps the parent's encapsulation).
* feat(tasks): use notes bubble icon and restore expanded note draft
Match the notes icon used across the task feature (task list item,
detail panel, archived-task dialog) instead of the one-off edit_note
glyph. The glyph flips outline -> filled once the note field has text,
mirroring the detail panel's empty / has-notes states.
Also start the note field expanded when reopening the bar with a
persisted draft note, so it is visible rather than hidden behind the
collapsed toggle.
* fix(tasks): repair startup-overlay selector for add-task-bar textarea
The add-task-bar title field became a <textarea class="main-input">, but
StartupOverlayService still queried 'add-task-bar.global input'. A CSS input
type selector does not match a textarea, so on Android cold-start the partial-
text handoff (cursor position + focus) never ran and the overlay cleared only
via the 3s safety timeout. Target .main-input and retype to HTMLTextAreaElement.
Also address multi-review nits on the same branch:
- guard expandNote() in search mode so Ctrl+Enter cannot strand isNoteExpanded
(mirrors toggleNote()), with a spec covering it
- drop the unused fadeAnimation import and animations entry
- type the inputEl viewChild as ElementRef<HTMLTextAreaElement> (matches noteEl)
- repoint WorkViewPage.addBtn to .e2e-add-task-submit (.switch-add-to-btn now
belongs to the add-to-backlog toggle)
* test(tasks): clear add-task-bar sessionStorage between component specs
* docs(tasks): update add-task-bar keyboard shortcuts for new Ctrl mapping
* feat(android): add home screen widget for today's tasks (#3818)
Revives PR #7124 (POC by @ilvez) on current master with the review
punch-list addressed:
- today's tasks pushed as a widget_data KeyValStore snapshot (memoized
selector, debounced, hydration-guarded, re-pushed on sync-window end
and on pause); Angular is the single writer of the blob
- done-checkbox taps go through a SharedPreferences queue only; pending
taps are overlaid natively at render time (no native blob write, no
race with the serializer, no double setDone)
- row title tap opens the app via fill-in extras branching (single
PendingIntent template); exported receiver no longer lists the custom
actions in its intent-filter
- typed v:1 contract (android-widget.model.ts <-> WidgetData.kt) locked
by golden-shape tests on both ends
- drain dedupes and skips missing/already-done tasks; aggregated
translated snack
- KeyValStore: drop per-call db.close() (SQLiteOpenHelper caches the
connection; access stays @Synchronized via the App singleton)
Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
* feat(android): polish widget UI and support toggling done state
Visual polish to match the app:
- rounded surface card in the app's exact light/dark tokens
(#f8f8f7 / #131314) with automatic day/night switching
- branded header (SP logo + 'Today') with separator, matching
brand purple (#8b4a9d light / #a05db1 dark)
- app-style circle-check on the row end (Material check_circle in
brand color when done), dimmed title for done tasks
- project dot tinted with the project color and hidden for
project-less tasks; whole row and empty state open the app
Done-state toggle (was done-only):
- queue is now a last-wins map {taskId: targetIsDone}; tapping a
done task queues setUnDone, and a done->undone round trip before
the app runs collapses to a no-op
- checkbox target computed from the DISPLAYED state (incl. pending
overlay) so repeated taps toggle back and forth while app is dead
- drain applies setDone/setUnDone, still skipping missing tasks and
tasks already in the target state
---------
Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>