diff --git a/backlog/tasks/task-79 - Introduce-explicit-runtime-state-transitions-and-reducers-in-main.md b/backlog/tasks/task-79 - Introduce-explicit-runtime-state-transitions-and-reducers-in-main.md index a234c4f..6414ac0 100644 --- a/backlog/tasks/task-79 - Introduce-explicit-runtime-state-transitions-and-reducers-in-main.md +++ b/backlog/tasks/task-79 - Introduce-explicit-runtime-state-transitions-and-reducers-in-main.md @@ -1,10 +1,11 @@ --- id: TASK-79 title: Introduce explicit runtime state transitions and reducers in main -status: To Do -assignee: [] +status: Done +assignee: + - '@sudacode' created_date: '2026-02-18 11:43' -updated_date: '2026-02-18 11:43' +updated_date: '2026-02-22 00:10' labels: - main-process - state-management @@ -41,15 +42,49 @@ Main runtime state is currently mutable from many callsites. This task introduce ## Acceptance Criteria -- [ ] #1 Critical runtime state domains mutate through explicit transition helpers -- [ ] #2 State invariants are test-covered -- [ ] #3 Direct ad-hoc mutation in migrated domains is removed -- [ ] #4 Ownership/mutation rules documented +- [x] #1 Critical runtime state domains mutate through explicit transition helpers +- [x] #2 State invariants are test-covered +- [x] #3 Direct ad-hoc mutation in migrated domains is removed +- [x] #4 Ownership/mutation rules documented +## Implementation Plan + + +Implementation plan (writing-plans): +1) Add pure reducer-style transition helpers in `src/main/state.ts` for critical AniList domains: client-secret state, retry queue metadata, media-guess runtime state, and tracking in-flight flag. Add focused tests in `src/main/state.test.ts`. +2) Rewire `src/main.ts` to use transition helpers instead of direct mutation for migrated domains in runtime deps wiring (`buildAnilistStateRuntimeMainDepsHandler`, media-guess state deps, retry-update deps, post-watch in-flight setter). +3) Add/extend invariant tests in `src/main/runtime/anilist-state.test.ts` and `src/main/runtime/anilist-media-state.test.ts` (metadata preservation, idempotent reset, scoped-field resets). +4) Document ownership/mutation rules in `docs/architecture.md` under composition guidance and run verification gates (`bun run build`, `bun run test:core:src`). +5) Record evidence and AC/DoD progress in TASK-79 notes. + +Parallelization: run code-wiring slice and tests/docs slice in parallel subagents where safe, then reconcile and run final gates in the main session. + +Detailed step-by-step plan saved at `docs/plans/2026-02-21-task-79-runtime-state-reducers.md`. + + +## Implementation Notes + + +2026-02-21: Started implementation pass via writing-plans/executing-plans workflow. Scoping to explicit transition helpers/reducers for critical AniList runtime state domains in `src/main.ts`/`src/main/state.ts`, plus invariant tests and architecture ownership rules docs. + +Implemented explicit AniList runtime transition helpers/reducers in `src/main/state.ts` and rewired migrated `src/main.ts` mutation paths through them (client-secret state, retry queue metadata, media-guess runtime state, in-flight flag). + +Added focused reducer tests in `src/main/state.test.ts` plus invariants coverage updates in `src/main/runtime/anilist-state.test.ts` and `src/main/runtime/anilist-media-state.test.ts`. + +Documented runtime ownership/mutation rules for migrated domains in `docs/architecture.md` under Composition Pattern. + +Validation evidence: `bun test src/main/state.test.ts src/main/runtime/anilist-state.test.ts src/main/runtime/anilist-media-state.test.ts` (12 pass), `bun run build` (pass), `bun run test:core:src` (219 pass, 6 skip, 0 fail). + + +## Final Summary + + +Introduced explicit reducer-style runtime transitions for critical AniList state in the main process by adding pure transition helpers in `src/main/state.ts` and routing migrated writes in `src/main.ts` through those helpers. Removed ad-hoc direct mutation in migrated domains (client-secret state, retry queue metadata, media-guess runtime state, in-flight flag), added focused and invariant tests, and documented ownership/mutation rules in architecture docs. Validation passed with focused state suites, full build, and core source test lane. + + ## Definition of Done -- [ ] #1 Core tests pass after migration -- [ ] #2 No behavior regressions in startup/IPC/overlay flows +- [x] #1 Core tests pass after migration +- [x] #2 No behavior regressions in startup/IPC/overlay flows - diff --git a/docs/architecture.md b/docs/architecture.md index 2acb6b4..34ee8a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,6 +178,16 @@ Composer modules share contract conventions via `src/main/runtime/composers/cont This keeps side effects explicit and makes behavior easy to unit-test with fakes. +### Runtime State Ownership (Migrated Domains) + +For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules: + +- Composition/runtime modules own mutable state cells and expose narrow `get*`/`set*` accessors. +- Domain handlers do not mutate foreign state directly; they call explicit transition helpers that encode invariants. +- Transition helpers may sync derived counters/snapshots, but must preserve non-owned metadata unless the transition explicitly owns that metadata. +- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers. +- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants. + ## Program Lifecycle - **Startup:** `startup.ts` parses CLI args and detects the compositor backend. If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks. diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 6cd5467..664d86e 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -2,45 +2,55 @@ Read first. Keep concise. -| agent_id | alias | mission | status | file | last_update_utc | -| --------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ---------------------- | -| `codex-generate-minecard-image-20260220T112900Z-vsxr` | `codex-generate-minecard-image` | `Generate media fallbacks (GIF) from assets/minecard.webm and wire README/docs fallback markup` | `done` | `docs/subagents/agents/codex-generate-minecard-image-20260220T112900Z-vsxr.md` | `2026-02-20T11:35:30Z` | -| `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T11:42:39Z` | -| `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | -| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T02:56:34Z` | -| `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | -| `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | -| `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | -| `codex-texthooker-color-ws-20260220T005844Z-r7m2` | `codex-texthooker-color-ws` | `Fix texthooker websocket payload so token highlight colors render` | `completed` | `docs/subagents/agents/codex-texthooker-color-ws-20260220T005844Z-r7m2.md` | `2026-02-20T01:01:00Z` | -| `codex-nplusone-pos1-20260220T012300Z-c5he` | `codex-nplusone-pos1` | `Fix N+1 false-negative when Yomitan functional tokens inflate unknown candidate count` | `completed` | `docs/subagents/agents/codex-nplusone-pos1-20260220T012300Z-c5he.md` | `2026-02-20T01:28:20Z` | -| `codex-subtitle-bg-20260220T054247Z-h9cu` | `codex-subtitle-bg` | `Update default subtitle background color to requested RGBA value` | `completed` | `docs/subagents/agents/codex-subtitle-bg-20260220T054247Z-h9cu.md` | `2026-02-20T05:44:45Z` | -| `codex-narrow-space-tokenizer-20260220T061716Z-p97s` | `codex-narrow-space-tokenizer` | `Fix tokenization when subtitle line contains narrow/invisible Unicode spacing between segments` | `completed` | `docs/subagents/agents/codex-narrow-space-tokenizer-20260220T061716Z-p97s.md` | `2026-02-20T06:20:07Z` | -| `codex-preserve-linebreaks-20260220T063538Z-s4nd` | `codex-preserve-linebreaks` | `Add config option to preserve subtitle line breaks in visible overlay rendering` | `completed` | `docs/subagents/agents/codex-preserve-linebreaks-20260220T063538Z-s4nd.md` | `2026-02-20T06:42:51Z` | -| `codex-release-mpv-plugin-20260220T035757Z-d4yf` | `codex-release-mpv-plugin` | `Package optional release assets bundle (mpv plugin + rofi theme), move theme to assets/themes, update install/docs` | `completed` | `docs/subagents/agents/codex-release-mpv-plugin-20260220T035757Z-d4yf.md` | `2026-02-20T04:02:26Z` | -| `codex-bundle-config-example-20260220T092408Z-a1b2` | `codex-bundle-config-example` | `Bundle config.example.jsonc in release assets tarball and align install docs` | `completed` | `docs/subagents/agents/codex-bundle-config-example-20260220T092408Z-a1b2.md` | `2026-02-20T09:26:24Z` | -| `codex-tsconfig-modernize-20260220T093035Z-68qb` | `codex-tsconfig-modernize` | `Enable noUncheckedIndexedAccess + isolatedModules in root tsconfig and fix resulting compile errors` | `completed` | `docs/subagents/agents/codex-tsconfig-modernize-20260220T093035Z-68qb.md` | `2026-02-20T09:46:26Z` | -| `codex-jellyfin-secret-store-20260220T101428Z-om4z` | `codex-jellyfin-secret-store` | `Verify whether Jellyfin token can use same secret-store path as AniList token` | `completed` | `docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md` | `2026-02-20T10:22:45Z` | -| `codex-vitepress-subagents-ignore-20260220T101755Z-k2m9` | `codex-vitepress-subagents-ignore` | `Exclude docs/subagents from VitePress build` | `completed` | `docs/subagents/agents/codex-vitepress-subagents-ignore-20260220T101755Z-k2m9.md` | `2026-02-20T10:18:30Z` | -| `codex-preserve-linebreak-display-20260220T110436Z-r8f1` | `codex-preserve-linebreak-display` | `Fix visible overlay display artifact when subtitleStyle.preserveLineBreaks is disabled` | `completed` | `docs/subagents/agents/codex-preserve-linebreak-display-20260220T110436Z-r8f1.md` | `2026-02-20T11:10:51Z` | -| `codex-review-refactor-cleanup-20260220T113818Z-i2ov` | `codex-review-refactor-cleanup` | `Review recent TASK-85 refactor effort and identify remaining cleanup work` | `handoff` | `docs/subagents/agents/codex-review-refactor-cleanup-20260220T113818Z-i2ov.md` | `2026-02-21T02:04:12Z` | -| `codex-commit-unstaged-20260220T115057Z-k7q2` | `codex-commit-unstaged` | `Commit all current unstaged repository changes with content-derived conventional message` | `in_progress` | `docs/subagents/agents/codex-commit-unstaged-20260220T115057Z-k7q2.md` | `2026-02-20T11:51:18Z` | -| `codex-task95-hotspots-20260221T031420Z-x7k2` | `codex-task95-hotspots` | `Execute TASK-95 decompose oversized core hotspots end-to-end` | `done` | `docs/subagents/agents/codex-task95-hotspots-20260221T031420Z-x7k2.md` | `2026-02-21T03:29:23Z` | -| `codex-task94-thin-root-20260221T031320Z-q3n7` | `codex-task94-thin-root` | `Execute TASK-94 by extracting main.ts deps-builder clusters into runtime composers` | `handoff` | `docs/subagents/agents/codex-task94-thin-root-20260221T031320Z-q3n7.md` | `2026-02-21T03:41:10Z` | -| `opencode-task95-immersion-tracker-20260221T031846Z-p4k9` | `opencode-task95-immersion-tracker` | `Implement TASK-95 immersion-tracker extraction into focused collaborators and seam tests` | `handoff` | `docs/subagents/agents/opencode-task95-immersion-tracker-20260221T031846Z-p4k9.md` | `2026-02-21T03:26:51Z` | -| `opencode-task95-config-20260221T031843Z-m4k9` | `opencode-task95-config` | `Implement TASK-95 config extraction for src/config/service.ts` | `done` | `docs/subagents/agents/opencode-task95-config-20260221T031843Z-m4k9.md` | `2026-02-21T03:26:57Z` | -| `codex-task95-anki-20260221T031836Z-6f3e` | `codex-task95-anki` | `Implement TASK-95 anki-integration extraction for field-grouping merge collaborator` | `done` | `docs/subagents/agents/codex-task95-anki-20260221T031836Z-6f3e.md` | `2026-02-21T03:26:55Z` | -| `opencode-task-94-20260221T033647Z-7ou2` | `opencode-task-94` | `Finish TASK-94 thin composition root refactor and close acceptance criteria` | `done` | `docs/subagents/agents/opencode-task-94-20260221T033647Z-7ou2.md` | `2026-02-21T04:12:45Z` | -| `codex-task71-round2-20260221T043541Z-k9t3` | `codex-task71-round2` | `Execute TASK-71 round 2 split of main.ts into domain runtime modules` | `done` | `docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md` | `2026-02-21T04:57:00Z` | -| `codex-task85-20260221T051308Z-164g` | `codex-task85-exec` | `Execute TASK-85 remaining AC/DoD with writing-plans and executing-plans flow` | `done` | `docs/subagents/agents/codex-task85-20260221T051308Z-164g.md` | `2026-02-21T05:45:50Z` | -| `codex-review-refactor-20260221T062353Z-p6k2` | `codex-review-refactor` | `Perform code review for current refactor changes and report actionable findings` | `done` | `docs/subagents/agents/codex-review-refactor-20260221T062353Z-p6k2.md` | `2026-02-21T07:16:33Z` | -| `opencode-task93-sync-20260221T070842Z-71c6` | `opencode-task93-sync` | `Synchronize TASK-85 closure tracking and child-task status in Backlog` | `done` | `docs/subagents/agents/opencode-task93-sync-20260221T070842Z-71c6.md` | `2026-02-21T07:11:58Z` | -| `opencode-task97-runtime-composer-20260221T094150Z-r8k3` | `opencode-task97-runtime-composer` | `Execute TASK-97 normalize runtime composer contracts end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task97-runtime-composer-20260221T094150Z-r8k3.md` | `2026-02-21T10:06:59Z` | -| `opencode-task96-config-resolve-20260221T094119Z-mbfo` | `opencode-task96-config-resolve` | `Execute TASK-96 split config resolve into domain modules with plan-first workflow` | `planning` | `docs/subagents/agents/opencode-task96-config-resolve-20260221T094119Z-mbfo.md` | `2026-02-21T09:41:19Z` | -| `opencode-task98-source-tests-20260221T094524Z-kzvd` | `opencode-task98-source-tests` | `Execute TASK-98 shift core tests to source level and trim dist coupling without commit` | `blocked` | `docs/subagents/agents/opencode-task98-source-tests-20260221T094524Z-kzvd.md` | `2026-02-21T09:56:47Z` | -| `codex-task96-config-resolve-20260221T110058Z-k7m2` | `codex-task96-config-resolve` | `Execute TASK-96 split config resolve into domain modules end-to-end without commit` | `done` | `docs/subagents/agents/codex-task96-config-resolve-20260221T110058Z-k7m2.md` | `2026-02-21T20:10:43Z` | -| `codex-task73-mpv-socket-20260221T201605Z-zjhs` | `codex-task73-mpv-socket` | `Execute TASK-73 consolidate launcher mpv socket readiness primitives end-to-end` | `done` | `docs/subagents/agents/codex-task73-mpv-socket-20260221T201605Z-zjhs.md` | `2026-02-21T20:20:18Z` | -| `codex-task74-launcher-tests-20260221T201635Z-10i6` | `codex-task74-launcher-tests` | `Implement TASK-74 launcher regression tests for config discovery + command branching end-to-end` | `done` | `docs/subagents/agents/codex-task74-launcher-tests-20260221T201635Z-10i6.md` | `2026-02-21T20:20:52Z` | -| `opencode-task76-anki-workflows-20260221T201659Z-r4p1` | `opencode-task76-anki-workflows` | `Execute TASK-76 decompose anki-integration orchestrator into workflow services via plan-first workflow` | `done` | `docs/subagents/agents/opencode-task76-anki-workflows-20260221T201659Z-r4p1.md` | `2026-02-21T21:17:28Z` | -| `opencode-task76-doc-boundaries-20260221T203558Z-h7q4` | `opencode-task76-doc-boundaries` | `Update Anki integration docs with post-decomposition ownership boundaries for TASK-76` | `done` | `docs/subagents/agents/opencode-task76-doc-boundaries-20260221T203558Z-h7q4.md` | `2026-02-21T20:36:55Z` | -| `codex-docs-unpushed-review-20260221T213707Z-lyej` | `codex-docs-unpushed-review` | `Review unpushed commits for docs drift; patch docs to reflect current code/state` | `in_progress` | `docs/subagents/agents/codex-docs-unpushed-review-20260221T213707Z-lyej.md` | `2026-02-21T21:37:07Z` | +| agent_id | alias | mission | status | file | last_update_utc | +| ------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------- | ---------------------- | +| `codex-generate-minecard-image-20260220T112900Z-vsxr` | `codex-generate-minecard-image` | `Generate media fallbacks (GIF) from assets/minecard.webm and wire README/docs fallback markup` | `done` | `docs/subagents/agents/codex-generate-minecard-image-20260220T112900Z-vsxr.md` | `2026-02-20T11:35:30Z` | +| `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T11:42:39Z` | +| `codex-config-validation-20260219T172015Z-iiyf` | `codex-config-validation` | `Find root cause of config validation error for ~/.config/SubMiner/config.jsonc` | `completed` | `docs/subagents/agents/codex-config-validation-20260219T172015Z-iiyf.md` | `2026-02-19T17:26:17Z` | +| `codex-task85-20260219T233711Z-46hc` | `codex-task85` | `Resume TASK-85 maintainability refactor from latest handoff point` | `in_progress` | `docs/subagents/agents/codex-task85-20260219T233711Z-46hc.md` | `2026-02-20T02:56:34Z` | +| `codex-anilist-deeplink-20260219T233926Z` | `anilist-deeplink` | `Fix external subminer:// AniList callback handling from browser` | `done` | `docs/subagents/agents/codex-anilist-deeplink-20260219T233926Z.md` | `2026-02-19T23:59:21Z` | +| `codex-texthooker-highlights-20260220T002354Z-927c` | `codex-texthooker-highlights` | `Add optional texthooker highlight toggles for known/n+1/frequency/JLPT` | `completed` | `docs/subagents/agents/codex-texthooker-highlights-20260220T002354Z-927c.md` | `2026-02-20T00:30:49Z` | +| `codex-texthooker-ui-playwright-20260220T003827Z-k3p9` | `codex-texthooker-ui-playwright` | `Run Playwright MCP smoke/regression checks for texthooker-ui changes` | `completed` | `docs/subagents/agents/codex-texthooker-ui-playwright-20260220T003827Z-k3p9.md` | `2026-02-20T00:42:09Z` | +| `codex-texthooker-color-ws-20260220T005844Z-r7m2` | `codex-texthooker-color-ws` | `Fix texthooker websocket payload so token highlight colors render` | `completed` | `docs/subagents/agents/codex-texthooker-color-ws-20260220T005844Z-r7m2.md` | `2026-02-20T01:01:00Z` | +| `codex-nplusone-pos1-20260220T012300Z-c5he` | `codex-nplusone-pos1` | `Fix N+1 false-negative when Yomitan functional tokens inflate unknown candidate count` | `completed` | `docs/subagents/agents/codex-nplusone-pos1-20260220T012300Z-c5he.md` | `2026-02-20T01:28:20Z` | +| `codex-subtitle-bg-20260220T054247Z-h9cu` | `codex-subtitle-bg` | `Update default subtitle background color to requested RGBA value` | `completed` | `docs/subagents/agents/codex-subtitle-bg-20260220T054247Z-h9cu.md` | `2026-02-20T05:44:45Z` | +| `codex-narrow-space-tokenizer-20260220T061716Z-p97s` | `codex-narrow-space-tokenizer` | `Fix tokenization when subtitle line contains narrow/invisible Unicode spacing between segments` | `completed` | `docs/subagents/agents/codex-narrow-space-tokenizer-20260220T061716Z-p97s.md` | `2026-02-20T06:20:07Z` | +| `codex-preserve-linebreaks-20260220T063538Z-s4nd` | `codex-preserve-linebreaks` | `Add config option to preserve subtitle line breaks in visible overlay rendering` | `completed` | `docs/subagents/agents/codex-preserve-linebreaks-20260220T063538Z-s4nd.md` | `2026-02-20T06:42:51Z` | +| `codex-release-mpv-plugin-20260220T035757Z-d4yf` | `codex-release-mpv-plugin` | `Package optional release assets bundle (mpv plugin + rofi theme), move theme to assets/themes, update install/docs` | `completed` | `docs/subagents/agents/codex-release-mpv-plugin-20260220T035757Z-d4yf.md` | `2026-02-20T04:02:26Z` | +| `codex-bundle-config-example-20260220T092408Z-a1b2` | `codex-bundle-config-example` | `Bundle config.example.jsonc in release assets tarball and align install docs` | `completed` | `docs/subagents/agents/codex-bundle-config-example-20260220T092408Z-a1b2.md` | `2026-02-20T09:26:24Z` | +| `codex-tsconfig-modernize-20260220T093035Z-68qb` | `codex-tsconfig-modernize` | `Enable noUncheckedIndexedAccess + isolatedModules in root tsconfig and fix resulting compile errors` | `completed` | `docs/subagents/agents/codex-tsconfig-modernize-20260220T093035Z-68qb.md` | `2026-02-20T09:46:26Z` | +| `codex-jellyfin-secret-store-20260220T101428Z-om4z` | `codex-jellyfin-secret-store` | `Verify whether Jellyfin token can use same secret-store path as AniList token` | `completed` | `docs/subagents/agents/codex-jellyfin-secret-store-20260220T101428Z-om4z.md` | `2026-02-20T10:22:45Z` | +| `codex-vitepress-subagents-ignore-20260220T101755Z-k2m9` | `codex-vitepress-subagents-ignore` | `Exclude docs/subagents from VitePress build` | `completed` | `docs/subagents/agents/codex-vitepress-subagents-ignore-20260220T101755Z-k2m9.md` | `2026-02-20T10:18:30Z` | +| `codex-preserve-linebreak-display-20260220T110436Z-r8f1` | `codex-preserve-linebreak-display` | `Fix visible overlay display artifact when subtitleStyle.preserveLineBreaks is disabled` | `completed` | `docs/subagents/agents/codex-preserve-linebreak-display-20260220T110436Z-r8f1.md` | `2026-02-20T11:10:51Z` | +| `codex-review-refactor-cleanup-20260220T113818Z-i2ov` | `codex-review-refactor-cleanup` | `Review recent TASK-85 refactor effort and identify remaining cleanup work` | `handoff` | `docs/subagents/agents/codex-review-refactor-cleanup-20260220T113818Z-i2ov.md` | `2026-02-21T02:04:12Z` | +| `codex-commit-unstaged-20260220T115057Z-k7q2` | `codex-commit-unstaged` | `Commit all current unstaged repository changes with content-derived conventional message` | `in_progress` | `docs/subagents/agents/codex-commit-unstaged-20260220T115057Z-k7q2.md` | `2026-02-20T11:51:18Z` | +| `codex-task95-hotspots-20260221T031420Z-x7k2` | `codex-task95-hotspots` | `Execute TASK-95 decompose oversized core hotspots end-to-end` | `done` | `docs/subagents/agents/codex-task95-hotspots-20260221T031420Z-x7k2.md` | `2026-02-21T03:29:23Z` | +| `codex-task94-thin-root-20260221T031320Z-q3n7` | `codex-task94-thin-root` | `Execute TASK-94 by extracting main.ts deps-builder clusters into runtime composers` | `handoff` | `docs/subagents/agents/codex-task94-thin-root-20260221T031320Z-q3n7.md` | `2026-02-21T03:41:10Z` | +| `opencode-task95-immersion-tracker-20260221T031846Z-p4k9` | `opencode-task95-immersion-tracker` | `Implement TASK-95 immersion-tracker extraction into focused collaborators and seam tests` | `handoff` | `docs/subagents/agents/opencode-task95-immersion-tracker-20260221T031846Z-p4k9.md` | `2026-02-21T03:26:51Z` | +| `opencode-task95-config-20260221T031843Z-m4k9` | `opencode-task95-config` | `Implement TASK-95 config extraction for src/config/service.ts` | `done` | `docs/subagents/agents/opencode-task95-config-20260221T031843Z-m4k9.md` | `2026-02-21T03:26:57Z` | +| `codex-task95-anki-20260221T031836Z-6f3e` | `codex-task95-anki` | `Implement TASK-95 anki-integration extraction for field-grouping merge collaborator` | `done` | `docs/subagents/agents/codex-task95-anki-20260221T031836Z-6f3e.md` | `2026-02-21T03:26:55Z` | +| `opencode-task-94-20260221T033647Z-7ou2` | `opencode-task-94` | `Finish TASK-94 thin composition root refactor and close acceptance criteria` | `done` | `docs/subagents/agents/opencode-task-94-20260221T033647Z-7ou2.md` | `2026-02-21T04:12:45Z` | +| `codex-task71-round2-20260221T043541Z-k9t3` | `codex-task71-round2` | `Execute TASK-71 round 2 split of main.ts into domain runtime modules` | `done` | `docs/subagents/agents/codex-task71-round2-20260221T043541Z-k9t3.md` | `2026-02-21T04:57:00Z` | +| `codex-task85-20260221T051308Z-164g` | `codex-task85-exec` | `Execute TASK-85 remaining AC/DoD with writing-plans and executing-plans flow` | `done` | `docs/subagents/agents/codex-task85-20260221T051308Z-164g.md` | `2026-02-21T05:45:50Z` | +| `codex-review-refactor-20260221T062353Z-p6k2` | `codex-review-refactor` | `Perform code review for current refactor changes and report actionable findings` | `done` | `docs/subagents/agents/codex-review-refactor-20260221T062353Z-p6k2.md` | `2026-02-21T07:16:33Z` | +| `opencode-task93-sync-20260221T070842Z-71c6` | `opencode-task93-sync` | `Synchronize TASK-85 closure tracking and child-task status in Backlog` | `done` | `docs/subagents/agents/opencode-task93-sync-20260221T070842Z-71c6.md` | `2026-02-21T07:11:58Z` | +| `opencode-task97-runtime-composer-20260221T094150Z-r8k3` | `opencode-task97-runtime-composer` | `Execute TASK-97 normalize runtime composer contracts end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task97-runtime-composer-20260221T094150Z-r8k3.md` | `2026-02-21T10:06:59Z` | +| `opencode-task96-config-resolve-20260221T094119Z-mbfo` | `opencode-task96-config-resolve` | `Execute TASK-96 split config resolve into domain modules with plan-first workflow` | `planning` | `docs/subagents/agents/opencode-task96-config-resolve-20260221T094119Z-mbfo.md` | `2026-02-21T09:41:19Z` | +| `opencode-task98-source-tests-20260221T094524Z-kzvd` | `opencode-task98-source-tests` | `Execute TASK-98 shift core tests to source level and trim dist coupling without commit` | `blocked` | `docs/subagents/agents/opencode-task98-source-tests-20260221T094524Z-kzvd.md` | `2026-02-21T09:56:47Z` | +| `codex-task96-config-resolve-20260221T110058Z-k7m2` | `codex-task96-config-resolve` | `Execute TASK-96 split config resolve into domain modules end-to-end without commit` | `done` | `docs/subagents/agents/codex-task96-config-resolve-20260221T110058Z-k7m2.md` | `2026-02-21T20:10:43Z` | +| `codex-task73-mpv-socket-20260221T201605Z-zjhs` | `codex-task73-mpv-socket` | `Execute TASK-73 consolidate launcher mpv socket readiness primitives end-to-end` | `done` | `docs/subagents/agents/codex-task73-mpv-socket-20260221T201605Z-zjhs.md` | `2026-02-21T20:20:18Z` | +| `codex-task74-launcher-tests-20260221T201635Z-10i6` | `codex-task74-launcher-tests` | `Implement TASK-74 launcher regression tests for config discovery + command branching end-to-end` | `done` | `docs/subagents/agents/codex-task74-launcher-tests-20260221T201635Z-10i6.md` | `2026-02-21T20:20:52Z` | +| `opencode-task76-anki-workflows-20260221T201659Z-r4p1` | `opencode-task76-anki-workflows` | `Execute TASK-76 decompose anki-integration orchestrator into workflow services via plan-first workflow` | `done` | `docs/subagents/agents/opencode-task76-anki-workflows-20260221T201659Z-r4p1.md` | `2026-02-21T21:17:28Z` | +| `opencode-task76-doc-boundaries-20260221T203558Z-h7q4` | `opencode-task76-doc-boundaries` | `Update Anki integration docs with post-decomposition ownership boundaries for TASK-76` | `done` | `docs/subagents/agents/opencode-task76-doc-boundaries-20260221T203558Z-h7q4.md` | `2026-02-21T20:36:55Z` | +| `codex-docs-unpushed-review-20260221T213707Z-lyej` | `codex-docs-unpushed-review` | `Review unpushed commits for docs drift; patch docs to reflect current code/state` | `done` | `docs/subagents/agents/codex-docs-unpushed-review-20260221T213707Z-lyej.md` | `2026-02-21T21:39:15Z` | +| `codex-task72-strict-startup-config-20260221T231804Z-3ngd` | `codex-task72-strict-startup-config` | `Execute TASK-72 strict startup config loading with actionable user-facing errors` | `done` | `docs/subagents/agents/codex-task72-strict-startup-config-20260221T231804Z-3ngd.md` | `2026-02-21T23:26:29Z` | +| `opencode-task77-tokenizer-stages-20260221T232016Z-v9k2` | `opencode-task77-tokenizer-stages` | `Execute TASK-77 tokenizer pipeline split into parser-selection enrichment and annotation stages without commit` | `done` | `docs/subagents/agents/opencode-task77-tokenizer-stages-20260221T232016Z-v9k2.md` | `2026-02-21T23:47:08Z` | +| `codex-task75-mpv-osd-buffered-20260221T231816Z-yj32` | `codex-task75-mpv-osd-buffered` | `Execute TASK-75 move MPV OSD log writes to buffered async path end-to-end` | `done` | `docs/subagents/agents/codex-task75-mpv-osd-buffered-20260221T231816Z-yj32.md` | `2026-02-21T23:48:10Z` | +| `opencode-task72-strict-startup-config-20260221T232155Z-kf0o` | `opencode-task72-strict-startup-config` | `Implement Task 1 from strict startup config loading plan with startup malformed-config failure signal and tests` | `done` | `docs/subagents/agents/opencode-task72-strict-startup-config-20260221T232155Z-kf0o.md` | `2026-02-21T23:24:32Z` | +| `opencode-task72-parse-details-20260221T232137Z-b63t` | `opencode-task72-parse-details` | `Implement TASK-72 Task 2 shared parse-error formatter wiring and tests` | `done` | `docs/subagents/agents/opencode-task72-parse-details-20260221T232137Z-b63t.md` | `2026-02-21T23:24:12Z` | +| `opencode-task77-slice-a-20260222T000100Z-j4p2` | `opencode-task77-slice-a` | `Implement TASK-77 slice A parser-selection-stage module + focused tests without touching tokenizer.ts` | `done` | `docs/subagents/agents/opencode-task77-slice-a-20260222T000100Z-j4p2.md` | `2026-02-22T00:03:30Z` | +| `opencode-task78-config-domain-20260221T235604Z-p9x2` | `opencode-task78-config-domain` | `Execute TASK-78 modularize config definitions and validation by domain end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task78-config-domain-20260221T235604Z-p9x2.md` | `2026-02-22T00:06:30Z` | +| `opencode-task77-sliceb-20260221T232507Z-vzk5` | `opencode-task77-sliceb` | `Implement TASK-77 slice B parser-enrichment stage module + focused tests without touching tokenizer.ts` | `done` | `docs/subagents/agents/opencode-task77-sliceb-20260221T232507Z-vzk5.md` | `2026-02-21T23:27:40Z` | +| `opencode-task79-runtime-reducers-20260221T235652Z-n4p7` | `opencode-task79-runtime-reducers` | `Execute TASK-79 explicit runtime state transitions/reducers in main via plan-first workflow` | `done` | `docs/subagents/agents/opencode-task79-runtime-reducers-20260221T235652Z-n4p7.md` | `2026-02-22T00:10:51Z` | +| `opencode-task79-sliceb-20260222T000253Z-m2r7` | `opencode-task79-sliceb` | `Implement TASK-79 slice B invariants coverage/tests and composition-boundary docs updates without commit` | `done` | `docs/subagents/agents/opencode-task79-sliceb-20260222T000253Z-m2r7.md` | `2026-02-22T00:04:21Z` | diff --git a/docs/subagents/agents/opencode-task79-runtime-reducers-20260221T235652Z-n4p7.md b/docs/subagents/agents/opencode-task79-runtime-reducers-20260221T235652Z-n4p7.md new file mode 100644 index 0000000..7771713 --- /dev/null +++ b/docs/subagents/agents/opencode-task79-runtime-reducers-20260221T235652Z-n4p7.md @@ -0,0 +1,31 @@ +# Agent Session: opencode-task79-runtime-reducers-20260221T235652Z-n4p7 + +- alias: `opencode-task79-runtime-reducers` +- mission: `Execute TASK-79 explicit runtime state transitions/reducers in main via writing-plans + executing-plans (no commit).` +- status: `done` +- started_utc: `2026-02-21T23:56:52Z` +- last_update_utc: `2026-02-22T00:10:51Z` + +## Intent + +- Load TASK-79 from Backlog MCP; capture plan in task. +- Produce implementation plan doc under `docs/plans/`. +- Execute code/test/docs updates end-to-end without commit. + +## Planned Files + +- `src/main.ts` +- `src/main/state.ts` +- `src/main/state.test.ts` + +## Assumptions + +- Backlog task `TASK-79` exists and is ready for execution. +- Existing startup/IPC/overlay behavior must remain unchanged. +- Parallel subagents can own independent slices (state domains/tests/docs) without overlap. + +## Phase Log + +- `2026-02-21T23:56:52Z` Started; loaded backlog overview + TASK-79 context; beginning planning. +- `2026-02-22T00:06:10Z` Slice A implementation: added explicit AniList state transition helpers + initializers in `src/main/state.ts`; rewired migrated `src/main.ts` AniList writes through transitions; added focused reducer tests in `src/main/state.test.ts`; focused `bun test src/main/state.test.ts` blocked by Bun runtime missing `node:sqlite`. +- `2026-02-22T00:10:51Z` Finalized TASK-79: switched `state.ts` import to direct `mpv-render-metrics` module (removes `node:sqlite` test coupling), focused state/anilist invariant tests passing, build + `test:core:src` passing, backlog TASK-79 marked Done. diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index eaf124c..d20e881 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -49,3 +49,25 @@ Shared notes. Append-only. - [2026-02-21T20:35:58Z] [opencode-task76-doc-boundaries-20260221T203558Z-h7q4|opencode-task76-doc-boundaries] overlap note: TASK-76 already has an active planning agent; this pass is docs-only (`docs/anki-integration.md`) to capture ownership boundaries after workflow decomposition. - [2026-02-21T21:16:18Z] [opencode-task76-anki-workflows-20260221T201659Z-r4p1|opencode-task76-anki-workflows] completed TASK-76: extracted `note-update-workflow` + `field-grouping-workflow` services, delegated facade hotpaths in `src/anki-integration.ts`, added focused workflow seam tests, docs ownership boundaries updated, `bun run build && bun run test:core:dist` green, and backlog TASK-76 marked Done. - [2026-02-21T21:37:07Z] [codex-docs-unpushed-review-20260221T213707Z-lyej|codex-docs-unpushed-review] starting docs-drift audit for unpushed commits (`origin/main..HEAD`); scope docs alignment only, no behavior/code rewrites. +- [2026-02-21T21:39:15Z] [codex-docs-unpushed-review-20260221T213707Z-lyej|codex-docs-unpushed-review] completed docs-drift pass for current HEAD: removed stale lowercase config-dir fallback claim (`docs/configuration.md`) and added launcher regression lane in dev test commands (`docs/development.md`). +- [2026-02-21T23:18:04Z] [codex-task72-strict-startup-config-20260221T231804Z-3ngd|codex-task72-strict-startup-config] starting TASK-72 via Backlog MCP + writing-plans/executing-plans workflow; scope expected around startup config strictness parity, user-facing errors, tests, and docs. +- [2026-02-21T23:20:16Z] [opencode-task77-tokenizer-stages-20260221T232016Z-v9k2|opencode-task77-tokenizer-stages] starting TASK-77 via Backlog MCP + writing-plans/executing-plans workflow; expected overlap around tokenizer/pipeline modules and related tests, coordinating to avoid clobber. +- [2026-02-21T23:47:08Z] [opencode-task77-tokenizer-stages-20260221T232016Z-v9k2|opencode-task77-tokenizer-stages] completed TASK-77: split tokenizer into parser-selection/enrichment/annotation/runtime stages, added direct stage tests, ran tokenizer+core src/dist gates green, and finalized Backlog task to Done. +- [2026-02-21T23:18:16Z] [codex-task75-mpv-osd-buffered-20260221T231816Z-yj32|codex-task75-mpv-osd-buffered] starting TASK-75 via Backlog MCP + writing-plans/executing-plans; scope `src/main/runtime/mpv-osd-log*` + wiring/tests for buffered async OSD logging with shutdown flush behavior. +- [2026-02-21T23:48:10Z] [codex-task75-mpv-osd-buffered-20260221T231816Z-yj32|codex-task75-mpv-osd-buffered] completed TASK-75: moved MPV OSD log writes to buffered async queue + flush path, wired `flushMpvLog` into on-will-quit cleanup, focused runtime/lifecycle tests passing, backlog task marked Done; full `bun run build` still blocked by unrelated tokenizer logger typing issue. +- [2026-02-21T23:21:55Z] [opencode-task72-strict-startup-config-20260221T232155Z-kf0o|opencode-task72-strict-startup-config] overlap note: implementing user-requested Task 1 for TASK-72 in `src/config/service.ts` + `src/config/config.test.ts`; another planning agent exists on TASK-72 (`codex-task72-...`), keeping edits minimal and scoped to startup malformed-config strict failure behavior. +- [2026-02-21T23:24:32Z] [opencode-task72-strict-startup-config-20260221T232155Z-kf0o|opencode-task72-strict-startup-config] completed Task 1: constructor now throws `ConfigStartupParseError` on malformed startup config (path + parse reason in message) instead of silently defaulting; added startup malformed-config constructor regression test; `bun run build && node --test dist/config/config.test.js` passed. +- [2026-02-21T23:21:37Z] [opencode-task72-parse-details-20260221T232137Z-b63t|opencode-task72-parse-details] overlap note: implementing only TASK-72 Task 2 (`src/main/config-validation.ts`, `src/main/runtime/startup-config.ts`, and related tests) while preserving existing TASK-72 planning artifacts from `codex-task72-strict-startup-config-20260221T231804Z-3ngd`. +- [2026-02-21T23:25:07Z] [opencode-task77-sliceb-20260221T232507Z-vzk5|opencode-task77-sliceb] overlap note: implementing TASK-77 slice B in `src/core/services/tokenizer/parser-enrichment-stage*.ts` only; preserving concurrent TASK-77 planning/slice-A work and leaving `src/core/services/tokenizer.ts` untouched per request. +- [2026-02-21T23:27:40Z] [opencode-task77-sliceb-20260221T232507Z-vzk5|opencode-task77-sliceb] completed TASK-77 slice B request: extracted parser enrichment stage pure logic into `parser-enrichment-stage.ts`, added focused tests in `parser-enrichment-stage.test.ts`, and validated with `bun test src/core/services/tokenizer/parser-enrichment-stage.test.ts` (3 pass). +- [2026-02-22T00:01:00Z] [opencode-task77-slice-a-20260222T000100Z-j4p2|opencode-task77-slice-a] overlap note: implementing TASK-77 slice A in `src/core/services/tokenizer/parser-selection-stage.ts` + focused tests; no edits to `src/core/services/tokenizer.ts` in this pass. +- [2026-02-22T00:03:30Z] [opencode-task77-slice-a-20260222T000100Z-j4p2|opencode-task77-slice-a] completed TASK-77 slice A: added pure parser-selection-stage module + focused tests (scanning preference, mecab fallback split, suspicious-kana tie-break); targeted bun test command green. +- [2026-02-21T23:24:12Z] [opencode-task72-parse-details-20260221T232137Z-b63t|opencode-task72-parse-details] completed TASK-72 Task 2 scope: shared parse-error formatter now in `src/main/config-validation.ts`, startup hot-reload parse-failure path uses formatter, tests updated (`src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`), required build+node-test command passed. +- [2026-02-21T23:26:29Z] [codex-task72-strict-startup-config-20260221T231804Z-3ngd|codex-task72-strict-startup-config] completed TASK-72 end-to-end (no commit): integrated Task 3 startup guard in `src/main.ts`, docs behavior update in `docs/configuration.md`, focused tests pass (`bun test ...` 46/46), and backlog TASK-72 finalized Done; full build still blocked by unrelated TASK-75/TASK-77 TS errors. +- [2026-02-21T23:56:04Z] [opencode-task78-config-domain-20260221T235604Z-p9x2|opencode-task78-config-domain] starting TASK-78 via Backlog MCP + writing-plans/executing-plans workflow; expected scope `src/config/definitions.ts`, `src/config/service.ts`, new config-domain modules/tests, and docs updates as needed. +- [2026-02-22T00:05:00Z] [opencode-task78-config-domain-20260221T235604Z-p9x2|opencode-task78-config-domain] completed TASK-78: split config definitions into domain modules under `src/config/definitions/*`, kept composed public API at `src/config/definitions.ts`, added domain-registry tests, updated contributor docs, and finalized backlog task to Done; `make generate-config` blocked by unrelated pre-existing `src/main/state.test.ts` export errors. +- [2026-02-21T23:56:52Z] [opencode-task79-runtime-reducers-20260221T235652Z-n4p7|opencode-task79-runtime-reducers] starting TASK-79 via Backlog MCP + writing-plans/executing-plans; initial scope expected around `src/main.ts` runtime-state mutation paths, reducer helpers, invariant tests, and ownership docs; parallel slices where safe. +- [2026-02-22T00:06:10Z] [opencode-task79-runtime-reducers-20260221T235652Z-n4p7|opencode-task79-runtime-reducers] implementing TASK-79 slice A: added reducer helpers + focused `src/main/state.test.ts`; rewired migrated `src/main.ts` AniList mutation paths (client-secret state, retry metadata, media-guess runtime fields, in-flight flag) through `src/main/state.ts` transitions. +- [2026-02-22T00:02:53Z] [opencode-task79-sliceb-20260222T000253Z-m2r7|opencode-task79-sliceb] overlap note: implementing TASK-79 slice B only (`src/main/runtime/anilist-state.test.ts`, `src/main/runtime/anilist-media-state.test.ts`, `docs/architecture.md`) for invariants coverage and composition-boundary ownership docs; no runtime behavior code changes. +- [2026-02-22T00:04:21Z] [opencode-task79-sliceb-20260222T000253Z-m2r7|opencode-task79-sliceb] completed TASK-79 slice B request: added invariants coverage in AniList runtime/media tests (queue metadata preservation, clear-token non-mutation, guess-only reset, tracking reset idempotence), documented migrated runtime reducer ownership rules in architecture docs, and verified focused tests green (8 pass). +- [2026-02-22T00:10:51Z] [opencode-task79-runtime-reducers-20260221T235652Z-n4p7|opencode-task79-runtime-reducers] completed TASK-79 end-to-end: merged slice A/B, added explicit AniList runtime transitions in `src/main/state.ts`, rewired migrated `src/main.ts` mutation paths, fixed `state.ts` core-services import coupling for focused tests, verified `bun test src/main/state.test.ts src/main/runtime/anilist-state.test.ts src/main/runtime/anilist-media-state.test.ts` + `bun run build` + `bun run test:core:src`, and marked backlog task Done. diff --git a/src/main.ts b/src/main.ts index 9162e74..3e5d627 100644 --- a/src/main.ts +++ b/src/main.ts @@ -392,7 +392,6 @@ import { } from './core/services'; import { guessAnilistMediaInfo, - type AnilistMediaGuess, updateAnilistPostWatchProgress, } from './core/services/anilist/anilist-updater'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; @@ -417,7 +416,21 @@ import { } from './main/frequency-dictionary-runtime'; import { createMediaRuntimeService } from './main/media-runtime'; import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; -import { type AppState, type StartupState, applyStartupState, createAppState } from './main/state'; +import { + type AnilistMediaGuessRuntimeState, + type AppState, + type StartupState, + applyStartupState, + createAppState, + createInitialAnilistMediaGuessRuntimeState, + createInitialAnilistUpdateInFlightState, + transitionAnilistClientSecretState, + transitionAnilistMediaGuessRuntimeState, + transitionAnilistRetryQueueLastAttemptAt, + transitionAnilistRetryQueueLastError, + transitionAnilistRetryQueueState, + transitionAnilistUpdateInFlightState, +} from './main/state'; import { isAllowedAnilistExternalUrl, isAllowedAnilistSetupNavigationUrl, @@ -464,12 +477,9 @@ const JELLYFIN_TOKEN_STORE_FILE = 'jellyfin-token-store.json'; const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json'; const TRAY_TOOLTIP = 'SubMiner'; -let anilistCurrentMediaKey: string | null = null; -let anilistCurrentMediaDurationSec: number | null = null; -let anilistCurrentMediaGuess: AnilistMediaGuess | null = null; -let anilistCurrentMediaGuessPromise: Promise | null = null; -let anilistLastDurationProbeAtMs = 0; -let anilistUpdateInFlight = false; +let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = + createInitialAnilistMediaGuessRuntimeState(); +let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState(); const anilistAttemptedUpdateKeys = new Set(); let anilistCachedAccessToken: string | null = null; let jellyfinPlayQuitOnDisconnectArmed = false; @@ -644,11 +654,17 @@ const buildImmersionMediaRuntimeMainDepsHandler = createBuildImmersionMediaRunti const buildAnilistStateRuntimeMainDepsHandler = createBuildAnilistStateRuntimeMainDepsHandler({ getClientSecretState: () => appState.anilistClientSecretState, setClientSecretState: (next) => { - appState.anilistClientSecretState = next; + appState.anilistClientSecretState = transitionAnilistClientSecretState( + appState.anilistClientSecretState, + next, + ); }, getRetryQueueState: () => appState.anilistRetryQueueState, setRetryQueueState: (next) => { - appState.anilistRetryQueueState = next; + appState.anilistRetryQueueState = transitionAnilistRetryQueueState( + appState.anilistRetryQueueState, + next, + ); }, getUpdateQueueSnapshot: () => anilistUpdateQueue.getSnapshot(), clearStoredToken: () => anilistTokenStore.clearToken(), @@ -1563,51 +1579,87 @@ const { }, resetMediaTrackingMainDeps: { setMediaKey: (value) => { - anilistCurrentMediaKey = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaKey: value }, + ); }, setMediaDurationSec: (value) => { - anilistCurrentMediaDurationSec = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaDurationSec: value }, + ); }, setMediaGuess: (value) => { - anilistCurrentMediaGuess = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuess: value }, + ); }, setMediaGuessPromise: (value) => { - anilistCurrentMediaGuessPromise = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuessPromise: value }, + ); }, setLastDurationProbeAtMs: (value) => { - anilistLastDurationProbeAtMs = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { lastDurationProbeAtMs: value }, + ); }, }, getMediaGuessRuntimeStateMainDeps: { - getMediaKey: () => anilistCurrentMediaKey, - getMediaDurationSec: () => anilistCurrentMediaDurationSec, - getMediaGuess: () => anilistCurrentMediaGuess, - getMediaGuessPromise: () => anilistCurrentMediaGuessPromise, - getLastDurationProbeAtMs: () => anilistLastDurationProbeAtMs, + getMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, + getMediaDurationSec: () => anilistMediaGuessRuntimeState.mediaDurationSec, + getMediaGuess: () => anilistMediaGuessRuntimeState.mediaGuess, + getMediaGuessPromise: () => anilistMediaGuessRuntimeState.mediaGuessPromise, + getLastDurationProbeAtMs: () => anilistMediaGuessRuntimeState.lastDurationProbeAtMs, }, setMediaGuessRuntimeStateMainDeps: { setMediaKey: (value) => { - anilistCurrentMediaKey = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaKey: value }, + ); }, setMediaDurationSec: (value) => { - anilistCurrentMediaDurationSec = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaDurationSec: value }, + ); }, setMediaGuess: (value) => { - anilistCurrentMediaGuess = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuess: value }, + ); }, setMediaGuessPromise: (value) => { - anilistCurrentMediaGuessPromise = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuessPromise: value }, + ); }, setLastDurationProbeAtMs: (value) => { - anilistLastDurationProbeAtMs = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { lastDurationProbeAtMs: value }, + ); }, }, resetMediaGuessStateMainDeps: { setMediaGuess: (value) => { - anilistCurrentMediaGuess = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuess: value }, + ); }, setMediaGuessPromise: (value) => { - anilistCurrentMediaGuessPromise = value; + anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( + anilistMediaGuessRuntimeState, + { mediaGuessPromise: value }, + ); }, }, maybeProbeDurationMainDeps: { @@ -1635,10 +1687,16 @@ const { nextReady: () => anilistUpdateQueue.nextReady(), refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), setLastAttemptAt: (value) => { - appState.anilistRetryQueueState.lastAttemptAt = value; + appState.anilistRetryQueueState = transitionAnilistRetryQueueLastAttemptAt( + appState.anilistRetryQueueState, + value, + ); }, setLastError: (value) => { - appState.anilistRetryQueueState.lastError = value; + appState.anilistRetryQueueState = transitionAnilistRetryQueueLastError( + appState.anilistRetryQueueState, + value, + ); }, refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), updateAnilistPostWatchProgress: (accessToken, title, episode) => @@ -1656,15 +1714,18 @@ const { now: () => Date.now(), }, maybeRunPostWatchUpdateMainDeps: { - getInFlight: () => anilistUpdateInFlight, + getInFlight: () => anilistUpdateInFlightState.inFlight, setInFlight: (value) => { - anilistUpdateInFlight = value; + anilistUpdateInFlightState = transitionAnilistUpdateInFlightState( + anilistUpdateInFlightState, + value, + ); }, getResolvedConfig: () => getResolvedConfig(), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), getCurrentMediaKey: () => getCurrentAnilistMediaKey(), hasMpvClient: () => Boolean(appState.mpvClient), - getTrackedMediaKey: () => anilistCurrentMediaKey, + getTrackedMediaKey: () => anilistMediaGuessRuntimeState.mediaKey, resetTrackedMedia: (mediaKey) => { resetAnilistMediaTracking(mediaKey); }, diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts index c3ecd7b..8720ccd 100644 --- a/src/main/runtime/anilist-media-state.test.ts +++ b/src/main/runtime/anilist-media-state.test.ts @@ -53,6 +53,40 @@ test('reset anilist media tracking clears duration/guess/probe state', () => { assert.equal(lastDurationProbeAtMs, 0); }); +test('reset anilist media tracking is idempotent', () => { + const state = { + mediaKey: 'old' as string | null, + mediaDurationSec: 123 as number | null, + mediaGuess: { title: 'guess' } as { title: string } | null, + mediaGuessPromise: Promise.resolve(null) as Promise | null, + lastDurationProbeAtMs: 999, + }; + + const reset = createResetAnilistMediaTrackingHandler({ + setMediaKey: (value) => { + state.mediaKey = value; + }, + setMediaDurationSec: (value) => { + state.mediaDurationSec = value; + }, + setMediaGuess: (value) => { + state.mediaGuess = value as { title: string } | null; + }, + setMediaGuessPromise: (value) => { + state.mediaGuessPromise = value; + }, + setLastDurationProbeAtMs: (value) => { + state.lastDurationProbeAtMs = value; + }, + }); + + reset('/new/media'); + const afterFirstReset = { ...state }; + reset('/new/media'); + + assert.deepEqual(state, afterFirstReset); +}); + test('get/set anilist media guess runtime state round-trips fields', () => { let state = { mediaKey: null as string | null, @@ -106,19 +140,27 @@ test('get/set anilist media guess runtime state round-trips fields', () => { }); test('reset anilist media guess state clears guess and in-flight promise', () => { - let mediaGuess: { title: string } | null = { title: 'guess' }; - let mediaGuessPromise: Promise | null = Promise.resolve(null); + const state = { + mediaKey: '/tmp/video.mkv' as string | null, + mediaDurationSec: 240 as number | null, + mediaGuess: { title: 'guess' } as { title: string } | null, + mediaGuessPromise: Promise.resolve(null) as Promise | null, + lastDurationProbeAtMs: 321, + }; const resetGuessState = createResetAnilistMediaGuessStateHandler({ setMediaGuess: (value) => { - mediaGuess = value as { title: string } | null; + state.mediaGuess = value as { title: string } | null; }, setMediaGuessPromise: (value) => { - mediaGuessPromise = value; + state.mediaGuessPromise = value; }, }); resetGuessState(); - assert.equal(mediaGuess, null); - assert.equal(mediaGuessPromise, null); + assert.equal(state.mediaGuess, null); + assert.equal(state.mediaGuessPromise, null); + assert.equal(state.mediaKey, '/tmp/video.mkv'); + assert.equal(state.mediaDurationSec, 240); + assert.equal(state.lastDurationProbeAtMs, 321); }); diff --git a/src/main/runtime/anilist-state.test.ts b/src/main/runtime/anilist-state.test.ts index 27b3e35..bc53248 100644 --- a/src/main/runtime/anilist-state.test.ts +++ b/src/main/runtime/anilist-state.test.ts @@ -34,8 +34,6 @@ function createRuntime() { pending: 7, ready: 8, deadLetter: 9, - lastAttemptAt: 3000, - lastError: 'boom' as string | null, }), clearStoredToken: () => { clearedStoredToken = true; @@ -71,7 +69,7 @@ test('setClientSecretState merges partial updates', () => { }); }); -test('refresh/get queue snapshot uses update queue snapshot', () => { +test('queue refresh preserves metadata while syncing counts', () => { const harness = createRuntime(); const snapshot = harness.runtime.getQueueStatusSnapshot(); @@ -79,14 +77,15 @@ test('refresh/get queue snapshot uses update queue snapshot', () => { pending: 7, ready: 8, deadLetter: 9, - lastAttemptAt: 3000, - lastError: 'boom', + lastAttemptAt: 2000, + lastError: 'none', }); assert.deepEqual(harness.getQueueState(), snapshot); }); test('clearTokenState resets token state and clears caches', () => { const harness = createRuntime(); + const queueBeforeClear = { ...harness.getQueueState() }; harness.runtime.clearTokenState(); assert.equal(harness.getClearedStoredToken(), true); @@ -98,4 +97,5 @@ test('clearTokenState resets token state and clears caches', () => { resolvedAt: null, errorAt: null, }); + assert.deepEqual(harness.getQueueState(), queueBeforeClear); }); diff --git a/src/main/state.test.ts b/src/main/state.test.ts new file mode 100644 index 0000000..aaa9f44 --- /dev/null +++ b/src/main/state.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createInitialAnilistMediaGuessRuntimeState, + createInitialAnilistUpdateInFlightState, + transitionAnilistClientSecretState, + transitionAnilistMediaGuessRuntimeState, + transitionAnilistRetryQueueLastAttemptAt, + transitionAnilistRetryQueueLastError, + transitionAnilistUpdateInFlightState, +} from './state'; + +test('transitionAnilistClientSecretState replaces state object', () => { + const current = { + status: 'not_checked', + source: 'none', + message: null, + resolvedAt: null, + errorAt: null, + } as const; + const next = { + status: 'resolved', + source: 'stored', + message: 'ok', + resolvedAt: 123, + errorAt: null, + } as const; + + const transitioned = transitionAnilistClientSecretState(current, next); + + assert.deepEqual(transitioned, next); + assert.equal(transitioned, next); +}); + +test('retry queue metadata transitions preserve queue counts', () => { + const queue = { + pending: 2, + ready: 1, + deadLetter: 4, + lastAttemptAt: null, + lastError: null, + }; + + const attempted = transitionAnilistRetryQueueLastAttemptAt(queue, 999); + const failed = transitionAnilistRetryQueueLastError(attempted, 'boom'); + + assert.deepEqual(attempted, { + pending: 2, + ready: 1, + deadLetter: 4, + lastAttemptAt: 999, + lastError: null, + }); + assert.deepEqual(failed, { + pending: 2, + ready: 1, + deadLetter: 4, + lastAttemptAt: 999, + lastError: 'boom', + }); + assert.notEqual(attempted, queue); + assert.notEqual(failed, attempted); +}); + +test('transitionAnilistMediaGuessRuntimeState applies partial updates', () => { + const current = createInitialAnilistMediaGuessRuntimeState(); + const promise = Promise.resolve(null); + + const transitioned = transitionAnilistMediaGuessRuntimeState(current, { + mediaKey: '/tmp/media.mkv', + mediaGuessPromise: promise, + lastDurationProbeAtMs: 500, + }); + + assert.deepEqual(transitioned, { + mediaKey: '/tmp/media.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: promise, + lastDurationProbeAtMs: 500, + }); + assert.notEqual(transitioned, current); +}); + +test('transitionAnilistUpdateInFlightState updates inFlight only', () => { + const current = createInitialAnilistUpdateInFlightState(); + const transitioned = transitionAnilistUpdateInFlightState(current, true); + + assert.deepEqual(current, { inFlight: false }); + assert.deepEqual(transitioned, { inFlight: true }); + assert.notEqual(transitioned, current); +}); diff --git a/src/main/state.ts b/src/main/state.ts index e6db108..ed0bfe9 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -12,13 +12,14 @@ import type { import type { CliArgs } from '../cli/args'; import type { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import type { AnkiIntegration } from '../anki-integration'; -import type { ImmersionTrackerService } from '../core/services'; -import type { MpvIpcClient } from '../core/services'; -import type { JellyfinRemoteSessionService } from '../core/services'; -import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services'; +import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service'; +import type { MpvIpcClient } from '../core/services/mpv'; +import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote'; +import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics'; import type { RuntimeOptionsManager } from '../runtime-options'; import type { MecabTokenizer } from '../mecab-tokenizer'; import type { BaseWindowTracker } from '../window-trackers'; +import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; export interface AnilistSecretResolutionState { status: 'not_checked' | 'resolved' | 'error'; @@ -36,6 +37,108 @@ export interface AnilistRetryQueueState { lastError: string | null; } +export interface AnilistMediaGuessRuntimeState { + mediaKey: string | null; + mediaDurationSec: number | null; + mediaGuess: AnilistMediaGuess | null; + mediaGuessPromise: Promise | null; + lastDurationProbeAtMs: number; +} + +export interface AnilistUpdateInFlightState { + inFlight: boolean; +} + +export function createInitialAnilistSecretResolutionState(): AnilistSecretResolutionState { + return { + status: 'not_checked', + source: 'none', + message: null, + resolvedAt: null, + errorAt: null, + }; +} + +export function createInitialAnilistRetryQueueState(): AnilistRetryQueueState { + return { + pending: 0, + ready: 0, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }; +} + +export function createInitialAnilistMediaGuessRuntimeState(): AnilistMediaGuessRuntimeState { + return { + mediaKey: null, + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; +} + +export function createInitialAnilistUpdateInFlightState(): AnilistUpdateInFlightState { + return { + inFlight: false, + }; +} + +export function transitionAnilistClientSecretState( + _current: AnilistSecretResolutionState, + next: AnilistSecretResolutionState, +): AnilistSecretResolutionState { + return next; +} + +export function transitionAnilistRetryQueueState( + _current: AnilistRetryQueueState, + next: AnilistRetryQueueState, +): AnilistRetryQueueState { + return next; +} + +export function transitionAnilistRetryQueueLastAttemptAt( + current: AnilistRetryQueueState, + lastAttemptAt: number | null, +): AnilistRetryQueueState { + return { + ...current, + lastAttemptAt, + }; +} + +export function transitionAnilistRetryQueueLastError( + current: AnilistRetryQueueState, + lastError: string | null, +): AnilistRetryQueueState { + return { + ...current, + lastError, + }; +} + +export function transitionAnilistMediaGuessRuntimeState( + current: AnilistMediaGuessRuntimeState, + partial: Partial, +): AnilistMediaGuessRuntimeState { + return { + ...current, + ...partial, + }; +} + +export function transitionAnilistUpdateInFlightState( + current: AnilistUpdateInFlightState, + inFlight: boolean, +): AnilistUpdateInFlightState { + return { + ...current, + inFlight, + }; +} + export interface AppState { yomitanExt: Extension | null; yomitanSettingsWindow: BrowserWindow | null; @@ -123,13 +226,7 @@ export function createAppState(values: AppStateInitialValues): AppState { currentMediaPath: null, currentMediaTitle: null, pendingSubtitlePosition: null, - anilistClientSecretState: { - status: 'not_checked', - source: 'none', - message: null, - resolvedAt: null, - errorAt: null, - }, + anilistClientSecretState: createInitialAnilistSecretResolutionState(), mecabTokenizer: null, keybindings: [], subtitleTimingTracker: null, @@ -159,13 +256,7 @@ export function createAppState(values: AppStateInitialValues): AppState { jlptLevelLookup: () => null, frequencyRankLookup: () => null, anilistSetupPageOpened: false, - anilistRetryQueueState: { - pending: 0, - ready: 0, - deadLetter: 0, - lastAttemptAt: null, - lastError: null, - }, + anilistRetryQueueState: createInitialAnilistRetryQueueState(), }; }