diff --git a/.v10-worktrees/README.md b/.v10-worktrees/README.md new file mode 100644 index 00000000..7bf64ad0 --- /dev/null +++ b/.v10-worktrees/README.md @@ -0,0 +1,88 @@ +# Task Genius v10 — Worktree Briefs + +This directory holds **self-contained briefs** for the 5 worktrees that +execute v10 Phase 1-4. Each brief is meant to be handed to a single agent +(or developer) who will create a worktree, do the work, and merge. + +The full v10 plan lives at `~/.claude/plans/dynamic-mixing-pie.md` (in the +user's Claude config). Each brief here references the main plan but is +written to be readable on its own — an agent picking up brief D should not +need to read the entire v10 plan to start working. + +## The 5 worktrees + +| # | Brief | Phase | Branch | Calendar | Depends on | +|---|---|---|---|---|---| +| **A** | [worktree-a-banners.md](./worktree-a-banners.md) | 1 (9.14.0) | `refactor/v10-phase1-banners` | ~1 wk | Phase 0 (✅ done) | +| **B** | [worktree-b-archiver.md](./worktree-b-archiver.md) | 1 (9.14.0) | `refactor/v10-phase1-archiver` | ~1 wk | Phase 0 (✅ done) | +| **C** | [worktree-c-readonly.md](./worktree-c-readonly.md) | 2 (9.15.0) | `refactor/v10-phase2-readonly` | ~1 wk | A merged to master | +| **D** | [worktree-d-cliff.md](./worktree-d-cliff.md) | 3 (10.0.0) | `refactor/v10-phase3-cliff` | ~1 wk | C merged to master | +| **E** | [worktree-e-cleanup.md](./worktree-e-cleanup.md) | 4 (10.0.1) | `refactor/v10-phase4-cleanup` | ~1 wk | D merged to master | + +## How to spawn agents + +**Parallel start (recommended):** spawn agents A and B simultaneously, both +branched off `refactor/v10-phase0`. They modify disjoint files. Once A merges +to master, spawn C. Then D after C, then E after D. + +**Solo execution:** the single-developer path is `A → B → C → D.2 → D.1 → D.3 +→ D.4 → D.5 → E`. The worktree split is informational; you can use one working +tree if you prefer. + +## Phase 0 status (the ground each worktree stands on) + +Phase 0 is **complete** on `refactor/v10-phase0`. 6 commits, 71 new passing +tests, 10 of 12 DoD items checked. The 2 deferred items (delete TaskView.ts, +fold settings-migration.ts into the registry) are documented in +[../PHASE0_DEFERRED.md](../PHASE0_DEFERRED.md) and explicitly assigned to +worktree D sub-PRs (D.4 and D.2 respectively). + +Phase 0 commits on `refactor/v10-phase0`: +- `dd3fe89a` — W0 test infrastructure (buildOrchestrator + InMemoryStorage + localforage mock) +- `1de55f4c` — W2/W2-bis/W3 (onunload race fixed, rebuild last-resort, worker timeouts) +- `fe4c976f` — W4 (typed cache scope map + invariants checker + LocalStorageCache version fix) +- `12481c17` — W1 (MigrationRegistry with version-keyed tombstones) +- `d583e967` — W5 (critical-path integration tests: roundtrip / settingsChange / cache invariants sequence) +- `e90fc537` — Phase 0 docs (PHASE0_DEFERRED.md, WORKTREE_PLAN.md) + +## Decisions resolved (defaults — user can override before any worktree commits) + +The 5 open questions from the main plan have been answered with sensible +defaults so worktrees can start without waiting on more decisions: + +1. **Deprecation list:** ship exactly the 14 features listed in the main plan + (Workflow, Habit, Reward, Timer, Gantt, Quadrant, Timeline Sidebar, + File-as-Task, Holiday detector, Electron quick-capture, 4 quick-capture + modal variants, Working-On view, Flagged view, Habit view). Pull-back can + happen at sub-PR review time. + +2. **Sub-plugin commitment:** YES, 4 new repos + (`task-genius-workflow`, `task-genius-habits`, `task-genius-timer`, + `task-genius-calendar-sync`). Each is small (~3-6k LOC). They share the + same tooling as the main plugin; the boundary is "share vault files, + nothing else" (no DataflowOrchestrator coupling). + +3. **Default hotkeys:** SHIP the 5 proposed bindings + (`Mod+Shift+T` capture, `Mod+Shift+G` open view, `Mod+Shift+I` inbox, + `Mod+Shift+F` forecast, `Mod+Alt+P` priority, `Mod+Enter` mark done on + task line). Document in CHANGELOG that v10 sets defaults for the first + time. Users can opt out via Obsidian's hotkey settings. + +4. **Cliff version:** 9.14 → 9.15 → 10.0 over 4 weeks (the original + proposal). Faster is better; the deprecation period exists to give beta + testers time, not as a UX runway. + +5. **Beta tester recruitment:** pin a GitHub Discussion explicitly recruiting + testers. Don't depend on a pre-existing pool. The Discussion goes up the + day Worktree A starts; recruitment runs in parallel with banner work. + +If the user wants to override any of these, they can do so before the +relevant worktree commits to a particular answer — most decisions come into +play in Worktree D (Phase 3), so the runway is ~3-4 weeks. + +## See also + +- [`../PHASE0_DEFERRED.md`](../PHASE0_DEFERRED.md) — the 2 W6 items deferred from Phase 0 +- [`../WORKTREE_PLAN.md`](../WORKTREE_PLAN.md) — the original umbrella worktree plan (this directory subsumes it; WORKTREE_PLAN.md kept for reference) +- [`../PLAN.md`](../PLAN.md) — the original v10 widgets requirements doc (predates this refactor effort) +- `~/.claude/plans/dynamic-mixing-pie.md` — the full v10 refactor plan (in the user's Claude config) diff --git a/.v10-worktrees/worktree-a-banners.md b/.v10-worktrees/worktree-a-banners.md new file mode 100644 index 00000000..cd6e0c73 --- /dev/null +++ b/.v10-worktrees/worktree-a-banners.md @@ -0,0 +1,234 @@ +# Worktree A — Phase 1 Deprecation Banners (9.14.0) + +## What you're doing + +Task Genius is being slimmed down for v10 (~95k LOC → ~40k LOC). Phase 1 is +the **announce + soft warning** week: deprecated settings tabs get a yellow +banner, the changelog modal auto-opens once on first launch of 9.14.0, and +nothing actually breaks for users yet. This is the gentle on-ramp. + +Your worktree is one of two Phase 1 worktrees that run **in parallel**: +- **Worktree A (this one)** — UI banners + changelog modal (user-facing) +- **Worktree B** — Archiver pure functions (infrastructure for Phase 3) + +A and B touch disjoint files. Both branch off `refactor/v10-phase0`. + +## Phase 0 ground + +You're branching off `refactor/v10-phase0` (already merged-quality, 6 commits, +71 new tests). Phase 0 added the MigrationRegistry, fixed lifecycle hazards, +added cache guardrails, and shipped integration test infrastructure. None of +that affects your work directly — you're touching settings UI files, not +dataflow. + +## Setup + +```bash +# From the main repo root +git fetch +git worktree add ../tg-phase1-banners refactor/v10-phase0 +cd ../tg-phase1-banners +git checkout -b refactor/v10-phase1-banners + +# Verify the integration tests pass on this branch +npm install +npm test -- --testPathPattern="integration/" +# Expect: 70+ passing in the integration namespace +``` + +## Files you'll create + +- `src/components/features/settings/components/DeprecationBanner.ts` — new component (~60 LOC) +- `src/common/deprecation-messages.ts` — centralized i18n strings (~80 LOC) + +## Files you'll modify (14 deprecated tabs + supporting files) + +The 14 deprecated tabs each get a one-line `DeprecationBanner` injection at +the top of their `renderXxxSettingsTab()` function: + +1. `src/components/features/settings/tabs/WorkflowSettingsTab.ts` → "Workflows move to `task-genius-workflow` in v10" +2. `src/components/features/settings/tabs/HabitSettingsTab.ts` → "Habits move to `task-genius-habits`" +3. `src/components/features/settings/tabs/RewardSettingsTab.ts` → "Rewards move to `task-genius-habits`" +4. `src/components/features/settings/tabs/TaskTimerSettingsTab.ts` → "Timer moves to `task-genius-timer`" +5. `src/components/features/settings/tabs/TimelineSidebarSettingsTab.ts` → "Timeline Sidebar removed in v10. Forecast view replaces it." +6. `src/components/features/settings/tabs/IcsSettingsTab.ts` (OAuth/CalDAV section only — read-only ICS stays in main plugin) → "OAuth/CalDAV moves to `task-genius-calendar-sync`" +7. `src/components/features/settings/tabs/IndexSettingsTab.ts` (file-as-task source section) → "File-as-Task source removed in v10" +8. `src/components/features/settings/tabs/DesktopIntegrationSettingsTab.ts` (electron-quick-capture window section) → "Electron quick-capture window removed in v10" +9-12. Sections within the views tab: Gantt, Quadrant, Habit view, Working-On view, Flagged view (5 view-config entries deprecated) +13. Holiday detector toggle (find via `grep -r "holiday-detector" src/components/features/settings/`) +14. AboutSettingsTab — gets a "Deprecations" collapsible at the top + +Plus: +- `src/components/features/settings/index.ts` — export the new banner component +- `src/managers/changelog-manager.ts` — wire to auto-open the v10 announcement on 9.14.0 first launch +- `src/translations/locale/en.ts` — add new strings for banner copy + +## Tasks (in order) + +### Task 1 — Build the DeprecationBanner component (~½ day) + +`src/components/features/settings/components/DeprecationBanner.ts`: + +```ts +export interface DeprecationBannerProps { + tabId: string; + replacementText: string; // e.g. "Workflows move to task-genius-workflow" + exportLabel?: string; // e.g. "Export this section now" + exportAction?: () => Promise | void; + learnMoreUrl?: string; + // Phase 2 will add: readOnly?: boolean +} + +export function renderDeprecationBanner( + containerEl: HTMLElement, + props: DeprecationBannerProps, +): void { + // Yellow background, alert icon, message text, optional Export button, + // optional "Learn more" link. ~60 LOC. CSS class .tg-deprecation-banner. +} +``` + +Add CSS in `src/styles/setting.scss` (or wherever the existing settings styles +live — grep `.task-genius-setting`). + +### Task 2 — Centralize the strings (~½ day) + +`src/common/deprecation-messages.ts`: + +```ts +export const DEPRECATION_MESSAGES = { + workflow: { + bannerText: "Workflows move to the task-genius-workflow plugin in v10. ...", + exportLabel: "Export workflows now", + learnMoreUrl: "https://github.com/.../discussions/...", + }, + habit: { ... }, + // ... 14 total entries +} as const; +``` + +This becomes the single source of truth so locales translate one set of +strings, not 14 spread across tabs. Phase 2 will add more keys here. + +### Task 3 — Wire banners into 14 tabs (~1.5 days) + +For each of the 14 tab files, add a one-line `renderDeprecationBanner(containerEl, ...)` +call at the top of the render function (before any existing content). The +tab continues to function normally; the banner is purely additive. + +For tabs that have a deprecated **section** (not the whole tab) — IcsSettingsTab, +IndexSettingsTab, DesktopIntegrationSettingsTab — render the banner above the +deprecated section, not the whole tab. + +The "Export this section now" button calls a stub from Worktree B's Archiver: + +```ts +import { archiveWorkflows } from "@/migration/v10/Archiver"; // from Worktree B + +exportAction: async () => { + const result = await archiveWorkflows(plugin); + // For Phase 1 we just write to vault — Phase 3 wraps in transactional migration + for (const [path, content] of result.files) { + await plugin.app.vault.create(path, content); + } + new Notice(`Archived ${result.files.size} files to Task Genius Archive/`); +}, +``` + +**Coordination with Worktree B:** if B hasn't merged yet, stub the import as +`async () => { new Notice("Export coming soon"); }` and add a TODO. Do not +block on B. + +### Task 4 — AboutSettingsTab "Deprecations" panel (~½ day) + +`src/components/features/settings/tabs/AboutSettingsTab.ts` — add a collapsible +section at the top of the tab listing all 14 deprecations with one global +"Export everything" button. The button calls all 7 archive functions in +sequence (or sequentially with a progress notice if you want to be fancy). + +### Task 5 — Auto-open v10 announcement on 9.14.0 first launch (~½ day) + +`src/managers/changelog-manager.ts` already auto-opens on version change. +Verify the existing logic and add a one-time announcement modal for 9.14.0 +that links to the pinned GitHub Discussion. Body content goes in +`src/translations/locale/en.ts` (a new key like `v10AnnouncementBody`). + +### Task 6 — Update CHANGELOG.md and create the GitHub Discussion (~½ day, out of repo) + +- Add a `## 9.14.0` section to CHANGELOG.md announcing the v10 deprecations +- Create a pinned GitHub Discussion titled **"Task Genius v10: what's changing + and how to prepare"** with the TL;DR table from the main plan, the archive + folder explanation, sub-plugin install instructions, and an FAQ section +- Pin a thread for beta tester recruitment + +## Definition of Done + +| # | Criterion | How to verify | +|---|---|---| +| 1 | All 14 deprecated tabs/sections show banners in real-vault smoke test | Manual: open Settings, visit each, confirm banner is at the top | +| 2 | Each banner's "Export this section" button creates files in `/Task Genius Archive/
/` | Manual: click each export button, verify the folder appears | +| 3 | Changelog modal auto-opens once on 9.14.0 first launch | Manual: bump manifest.json to 9.14.0, reload plugin, verify modal opens; reload again, verify it does NOT open | +| 4 | AboutSettingsTab has a "Deprecations" collapsible at the top with a global "Export everything" button | Manual | +| 5 | No regression in existing tab rendering (all 25 tabs still render their existing content) | Manual + `npm test` | +| 6 | New strings only in `en.ts`; locale fallback handles other languages | Code review | +| 7 | TypeScript build clean (`npm run build`) | Auto | +| 8 | Phase 0 integration tests still pass | `npm test -- --testPathPattern="integration/"` | +| 9 | CHANGELOG.md updated, GitHub Discussion pinned | Manual | + +## Conflicts to watch + +- **Worktree C (Phase 2)** will modify the same 14 tab files to add read-only + mode. **C cannot start until A is merged to master.** Don't try to run them + in parallel. +- **Worktree D.1 (Phase 3)** will DELETE most of these tabs. The banners get + removed in Phase 3. That's expected — your work is the bridge that gives + users 4 weeks of warning before the deletion. + +## Open questions that affect THIS worktree + +The v10 plan has 5 open questions; defaults are documented in +[`./README.md`](./README.md). The two that touch your work: + +- **Q1 — Deprecation list:** the 14 features listed above are the default. If + the user pulls any back, your task list shrinks accordingly. Wait for + user confirmation if you're about to ship a banner for a feature they + changed their mind about. +- **Q5 — Beta tester recruitment:** the GitHub Discussion goes up when your + worktree starts. If the user doesn't have a tester pool, the Discussion + recruits explicitly. + +The other 3 (sub-plugin commitment, hotkey policy, cliff version) don't +affect this worktree — they kick in for Worktree C and D. + +## Useful existing utilities to reuse + +- `src/managers/changelog-manager.ts` — already supports auto-open on version change (Task 5) +- `src/components/features/changelog/` — ChangelogView component (re-use for the announcement) +- `src/translations/helper.ts` — `t()` translation helper +- The existing `Setting()` chain pattern from any tab file is your model for adding the banner above existing settings + +## Don't do these things + +- **Don't delete any tabs.** Phase 3 owns deletions. Banners are additive only. +- **Don't make any tab read-only.** That's Phase 2 / Worktree C. +- **Don't wire the banners' export action through MigrationRegistry.** That's + Phase 3 / Worktree D.5. For Phase 1, the export is a direct call to the + Archiver function (which Worktree B is building in parallel). +- **Don't touch the dataflow layer.** Your changes are 100% UI. +- **Don't translate strings to non-English locales.** Just `en.ts`. The locale + prune script in Phase 4 (Worktree E) handles parity. +- **Don't migrate any callers off `Orchestrator.onSettingsChange(scopes[])` to + the typed `onSettingsFieldsChanged(fields[])`.** Leave that for Phase 1+ + feature work as features are touched. Your worktree shouldn't even import + the Orchestrator. + +## When you're done + +```bash +# From your worktree +git push origin refactor/v10-phase1-banners +gh pr create --base master --title "feat(settings): deprecation banners (Phase 1, Worktree A)" +``` + +After merge, signal that **Worktree C is unblocked** (it depends on A's +merge to master). diff --git a/.v10-worktrees/worktree-b-archiver.md b/.v10-worktrees/worktree-b-archiver.md new file mode 100644 index 00000000..482ac623 --- /dev/null +++ b/.v10-worktrees/worktree-b-archiver.md @@ -0,0 +1,291 @@ +# Worktree B — Phase 1 Archiver Pure Functions (9.14.0) + +## What you're doing + +Task Genius is being slimmed down for v10. When users upgrade to 10.0.0, +their persistent data for deprecated features (workflows, habits, rewards, +etc.) gets archived to a vault folder so they don't lose it. The archive +folder is also where extracted sub-plugins look for "previous data" on first +install. + +Your job is to build the **pure functions** that produce the archive — the +serialization logic — without any wiring to the UI or the migration system. +That wiring is Phase 3 / Worktree D.5. + +You're running **in parallel with Worktree A** (banners). A modifies UI files, +B modifies new infrastructure files. Zero conflict. + +## Phase 0 ground + +You're branching off `refactor/v10-phase0` (already merged-quality, 6 commits). +Phase 0 added the MigrationRegistry which Phase 3 will use to wrap your +archiver functions in atomic migration steps. You don't need to touch the +registry yourself. + +## Setup + +```bash +git fetch +git worktree add ../tg-phase1-archiver refactor/v10-phase0 +cd ../tg-phase1-archiver +git checkout -b refactor/v10-phase1-archiver + +npm install +npm test -- --testPathPattern="integration/" +# Expect: 70+ passing +``` + +## Files you'll create + +- `src/migration/v10/Archiver.ts` — 7 pure archive functions (~300 LOC) +- `src/migration/v10/index.ts` — barrel export +- `src/migration/v10/types.ts` — shared types (`ArchiveSection`, etc.) +- `src/__tests__/migration/v10/Archiver.test.ts` — fixture-based tests (~250 LOC) +- `src/__tests__/migration/v10/__fixtures__/realistic-settings.ts` — sample settings shapes for the 7 sections + +## Files you will NOT touch + +- `src/index.ts` (Worktree D wires the archiver into onload) +- `src/utils/migration/MigrationRegistry.ts` (Worktree D adds the v10-archive step) +- Any settings tab files (Worktree A injects the export buttons that *call* your functions) +- The DataflowOrchestrator (no dataflow concerns here) + +## Tasks (in order) + +### Task 1 — Define the contract (~½ day) + +`src/migration/v10/types.ts`: + +```ts +/** + * The result of one archive function. Pure data — no I/O happens inside the + * function. The caller is responsible for actually writing files via vault. + */ +export interface ArchiveSection { + /** Section name, e.g. "workflows", "habits", "timer". */ + section: string; + /** Files to write, keyed by relative path under /Task Genius Archive/ */ + files: Map; + /** Human-readable summary for the migration confirmation modal. */ + summary: string; + /** Optional structured count for the modal: {workflows: 47, habits: 12} */ + itemCount: number; +} + +export interface ArchiveManifest { + version: string; // plugin version that produced this archive + exportedAt: number; // unix epoch ms + sections: Array<{ + section: string; + itemCount: number; + files: string[]; // relative paths + }>; +} +``` + +### Task 2 — Implement the 7 archive functions (~3-4 days) + +`src/migration/v10/Archiver.ts`: + +```ts +import type TaskProgressBarPlugin from "@/index"; +import type { ArchiveSection } from "./types"; + +export async function archiveWorkflows( + plugin: TaskProgressBarPlugin, +): Promise { + const workflows = plugin.settings.workflow?.definitions ?? []; + const files = new Map(); + + // workflows.json — raw shape, round-trippable + files.set("workflows/workflows.json", JSON.stringify( + { version: "v10-archive-1", definitions: workflows }, + null, + 2, + )); + + // workflows.md — human-readable summary, one workflow per H2 + const md = renderWorkflowsMarkdown(workflows); + files.set("workflows/workflows.md", md); + + return { + section: "workflows", + files, + summary: `${workflows.length} workflow definition${workflows.length === 1 ? "" : "s"}`, + itemCount: workflows.length, + }; +} + +// ... 6 more functions +``` + +The 7 functions to implement (each takes `plugin` and returns an `ArchiveSection`): + +1. **`archiveWorkflows`** — reads `plugin.settings.workflow.definitions`. Output: `workflows/workflows.json` + `workflows/workflows.md`. +2. **`archiveHabits`** — reads `plugin.settings.habit.habits` (array). Output: `habits/habits.json` + `habits/habits.md`. Each habit gets a markdown section with type, schedule, and history if present. +3. **`archiveRewards`** — reads `plugin.settings.rewards.rewardItems` and `occurrenceLevels`. Output: `rewards/rewards.json` + `rewards/rewards.md`. +4. **`archiveTimerData`** — reads `plugin.settings.taskTimer` config + any in-memory `timerManager` state if accessible. Output: `timer/timer-data.json` + `timer/timer-summary.md`. Note: timer state may also live in localStorage; check `src/services/timer-export-service.ts` for the existing export shape and reuse it. +5. **`archiveCalDavSources`** — reads `plugin.settings.icsIntegration.sources` AND OAuth provider configs. **CRITICAL: strip OAuth tokens** before serializing. Output: `calendar-sync/caldav-sources.json` (URLs + names only). +6. **`archiveRemovedViews`** — reads `plugin.settings.viewConfiguration[]` and filters to entries whose ID is in `["gantt", "quadrant", "habit", "working-on", "flagged"]`. Output: `views/removed-views.json` (per-view filterRules + sort + visibility). +7. **`archiveOrphans`** — catch-all: reads any settings keys that don't have a dedicated archiver and aren't in the v10 keep-list. Output: `orphan-settings.json`. This is the safety net for "we deprecated something we forgot to write a function for." + +Plus a top-level orchestrator: + +```ts +export async function archiveAll( + plugin: TaskProgressBarPlugin, +): Promise<{ sections: ArchiveSection[]; manifest: ArchiveManifest }> { + const sections = await Promise.all([ + archiveWorkflows(plugin), + archiveHabits(plugin), + archiveRewards(plugin), + archiveTimerData(plugin), + archiveCalDavSources(plugin), + archiveRemovedViews(plugin), + archiveOrphans(plugin), + ]); + + const manifest: ArchiveManifest = { + version: plugin.manifest.version, + exportedAt: Date.now(), + sections: sections.map((s) => ({ + section: s.section, + itemCount: s.itemCount, + files: [...s.files.keys()], + })), + }; + + return { sections, manifest }; +} +``` + +### Task 3 — Build realistic test fixtures (~½ day) + +`src/__tests__/migration/v10/__fixtures__/realistic-settings.ts`: + +```ts +export const settingsWith47Workflows: Partial = { + workflow: { + enableWorkflow: true, + definitions: [/* 47 realistic workflow shapes */], + timestampFormat: "YYYY-MM-DD HH:mm", + autoAddTimestamp: true, + calculateSpentTime: false, + }, +}; + +export const settingsWith12Habits: Partial = { + habit: { + enableHabits: true, + habits: [/* 12 mixed daily/count/scheduled/mapping habits */], + }, +}; + +// ... fixtures for the other 5 sections +``` + +These fixtures double as documentation for what realistic v9 user data looks +like. Phase 3's migration confirmation modal will use the same shapes for its +"X workflows, Y habits" copy. + +### Task 4 — Test fixtures end-to-end (~1 day) + +`src/__tests__/migration/v10/Archiver.test.ts`: + +```ts +import { archiveWorkflows, archiveAll } from "@/migration/v10/Archiver"; +import { settingsWith47Workflows, ... } from "./__fixtures__/realistic-settings"; + +describe("Archiver (Phase 1 Worktree B)", () => { + it("archiveWorkflows produces expected files for 47 workflows", async () => { + const plugin = makeFakePlugin({ settings: settingsWith47Workflows }); + const result = await archiveWorkflows(plugin); + expect(result.itemCount).toBe(47); + expect(result.files.has("workflows/workflows.json")).toBe(true); + expect(result.files.has("workflows/workflows.md")).toBe(true); + const json = JSON.parse(result.files.get("workflows/workflows.json")!); + expect(json.definitions).toHaveLength(47); + }); + + // ... 1 test per section + 1 for archiveAll +}); +``` + +`makeFakePlugin` is a tiny helper — just `{ settings, manifest: {version: "9.14.0"} }`. +You don't need a full plugin instance. + +## Definition of Done + +| # | Criterion | How to verify | +|---|---|---| +| 1 | All 7 archive functions return correct shapes for known inputs | `npm test -- --testPathPattern="migration/v10/Archiver"` | +| 2 | `archiveAll` orchestrator produces a valid manifest | Test asserts manifest shape | +| 3 | OAuth tokens are stripped from `archiveCalDavSources` output | Test asserts no `accessToken`/`refreshToken` keys in JSON | +| 4 | Each section has both a JSON (round-trippable) and markdown (human) representation, where applicable | Test asserts both files exist | +| 5 | Functions are pure: no `vault.create`, no `localforage` calls, no `app.workspace.trigger` | Code review | +| 6 | TypeScript build clean (`npm run build`) | Auto | +| 7 | Phase 0 integration tests still pass | `npm test -- --testPathPattern="integration/"` | +| 8 | NOT wired into MigrationRegistry yet | Code review (no edits to `src/utils/migration/`) | + +## Coordination with Worktree A + +Worktree A's banner export buttons need to call your functions. Provide a +clean import surface: + +```ts +// from src/migration/v10/index.ts +export { archiveWorkflows, archiveHabits, /* ... */, archiveAll } from "./Archiver"; +export type { ArchiveSection, ArchiveManifest } from "./types"; +``` + +Worktree A imports from `@/migration/v10`. If A starts before B, A stubs the +export action with a TODO; once B merges, A removes the stub. **This is a +soft dependency** — A and B are independent worktrees. + +## Conflicts to watch + +**None.** Worktree B touches only new files in `src/migration/v10/` and +`src/__tests__/migration/v10/`. No existing code is modified. + +The only file you write outside that namespace is potentially nothing — even +the orchestrator export goes through `src/migration/v10/index.ts`. + +## Open questions that affect THIS worktree + +- **Q2 — Sub-plugin commitment:** the default is YES, 4 sub-plugins. If the + user changes their mind and wants to delete a feature outright (no sub-plugin + migration), you can SKIP the corresponding archive function — there's no + point archiving data nobody will ever consume. Wait for confirmation if this + changes before you ship `archiveTimerData` etc. + +The other 4 questions don't affect this worktree. + +## Useful existing utilities + +- `src/services/timer-export-service.ts` — already exports timer data to JSON. You can probably import its serialization logic for `archiveTimerData`. +- `src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts` (line ~1006) — has the existing settings shape for quick-capture, useful reference for `archiveOrphans`. +- `src/common/setting-definition.ts` (line ~995, `DEFAULT_SETTINGS`) — list of every settings field, useful for `archiveOrphans` exclusion list. + +## Don't do these things + +- **Don't write to the vault.** Pure functions only. Phase 3 / Worktree D.5 + handles I/O via the migration registry. +- **Don't import obsidian's `Notice`, `Modal`, or `App`.** Your functions take + a plugin instance and return data. Nothing more. +- **Don't add dependencies.** Use plain JSON.stringify / template strings. +- **Don't OVERLY pretty-print the markdown.** A simple `# {section} {N}` + followed by `## {item.name}` for each item is fine. The markdown is a + fallback view; the JSON is the source of truth for sub-plugin importers. +- **Don't try to make the archive "diff-friendly".** Phase 3 is one-shot; + there's no need to support incremental archives. + +## When you're done + +```bash +git push origin refactor/v10-phase1-archiver +gh pr create --base master --title "feat(migration): v10 Archiver pure functions (Phase 1, Worktree B)" +``` + +After merge, Worktree A can remove its stub imports if it shipped with one. +Worktree D.5 (Phase 3 confirmation modal) will wire your `archiveAll` into +the MigrationRegistry as the `v10-archive` step. diff --git a/.v10-worktrees/worktree-c-readonly.md b/.v10-worktrees/worktree-c-readonly.md new file mode 100644 index 00000000..e2f20767 --- /dev/null +++ b/.v10-worktrees/worktree-c-readonly.md @@ -0,0 +1,233 @@ +# Worktree C — Phase 2 Read-Only Mode + Beta (9.15.0) + +## What you're doing + +Phase 1 (Worktrees A + B) shipped 9.14.0 with deprecation banners. Phase 2 is +the **hard warning** week: deprecated settings tabs become read-only (inputs +disabled, only the Export button works), deprecated commands fire a one-shot +Notice on first use per session, and v10 enters beta via BRAT. + +This is the last stop before the cliff. After your worktree merges and ships +9.15.0, the beta channel gets 10.0.0-beta.1 and a ~5 day soak period. + +## Phase 0 + Phase 1 ground + +You depend on **Worktree A merged to master**. Verify before starting: + +```bash +git fetch +git log master --oneline | grep "deprecation banners" +# Expect: "feat(settings): deprecation banners (Phase 1, Worktree A)" present +``` + +If not present, **do not start.** Worktree C cannot run in parallel with A — +you'll fight for the same 14 settings tab files. + +Worktree B (Archiver) can be merged or pending; you only need its functions +for the export buttons (which are already wired by Worktree A). + +## Setup + +```bash +git fetch +git worktree add ../tg-phase2-readonly master +cd ../tg-phase2-readonly +git checkout -b refactor/v10-phase2-readonly + +npm install +npm test -- --testPathPattern="integration/" +# Expect: 70+ passing +``` + +## Files you'll modify + +The same 14 deprecated tab files Worktree A injected banners into. You're +adding a `readOnly` mode to each one: + +1-14. All `src/components/features/settings/tabs/{Workflow,Habit,Reward,TaskTimer,TimelineSidebar,Ics,Index,DesktopIntegration,About}SettingsTab.ts` (and the 5 view-config sections) + +Plus: +- `src/components/features/settings/components/DeprecationBanner.ts` (banner copy update) +- `src/common/deprecation-messages.ts` (add Notice strings for commands) +- `src/index.ts` (wrap deprecated commands with one-shot Notice) +- `src/managers/changelog-manager.ts` (10.0.0-beta.1 announcement modal) +- `manifest-beta.json` (bump to 10.0.0-beta.1) + +## Tasks (in order) + +### Task 1 — Add `readOnly` mode to DeprecationBanner (~½ day) + +Extend the banner component (from Worktree A): + +```ts +export interface DeprecationBannerProps { + // ... existing fields from Phase 1 + readOnly?: boolean; // NEW +} +``` + +When `readOnly: true`, the banner copy upgrades from "moves to X in v10" to +"**read-only — export now**" and the "Export" button gets primary button +styling. Visual: same yellow background, but more urgent affordance. + +Update CSS in `src/styles/setting.scss` for the new state. + +### Task 2 — Disable inputs in 14 deprecated tabs (~2 days) + +For each tab/section, walk the `Setting()` chains and call `.setDisabled(true)` +on each input when the tab is in read-only mode. The `Export` button stays +enabled. Add a `disabled` prop or a flag at the top of each render function. + +Pattern: + +```ts +// Before +new Setting(containerEl) + .setName("Enable workflows") + .addToggle(t => t.setValue(plugin.settings.workflow.enableWorkflow) + .onChange(async v => { ... })); + +// After (Phase 2) +const readOnly = true; // hardcoded — Phase 3 deletes the tab entirely +new Setting(containerEl) + .setName("Enable workflows") + .addToggle(t => t.setValue(plugin.settings.workflow.enableWorkflow) + .setDisabled(readOnly) + .onChange(async v => { if (readOnly) return; ... })); +``` + +Pass the `renderDeprecationBanner` call `{readOnly: true, ...}`. + +For sectioned tabs (Ics, Index, DesktopIntegration), only the deprecated +sections become read-only — the rest of the tab stays interactive. + +### Task 3 — Wrap deprecated commands with first-use Notice (~1 day) + +`src/index.ts` — for each command that targets a deprecated feature, wrap the +existing callback with a one-shot Notice: + +```ts +// New helper at the top of registerCommands or in a small util +const _deprecationWarned: Record = {}; +function warnDeprecatedOnce(commandId: string, message: string): void { + if (_deprecationWarned[commandId]) return; + _deprecationWarned[commandId] = true; + new Notice(message, 6000); +} + +// Wrap each deprecated command's callback +this.addCommand({ + id: "create-quick-workflow", + name: t("Create Quick Workflow"), + editorCallback: async (editor, ctx) => { + warnDeprecatedOnce( + "create-quick-workflow", + t("This command is removed in v10. Install task-genius-workflow."), + ); + // existing logic still runs + return createQuickWorkflowCommand(plugin, editor, ctx); + }, +}); +``` + +The 11 deprecated commands to wrap: +- 6 workflow commands from `src/commands/workflowCommands.ts` +- 5 task-timer commands (find via `grep -l "task-timer-" src/index.ts`) +- 1 reindex-habits command + +Memory is per-session (cleared on plugin reload). Don't store in settings. + +### Task 4 — Update Notice strings in deprecation-messages.ts (~½ day) + +Add new keys for each command-level Notice. Centralize so locales translate +once. + +### Task 5 — Sub-plugin v0.1.0 release coordination (out of repo, ~1 day) + +This is the part that touches OTHER repos: + +- `task-genius-workflow` v0.1.0 — release with a **one-time bootstrap importer** + that reads from main plugin's live `data.json` (not yet from `Task Genius + Archive/`, since 9.15 hasn't archived anything yet) +- `task-genius-habits` v0.1.0 — same pattern +- `task-genius-timer` v0.1.0 — same pattern +- **`task-genius-calendar-sync` is HELD** — riskiest one (OAuth tokens), + ships in Phase 4 (Worktree E) + +Each sub-plugin's bootstrap importer logs which fields it imported and writes +a marker `/.imported-from-main-plugin` so it doesn't +double-import on next load. + +The sub-plugin repos don't exist yet — Worktree C creates them. Use the +main plugin's tooling (esbuild config, jest config, manifest format) as a +template. + +### Task 6 — Cut 10.0.0-beta.1 (~½ day) + +`manifest-beta.json` — bump to `10.0.0-beta.1`. Worktree D will own the +actual 10.0.0 work; this commit just opens the beta channel so testers can +opt in early. + +`src/managers/changelog-manager.ts` — add a beta-only announcement: "v10 +beta is here. Migration modal will appear on first launch — please test on +a backup vault first." + +## Definition of Done + +| # | Criterion | How to verify | +|---|---|---| +| 1 | All 14 deprecated tabs/sections show inputs as disabled | Manual: open Settings, try to toggle anything in Workflows tab — should be grayed out | +| 2 | Export buttons in each tab still functional | Manual: click each export button, verify archive folder updates | +| 3 | Banner copy updated to "read-only — export now" | Manual visual check | +| 4 | Each deprecated command fires a Notice on first use per session | Manual: invoke `Create Quick Workflow` twice — Notice once, second time silent | +| 5 | Notice memory clears on plugin reload | Manual: reload plugin, invoke command again — Notice fires again | +| 6 | 3 sub-plugins (workflow, habits, timer) released to community plugins or BRAT | Out of repo — verify by installing fresh and confirming bootstrap importer runs | +| 7 | 10.0.0-beta.1 published via BRAT | `manifest-beta.json` shows `10.0.0-beta.1`, BRAT installs it, real beta tester confirms it loads | +| 8 | TypeScript build clean | `npm run build` | +| 9 | Phase 0 integration tests still pass | `npm test -- --testPathPattern="integration/"` | +| 10 | No regression in non-deprecated tabs | Manual: open General/Tasks/Views, edit a setting, verify it persists | + +## Conflicts to watch + +- **Worktree D (Phase 3)** will DELETE the 14 tabs you're modifying. Same as + the A→D conflict — Phase 3 starts after Phase 2 merges, no overlap. +- **Worktree B (Archiver)** — your Export buttons call Worktree B's functions. + If B hasn't merged, the buttons remain stubbed (Worktree A's stubs from + Phase 1). Don't block on B; it'll be ready by the time you finish. + +## Open questions that affect THIS worktree + +- **Q4 — Cliff version (4 weeks vs 8 weeks):** the default is 4 weeks. If the + user wants 8, your worktree gets a longer beta soak (10.0.0-beta.1 stays in + the field for ~2 weeks instead of ~5 days). The work itself doesn't change. +- **Q5 — Beta tester recruitment:** Worktree A pinned the GitHub Discussion. + By the time you start, recruitment should have produced ~5-10 testers. If + it hasn't, push the discussion harder before cutting beta. + +## Useful existing utilities + +- The Obsidian `Setting` class' `.setDisabled(true)` method — works on every + input type (toggle, text, dropdown, slider, button) +- `Notice` from obsidian — your one-shot Notice helper wraps this +- `manifest-beta.json` — already exists, just bump the version field + +## Don't do these things + +- **Don't delete any tabs.** Worktree D owns deletions. +- **Don't modify the dataflow layer.** All your changes are UI + command callbacks. +- **Don't make the Notice persistent across sessions.** Per-session is the + contract — users who reload the plugin should see the warning again. +- **Don't ship a 10.0.0 manifest.** Only `manifest-beta.json` gets bumped. + `manifest.json` stays at `9.15.0`. +- **Don't translate Notice strings to non-English locales.** Worktree E's + locale prune handles parity. + +## When you're done + +```bash +git push origin refactor/v10-phase2-readonly +gh pr create --base master --title "feat(settings): read-only deprecated tabs + first-use command notice (Phase 2, Worktree C)" +``` + +After merge, **Worktree D is unblocked**. Tag `9.15.0` and cut +`10.0.0-beta.1` via BRAT. The 5-day beta soak begins. diff --git a/.v10-worktrees/worktree-d-cliff.md b/.v10-worktrees/worktree-d-cliff.md new file mode 100644 index 00000000..0b8babe9 --- /dev/null +++ b/.v10-worktrees/worktree-d-cliff.md @@ -0,0 +1,459 @@ +# Worktree D — Phase 3 Cliff (10.0.0) + +## What you're doing + +This is **the cliff**. Everything Phase 1 + Phase 2 warned about, you actually +do. Settings tabs go from 25 to 7. Commands go from 43 to 16 with default +hotkeys. Quick-capture modals go from 5 to 1. Views go from 15 to 5 core + 3 +widget-only. Onboarding goes from 36 files to 5. Roughly 50% of the plugin's +LOC gets deleted. The migration confirmation modal runs on first launch and +archives every deprecated feature's persistent data to `/Task Genius +Archive/`. + +This is also the largest worktree by far. It's split into **5 sub-PRs** that +land sequentially on the worktree branch. You can run them as 5 separate +commits or 5 separate Sub-PRs against the worktree branch — whichever fits +your workflow. + +## Phase 0/1/2 ground + +You depend on **Worktree C merged to master**. Verify before starting: + +```bash +git fetch +git log master --oneline | head -10 +# Expect: "feat(settings): read-only deprecated tabs..." (Worktree C) is the most recent v10 commit +# Expect: Worktree A (banners) and Worktree B (Archiver) commits are also present +``` + +The Phase 0 deferred items from `PHASE0_DEFERRED.md` are picked up here: +- **Item 1 (delete `src/pages/TaskView.ts`)** → assigned to **D.4** +- **Item 2 (fold `src/utils/settings-migration.ts` into the registry)** → assigned to **D.2** + +Read [`../PHASE0_DEFERRED.md`](../PHASE0_DEFERRED.md) before starting either +sub-PR for the resolution paths. + +## Setup + +```bash +git fetch +git worktree add ../tg-phase3-cliff master +cd ../tg-phase3-cliff +git checkout -b refactor/v10-phase3-cliff + +npm install +npm test -- --testPathPattern="integration/" +# Expect: 70+ passing +``` + +## The 5 sub-PRs + +Each sub-PR lands on `refactor/v10-phase3-cliff`. They MUST land in this +order due to `src/index.ts` conflicts: + +``` +D.2 (commands) → D.1 (settings tabs) → D.3 (quick capture) → D.4 (views + deletions) → D.5 (confirmation modal) +``` + +D.2 must be first because it owns the bulk of the `src/index.ts` rewrite. +D.5 must be last because it depends on every other sub-PR being stable. + +--- + +### D.2 — Command palette + hotkeys (43 → 16) + +**Why first:** owns the largest stretch of `src/index.ts`. Other sub-PRs +rebase onto it. + +**Files:** +- `src/index.ts` (command registration block — major rewrite) +- `src/commands/*.ts` (delete: `completedTaskMover.ts`, `sortTaskCommands.ts`, `taskCycleCommands.ts`, `taskMover.ts`, `workflowCommands.ts`) +- `src/commands/v10/` (new directory) — 16 new consolidated command files + +**Plus PHASE 0 DEFERRED Item 2** — fold `src/utils/settings-migration.ts` +into the registry. Resolution path: +1. Move `repairStatusCycles`, `validateStatusCycle`, `sortCyclesByPriority`, `findDuplicateCycleIds` → `src/utils/status-cycle-resolver.ts` (already exists) +2. Inline `migrateToMultiCycle` body into `src/utils/migration/steps/legacy-bundle-0.ts` (no longer imported) +3. Remove the W1 fallback in `src/index.ts:2003-2010` (the registry has atomic semantics — fallback is dead defense) +4. Delete `src/utils/settings-migration.ts` +5. Verify `npm test` and `npm run build` + +**The 16 new commands** (renaming + consolidation map from main plan §3.4): + +| New ID | Old IDs absorbed | Default hotkey | +|---|---|---| +| `tg:capture` | `quick-capture`, `minimal-quick-capture`, `toggle-quick-capture`, `toggle-quick-capture-globally`, `quick-file-create` | `Mod+Shift+T` | +| `tg:capture-here` | (new) | — | +| `tg:mark-done` | `cycle-task-status-forward` (when on task line) | `Mod+Enter` (task line) | +| `tg:mark-cycle` | `cycle-task-status-forward`, `cycle-task-status-backward` | — | +| `tg:set-priority` | all 12 priority commands collapsed into one picker | `Mod+Alt+P` | +| `tg:remove-priority` | `remove-priority` | — | +| `tg:open-view` | `open-task-genius-view` | `Mod+Shift+G` | +| `tg:open-inbox` | (new) | `Mod+Shift+I` | +| `tg:open-forecast` | (new) | `Mod+Shift+F` | +| `tg:open-review` | (new) | — | +| `tg:move-tasks` | all 6 task-mover commands → one picker for scope | — | +| `tg:sort-tasks` | `sort-tasks-by-due-date`, `sort-tasks-in-entire-document` | — | +| `tg:reindex` | `force-reindex-tasks` (and removes `reindex-habits`) | — | +| `tg:open-settings` | `open-task-genius-settings-modal` | — | +| `tg:open-archive` | (new — reveals `Task Genius Archive/` in file explorer) | — | +| `tg:setup` | `open-task-genius-setup` | — | + +**Removed entirely:** `open-timeline-sidebar-view`, `open-task-genius-changelog` +(auto-opens on version change), 5× `task-timer-*`, 6× `workflow-*`. The 11 +auto-move commands collapse into a setting toggle in the Tasks tab — **not in +the palette at all**. + +**Backward compatibility:** keep old command IDs as aliases for **one version +only** (10.0.0). Drop in 10.0.1 via Worktree E. This preserves manually-bound +hotkeys for surviving commands during the upgrade. + +**Discovery:** after the migration modal (D.5), show a one-screen "What +changed" sheet listing the 5 default hotkeys, the 4 picker collapses, and +the sub-plugin pointers. Use the existing `ChangelogManager` modal infrastructure. + +**DoD subset:** `npm test`, `npm run build`, all 16 new commands appear in +the palette with the new names, manually-bound hotkeys for surviving commands +still work via aliases. + +--- + +### D.1 — Settings tab rewrite (25 → 7) + +**Files:** +- `src/components/features/settings/SettingsModal.ts` (`renderTabContent` switch — 25 cases → 7) +- `src/components/features/settings/tabs/*` (delete 14, rewrite 7) +- `src/components/features/settings/index.ts` (exports) +- `src/components/features/settings/components/DeprecationBanner.ts` (delete — banners are gone in v10) +- `src/common/deprecation-messages.ts` (delete) +- `src/utils/ObsidianUriHandler.ts:117` (switch to redirect map) +- `src/utils/uri-tab-redirects.ts` (new) +- `src/migration/v10/TabIdMap.ts` (new) +- All `src/components/features/settings/tabs/{Workflow,Habit,Reward,TaskTimer,TimelineSidebar}*.ts` (delete) + +**The 7 new tabs:** + +| # | New tab | Absorbs old tabs | +|---|---|---| +| 1 | **General** | `index`, `file-filter`, `interface` (parts) | +| 2 | **Capture** | `quick-capture`, `time-parsing`, `date-priority` (parts) | +| 3 | **Tasks** | `progress-bar`, `task-status`, `task-handler`, `date-priority` (parts) | +| 4 | **Views** | `view-settings`, `calendar-views`, `task-filter` | +| 5 | **Projects & Tags** | `project` | +| 6 | **Integrations** | `ics` (read-only only), `mcp-integration`, `bases-support`, `workspaces`, `desktop-integration` | +| 7 | **About** | `about`, `beta-test` + "Open archive folder", import/export, tombstones list | + +**Deleted tabs (no merge):** `workflow`, `habit`, `reward`, `task-timer`, +`timeline-sidebar`. Their content was archived in D.5. + +**URI redirect map:** `src/utils/uri-tab-redirects.ts` — see main plan §3.8 +for the full table. Existing URI bookmarks pointing to deprecated tabs land +on `about#archived-X` with a Notice explaining where the data went. + +**DoD subset:** Settings opens in ≤500ms. All 7 tabs render valid content on +an empty vault. Existing URI bookmarks still work (manual smoke test). + +--- + +### D.3 — Quick capture modal consolidation (5 → 1) + +**Files:** +- `src/components/features/quick-capture/modals/{QuickCaptureModal,MinimalQuickCaptureModal,MinimalQuickCaptureModalWithSwitch,BaseQuickCaptureModal}.ts` (delete) +- `src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts` → rename to `QuickCaptureModal.ts` +- New `Mode` strip UI inside the survivor (Full / Minimal / Daily) +- `src/utils/migration/steps/v10-quick-capture-modes.ts` (new — registers as a tombstone+transform) +- `src/index.ts` (command callback updates — `tg:capture` opens the unified modal) + +**Three modes inside one modal:** + +``` +┌─────────────────────────────────────┐ +│ [● Full] [Minimal] [Daily] [×] │ ← mode strip +├─────────────────────────────────────┤ +│ ▌ Task content... │ ← always: text input, autofocus +│ │ +│ ─── Below this line shown in Full ──│ +│ Project ▾ Tags ▾ Priority ▾ │ +│ Due ▾ Start ▾ Scheduled ▾ │ +│ Target file: │ +│ Append / Prepend / Replace ▾ │ +│ [Cancel] [Capture ↵] │ +└─────────────────────────────────────┘ +``` + +**Settings migration step `v10-quick-capture-modes`:** +```ts +// Old: separate command bound to MinimalQuickCaptureModalWithSwitch +// Old: lastUsedMode tracked per modal class +plugin.settings.quickCapture.mode = mapMode(plugin.settings.quickCapture.lastUsedMode); +delete plugin.settings.quickCapture.lastUsedMode; +delete plugin.settings.quickCapture.minimalModeSettings; // fields merged up one level +``` + +Known degradation: users who configured different file targets for full vs +minimal lose that distinction — call out in changelog. + +**DoD subset:** capture flow works in all 3 modes, settings migration handles +existing `lastUsedMode` correctly, default hotkey (`Mod+Shift+T`) opens the +modal. + +--- + +### D.4 — View consolidation + onboarding compression + deletions + +**This is the biggest sub-PR by LOC.** It also picks up Phase 0 deferred +Item 1 (delete `src/pages/TaskView.ts`). + +**Files (deletion sweep):** +- `src/components/features/{gantt,quadrant,habit}/*` (delete entire dirs — except keep a small `QuadrantWidgetView.ts` for codeblock embedding) +- `src/components/features/timeline-sidebar/*` (delete) +- `src/components/features/onboarding/**` (delete 31 of 36 files, add 4 simplified) +- `src/managers/{habit-manager,reward-manager,timer-manager,electron-quick-capture}.ts` (delete) +- `src/services/{timer-export-service,timer-format-service,timer-metadata-service}.ts` (delete) +- `src/managers/calendar-auth-manager.ts` (delete — moves to calendar-sync sub-plugin) +- `src/providers/*` (delete entire dir → calendar-sync sub-plugin) +- `src/parsers/holiday-detector.ts` (delete) +- `src/dataflow/sources/FileSource.ts` (delete) +- `src/editor-extensions/workflow/*` (delete entire dir) +- `src/editor-extensions/date-time/task-timer.ts` (delete) +- `src/pages/TaskView.ts` (delete after porting dirty changes — see PHASE0_DEFERRED.md Item 1) + +**Files (additions):** +- `src/widgets/registerWidgets.ts` (add TableWidget, QuadrantWidget) +- `src/widgets/views/TableWidgetView.ts` (new, port from `src/components/features/table/`) +- `src/widgets/views/QuadrantWidgetView.ts` (new, ~200 LOC — codeblock embedding only) +- `src/migration/v10/ViewMigration.ts` (new) +- `src/utils/migration/steps/v10-view-cleanup.ts` (new — tombstone for removed views) +- `src/common/setting-definition.ts` (`viewConfiguration` defaults: 15 → 5) +- `src/components/features/onboarding/steps/{WelcomeStep,CaptureStep,ViewsStep,DoneStep}.ts` (new — 4 simplified onboarding steps) + +**View consolidation (15 → 5 core + 3 widget-only):** + +Core (live in FluentTaskView sidebar): `inbox`, `forecast`, `projects`, `tags`, `review` + +Widget-only (no sidebar entry, codeblock + `tg:open-view` picker only): `calendar`, `kanban`, `table` + +Removed: `gantt`, `quadrant`, `habit`, `working-on`, `flagged` + +**Phase 0 deferred Item 1 (port TaskView.ts changes to FluentTaskView):** + +The user has uncommitted modifications in `src/pages/TaskView.ts` adding +multi-cycle support to the "switch status" context menu. Before deleting +TaskView.ts: + +1. Read the dirty diff (`git diff src/pages/TaskView.ts`). The relevant block + is the "switch status" submenu builder around line ~1263-1440. +2. Find the equivalent location in `src/pages/FluentTaskView.ts` and port the + multi-cycle logic. The new code reuses these helpers from + `src/utils/status-cycle-resolver.ts`: + - `findApplicableCycles(currentMark, statusCycles)` + - `getAllStatusNames` + - `getNextStatusPrimary` + - `getAllStatusMarks` +3. Move `TASK_VIEW_TYPE` constant from `src/pages/TaskView.ts` to a new file + `src/common/view-types.ts` (stale leaves in user vaults still need to be + detached on next load — see `src/index.ts:1825` and `:2116`). +4. Update import sites of `TASK_VIEW_TYPE`: + - `src/index.ts:72` (also remove the `TaskView` symbol from this import) + - `src/components/features/fluent/FluentIntegration.ts:13` +5. Delete the `instanceof TaskView` block at `src/index.ts:2119` — unreachable + since the class is no longer registered. +6. Delete `src/pages/TaskView.ts`. +7. Run typecheck + integration suite. + +**Migration step `v10-view-cleanup`** (`src/migration/v10/ViewMigration.ts`): +```ts +const REMOVED = ["gantt", "quadrant", "habit", "working-on", "flagged"]; +const DEMOTED = ["calendar", "kanban", "table"]; + +// 1. Snapshot removed view configs into Archive/views/removed-views.json (Worktree B's archiver) +// 2. For removed views with non-default filterRules, create a saved filter preset +// on the closest core view (working-on → inbox preset, flagged → forecast preset) +// 3. Strip removed entries from plugin.settings.viewConfiguration +// 4. For demoted views, set type:"widget" and remove from FluentTaskView sidebar list +// 5. Walk plugin.settings.workspaces.byId[*].settings.fluentActiveViewId; replace any removed-view ID with "inbox" +// 6. Walk plugin.settings.workspaces.byId[*].settings.hiddenModules and prune removed IDs +``` + +**Steps 5 + 6 are the WORKSPACE_ONLY_KEYS trap** — workspaces in +`plugin.settings.workspaces.byId[*]` must be cleaned in the same migration +step or workspaces will boot to a broken view. + +**Onboarding compression (36 → 5 files):** +``` +src/components/features/onboarding/ +├── OnboardingView.ts ← view shell (kept, slimmed) +├── OnboardingController.ts ← state machine (kept, simplified to 4 steps) +├── steps/ +│ ├── WelcomeStep.ts ← 1. Welcome + import-from-archive prompt +│ ├── CaptureStep.ts ← 2. Pick target file, hotkey hint, sample capture +│ ├── ViewsStep.ts ← 3. Pick which of 5 core views to show +│ └── DoneStep.ts ← 4. "You're ready" + docs link +└── ui/Layout.ts ← shared step layout (kept, simplified) +``` +Delete every `Fluent*Step.ts`, `UserLevelStep`, `ModeSelectionStep`, +`ConfigPreviewStep`, `SettingsCheckStep`, `PlacementStep`, `IntroStep`, +`TaskGuideStep`, all of `steps/intro/`, `steps/guide/`, `steps/preview/`, +`previews/`, `TaskCreationGuide.ts`. **No more Beginner/Advanced/Power +branching** — one linear flow. + +**DoD subset:** all 5 core views render with an empty vault, all workspaces +boot to a valid `fluentActiveViewId`, onboarding completes in ≤4 steps, +LOC reduction visible (`find src/ -name "*.ts" | xargs wc -l` should be +notably lower). + +--- + +### D.5 — Migration confirmation modal + +**Files:** +- `src/migration/v10/ConfirmationModal.ts` (new) +- `src/utils/migration/steps/v10-archive.ts` (new — wraps Worktree B's `archiveAll`) +- `src/index.ts` (first-launch detection in onload) + +**The migration confirmation modal:** + +On first launch of 10.0.0, BEFORE any view renders, show a blocking modal: + +``` +┌────────────────────────────────────────────┐ +│ Task Genius is upgrading to v10. │ +│ │ +│ The following will be archived: │ +│ • 47 workflows → Task Genius Archive/ │ +│ workflows/ │ +│ • 12 habits → habits/ │ +│ • 3 ICS sources → calendar-sync/ │ +│ • 5 custom views → views/ │ +│ │ +│ Archive folder: [Task Genius Archive ] │ +│ │ +│ [Cancel & rollback to v9] [Preview] │ +│ [Apply ▶] │ +└────────────────────────────────────────────┘ +``` + +Driven by `MigrationRegistry.run(settings, {dryRun: true})` for the Preview +button, then `dryRun: false` on Apply. Cancel exits Obsidian without +persisting (leaves `data.json` untouched, instructions on how to revert). + +**Wire `archiveAll` (from Worktree B) into the registry as `v10-archive`:** + +```ts +// src/utils/migration/steps/v10-archive.ts +import { archiveAll } from "@/migration/v10/Archiver"; +import type { MigrationStep } from "../MigrationRegistry"; + +export const v10ArchiveStep: MigrationStep = { + id: "v10.0.0-archive", + targetVersion: "10.0.0", + kind: "transform", + description: "Archive deprecated feature data to /Task Genius Archive/", + apply: async (settings, ctx) => { + // The migration is two-part: + // 1. Run archiveAll() to compute the archive contents + // 2. Have the modal write them to the vault (since the registry is pure) + // The "writing" is done by the ConfirmationModal in apply phase, NOT here. + // This step's job is just to mark that the archive happened. + return { changed: true, details: ["v10 archive scheduled"] }; + }, +}; +``` + +**Beta vs stable behavior:** in `10.0.0-beta.X`, the modal defaults to +"preview only, do not apply" — user must explicitly click Apply. In stable +`10.0.0`, Apply is the primary button. + +**DoD subset:** the §3.10 acceptance test from the main plan — the "single +hardest test": + +> A user upgrades from 9.13.1 with: 47 workflows, 12 habits, 3 ICS sources, +> 2 OAuth calendars, 5 custom views, 3 workspaces, all 25 settings tabs touched +> at least once. After upgrading to 10.0.0, they: +> 1. See the migration modal within 3 seconds. +> 2. Click Apply. +> 3. See their Inbox. +> 4. Capture a task with `Mod+Shift+T`. +> 5. Mark it done. +> 6. Open `Task Genius Archive/` and see all their data. +> +> If any of those six steps requires reading docs, opening Settings, or +> restarting Obsidian — **it's not done.** + +--- + +## Definition of Done (worktree-level) + +| # | Criterion | How to verify | +|---|---|---| +| 1 | The §3.10 acceptance test from the main plan | Manual smoke on a real vault | +| 2 | First-time user enables plugin → first captured task in ≤60 seconds, ≤5 clicks | Manual on a fresh vault | +| 3 | Onboarding completes in ≤4 steps, ≤90 seconds | Manual | +| 4 | Settings opens in ≤500ms | Performance tab in dev tools | +| 5 | All workspaces boot to a valid `fluentActiveViewId` (no removed view IDs) | `git grep -l "removed view" src/__tests__` | +| 6 | All custom hotkeys for surviving commands still work | Manual | +| 7 | URI bookmarks pointing to deprecated tabs land somewhere reasonable with a Notice | Manual: navigate to `obsidian://task-genius?action=settings&tab=workflow` | +| 8 | No console errors during migration | `src/__tests__/migration/v10-migration.test.ts` | +| 9 | Main plugin LOC ≤ 42k (target 40k, ceiling 42k) | `find src/ -name "*.ts" -not -path "*__tests__*" -not -path "*__mocks__*" \| xargs wc -l \| tail -1` | +| 10 | 0 references to deleted modules | `git grep "from.*workflow"`, `git grep "from.*habit-manager"`, etc. — all should be empty in main plugin | +| 11 | All 5 core views render with an empty vault | Manual | +| 12 | All 16 commands appear in palette with new names | Manual | +| 13 | Phase 0 integration tests still pass | `npm test -- --testPathPattern="integration/"` | +| 14 | TypeScript build clean | `npm run build` | + +## Conflicts within Worktree D + +D.1-D.4 each touch `src/index.ts` differently: +- D.1 changes settings tab routing +- D.2 rewrites commands +- D.3 changes quick-capture commands +- D.4 deletes timer/workflow commands + +**Sequence them: D.2 first (it owns the command block), then D.1, D.3, D.4 +in any order, then D.5 last.** + +## Open questions that affect THIS worktree + +- **Q1 — Deprecation list:** if the user pulls anything back, the corresponding + D.4 deletion gets skipped. Confirm before D.4 starts. +- **Q3 — Default hotkeys:** the 5 defaults (`Mod+Shift+T`, etc.) are baked + into D.2. If the user wants different bindings or no defaults, change at + the top of D.2. +- **Q4 — Cliff version:** 4-week vs 8-week runway only affects WHEN you + start (after C merges, plus the beta soak period). Doesn't change scope. + +## Useful existing utilities + +- `src/managers/changelog-manager.ts` — for the "What changed" first-run sheet +- `src/utils/migration/MigrationRegistry.ts` (Phase 0) — already supports + atomic + dry-run, just add new steps +- `src/dataflow/cache/scope-map.ts` (Phase 0) — typed scope map for the + settings consolidation; D.1 should migrate the settings tab callers to + `onSettingsFieldsChanged()` as it rewrites them +- `src/widgets/core/BaseWidgetView.ts` — base for new TableWidgetView and + QuadrantWidgetView +- `src/widgets/codeblock/WidgetCodeBlockProcessor.ts` — codeblock embedding, + no changes needed + +## Don't do these things + +- **Don't ship without the migration confirmation modal.** D.5 is mandatory. + The whole point of Phase 0/1/2 was to set up the safety net for D.5 — it's + the user's only protection against data loss. +- **Don't keep `legacy` flags.** v10 is the cliff. No `enableV9Compatibility` + toggles. +- **Don't translate strings to non-English locales.** Worktree E's locale + prune handles parity. +- **Don't release `task-genius-calendar-sync` v0.1.0** — that's Worktree E. + Calendar sync is the riskiest sub-plugin (OAuth tokens) and gets held until + 10.0.1 for an extra week of soak. + +## When you're done + +```bash +git push origin refactor/v10-phase3-cliff +gh pr create --base master --title "feat: v10 cliff - 16 commands, 7 settings tabs, 5 core views (Phase 3, Worktree D)" +``` + +After merge, **Worktree E is unblocked**. Tag `10.0.0`. The cleanup phase +begins. diff --git a/.v10-worktrees/worktree-e-cleanup.md b/.v10-worktrees/worktree-e-cleanup.md new file mode 100644 index 00000000..a0a72ba0 --- /dev/null +++ b/.v10-worktrees/worktree-e-cleanup.md @@ -0,0 +1,308 @@ +# Worktree E — Phase 4 Cleanup (10.0.1) + +## What you're doing + +The cliff is behind us — 10.0.0 shipped with the migration confirmation modal, +the new 7-tab settings UI, the 16-command palette, the 5 core views, and +~50% of the LOC deleted. Worktree E is the **cleanup pass** that: + +- Removes the migration code itself (its job is done) +- Drops the one-version command-ID aliases that Worktree D shipped for + backward compatibility +- Prunes orphan locale keys +- Releases `task-genius-calendar-sync` v0.1.0 (the riskiest sub-plugin, held + back from Phase 2 for an extra week of soak) + +This is the smallest worktree by LOC. It's mostly bookkeeping with one +sub-plugin release coordination. + +## Phase 0/1/2/3 ground + +You depend on **Worktree D merged to master**. Verify before starting: + +```bash +git fetch +git log master --oneline | head -5 +# Expect: "feat: v10 cliff..." (Worktree D) is the most recent commit +git tag | grep "10.0.0$" +# Expect: 10.0.0 tag exists +``` + +Beta soak should have run for ~5 days minimum after `10.0.0-beta.1` was cut +in Phase 2. If real users on the beta channel are reporting bugs, FIX THOSE +FIRST before starting Worktree E — your work is post-stability cleanup, not +emergency response. + +## Setup + +```bash +git fetch +git worktree add ../tg-phase4-cleanup master +cd ../tg-phase4-cleanup +git checkout -b refactor/v10-phase4-cleanup + +npm install +npm test -- --testPathPattern="integration/" +# Expect: 70+ passing +``` + +## Files you'll modify + +- `src/migration/v10/Archiver.ts` (delete — its job is done, archive folder lives in user vaults forever) +- `src/migration/v10/index.ts` (update exports) +- `src/utils/migration/steps/v10-archive.ts` → tombstone-only marker +- `src/utils/migration/steps/v10-view-cleanup.ts` → tombstone marker +- `src/index.ts` (drop one-version command aliases that D.2 shipped) +- `src/__tests__/migration/v10/` (delete the Archiver tests since the source is gone) + +## Files you'll create + +- `scripts/prune-locale-orphans.mjs` (new — one-shot script) +- `src/__tests__/locale-parity.test.ts` (new — CI guard against regressions) + +## Tasks (in order) + +### Task 1 — Tombstone the migration code (~½ day) + +The `v10-archive` and `v10-view-cleanup` migration steps did their job in +10.0.0. They should now become **no-op tombstones** that just record "already +applied" via `_meta.lastMigratedVersion`. The actual archive logic in +`src/migration/v10/Archiver.ts` is no longer needed. + +For each of the two steps: + +```ts +// Before (10.0.0) +export const v10ArchiveStep: MigrationStep = { + id: "v10.0.0-archive", + targetVersion: "10.0.0", + kind: "transform", + description: "...", + apply: async (settings, ctx) => { /* real work */ }, +}; + +// After (10.0.1) +export const v10ArchiveStep: MigrationStep = { + id: "v10.0.0-archive", + targetVersion: "10.0.0", + kind: "tombstone", // changed from "transform" + description: "Archive migration applied in 10.0.0 (now no-op)", + apply: () => ({ changed: false, details: ["already applied"] }), +}; +``` + +The reason to keep the step at all (instead of deleting it) is that users +upgrading from 9.x DIRECTLY to 10.0.1 (skipping 10.0.0) need the registry to +recognize the version slot. With a tombstone marker, the registry sees +`fromVersion < 10.0.0 < toVersion = 10.0.1` and runs the step — which now +no-ops. Without the marker, fresh-install users would also be fine, but +upgrading users wouldn't have a migration record at the right version. + +Then delete `src/migration/v10/Archiver.ts` and its tests: + +```bash +rm src/migration/v10/Archiver.ts +rm -rf src/__tests__/migration/v10/ +``` + +Update `src/migration/v10/index.ts` to remove the Archiver exports. + +### Task 2 — Drop one-version command aliases (~½ day) + +Worktree D.2 kept old command IDs as aliases for one version (10.0.0) to +preserve manually-bound hotkeys. Now drop them: + +```bash +git grep -n "addCommand.*id:.*['\"]old-command-id['\"]" src/index.ts +# Find the alias registrations and delete them +``` + +The aliases were: +- `quick-capture` → aliased to `tg:capture` +- `cycle-task-status-forward` → aliased to `tg:mark-cycle` +- ... etc., one alias per surviving command + +These are documented in the D.2 sub-PR commit history. Read that commit to +see the full alias list. + +Add a CHANGELOG entry: "Removed v9 command aliases. If you bound hotkeys to +old command IDs, rebind them to the v10 equivalents (see CHANGELOG 10.0.0)." + +### Task 3 — Locale prune script (~½ day) + +`scripts/prune-locale-orphans.mjs`: + +```js +#!/usr/bin/env node +/** + * Prune locale orphans: delete keys in non-en locale files that don't + * exist in en.ts. Run manually before tagging 10.0.1. + */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const localeDir = path.join(__dirname, "..", "src", "translations", "locale"); + +const en = await import(path.join(localeDir, "en.ts")); +const enKeys = new Set(Object.keys(en.default)); + +const files = await fs.readdir(localeDir); +for (const file of files) { + if (file === "en.ts" || !file.endsWith(".ts")) continue; + // ... parse, prune, write back +} + +console.log(`Pruned orphan keys from ${files.length - 1} non-en locales`); +``` + +Run it: + +```bash +node scripts/prune-locale-orphans.mjs +``` + +This is a ONE-SHOT script. Don't add it to npm scripts or CI. The author +runs it manually when locale orphans accumulate. + +### Task 4 — Locale parity Jest test (~½ day) + +`src/__tests__/locale-parity.test.ts` — a tiny ratchet test that asserts +every key in `en.ts` exists in every other locale file. New strings added +to `en.ts` are allowed to be missing in other locales (they fall back to +en) — this only fails if a locale file has EXTRA keys that don't exist in +en. + +Wait, that's wrong direction. Re-think: + +- `en.ts` is the canonical superset +- Other locales should be SUBSETS of `en.ts` (missing translations fall back to en) +- If a non-en locale has a key that en doesn't, that's an orphan — assert it doesn't happen + +```ts +import en from "@/translations/locale/en"; +import zhCn from "@/translations/locale/zh-cn"; +import zhTw from "@/translations/locale/zh-tw"; +import ja from "@/translations/locale/ja"; +import ru from "@/translations/locale/ru"; +import uk from "@/translations/locale/uk"; +import ptBr from "@/translations/locale/pt-br"; +import enGb from "@/translations/locale/en-gb"; + +const enKeys = new Set(Object.keys(en)); + +describe("locale parity (W4 cleanup)", () => { + test.each([ + ["zh-cn", zhCn], + ["zh-tw", zhTw], + ["ja", ja], + ["ru", ru], + ["uk", uk], + ["pt-br", ptBr], + ["en-gb", enGb], + ])("%s has no orphan keys not in en.ts", (name, locale) => { + const orphans = Object.keys(locale).filter((k) => !enKeys.has(k)); + expect(orphans).toEqual([]); + }); +}); +``` + +This is the **automated** complement to the manual prune script. The script +runs occasionally; the test runs every PR. + +### Task 5 — Release `task-genius-calendar-sync` v0.1.0 (~2 days, mostly out of repo) + +This is the riskiest sub-plugin — OAuth tokens, token refresh, CalDAV write +operations. It was held back from Phase 2 for an extra week of soak after +v10.0.0 ships. + +Tasks: +1. Create the new repo `task-genius-calendar-sync` (copy main plugin's tooling) +2. Migrate `src/providers/*`, `src/managers/calendar-auth-manager.ts` from the + main plugin (which deleted them in Worktree D.4) +3. Add a one-time importer that reads from `/Task Genius Archive/calendar-sync/caldav-sources.json` +4. Test with a real Google Calendar account (out of repo) +5. Release v0.1.0 to community plugins (or BRAT) +6. Pin a "calendar sync available" announcement to the GitHub Discussion + +This task is largely external to the main plugin repo. The main-plugin side +is just verifying nothing reads from the deleted provider files (`git grep`). + +### Task 6 — Bump version + tag (~10 minutes) + +```bash +# In the main plugin repo +# Bump manifest.json from 10.0.0 to 10.0.1 +# Bump versions.json (the Obsidian plugin version map) +# Commit +git tag v10.0.1 +git push origin v10.0.1 +``` + +## Definition of Done + +| # | Criterion | How to verify | +|---|---|---| +| 1 | `src/migration/v10/Archiver.ts` deleted | File doesn't exist; `git grep "Archiver"` returns no production matches | +| 2 | Both v10 migration steps are tombstones (no-op) | Code review | +| 3 | One-version command aliases removed from `src/index.ts` | `git grep "alias.*10.0.0"` returns nothing | +| 4 | Locale prune script exists and runs without error | `node scripts/prune-locale-orphans.mjs` exits 0 | +| 5 | Locale parity Jest test exists and passes | `npm test -- --testPathPattern="locale-parity"` | +| 6 | `task-genius-calendar-sync` v0.1.0 published | Out of repo; check community plugins or BRAT | +| 7 | `manifest.json` is `10.0.1` | File contents | +| 8 | TypeScript build clean | `npm run build` | +| 9 | All Phase 0 + Phase 3 integration tests still pass | `npm test -- --testPathPattern="integration/"` | +| 10 | No deleted-module references | `git grep "from.*workflow"`, `git grep "from.*habit-manager"`, `git grep "from.*timer-manager"`, `git grep "from.*calendar-auth"` — all empty | +| 11 | Main plugin LOC ≤ 42k (still hitting the v10 ceiling) | `find src/ -name "*.ts" -not -path "*__tests__*" -not -path "*__mocks__*" \| xargs wc -l \| tail -1` | + +## Conflicts to watch + +**None.** By the time Worktree E starts, Phase 3 is merged and stable. No +other worktrees are in flight. + +## Open questions that affect THIS worktree + +- **Q2 — Sub-plugin commitment:** if the user changed their mind about + `task-genius-calendar-sync` (e.g. wants to delete OAuth/CalDAV outright + instead of extracting), Task 5 gets skipped. Confirm before starting. + +The other 4 questions are settled by the time you reach Phase 4. + +## Useful existing utilities + +- `src/utils/migration/MigrationRegistry.ts` — already supports `kind: "tombstone"` (Phase 0) +- `src/translations/locale/*.ts` — existing locale files; the prune script + walks them +- `src/translations/helper.ts` — `t()` translation helper + +## Don't do these things + +- **Don't delete the migration steps entirely.** They become tombstones, not + deletions. Tombstones preserve the version slot for skip-version upgrades. +- **Don't translate orphan strings before pruning.** The prune script + determines which strings are orphans by comparing to en.ts. Translate AFTER + the prune, not before. +- **Don't add the prune script to CI or npm scripts.** It's a one-shot tool; + the author runs it manually. +- **Don't ship `task-genius-calendar-sync` without testing OAuth refresh on + a real account.** This is the highest-risk sub-plugin; an untested + refresh-token bug = users locked out of their calendars. +- **Don't bump the major version.** 10.0.1 is a patch release; 10.1.0 is + reserved for the first feature release post-cliff. + +## When you're done + +```bash +git push origin refactor/v10-phase4-cleanup +gh pr create --base master --title "chore: v10 cleanup - tombstones, locale prune, calendar-sync release (Phase 4, Worktree E)" +``` + +After merge, tag `v10.0.1` and the v10 refactor effort is COMPLETE. The +plugin is at ~40k LOC, the user has 5 core views + 16 commands + 7 settings +tabs + 1 quick-capture modal + a 4-step onboarding, and 4 sub-plugins are +available for users who want the deprecated features back. + +Update CHANGELOG.md with a final summary entry and consider writing a blog +post / GitHub release note explaining the 6-week journey for users who +weren't in the loop. diff --git a/PHASE0_DEFERRED.md b/PHASE0_DEFERRED.md new file mode 100644 index 00000000..50fa5524 --- /dev/null +++ b/PHASE0_DEFERRED.md @@ -0,0 +1,96 @@ +# Phase 0 Deferred Items + +Phase 0 of the v10 refactor (`refactor/v10-phase0` branch) completed 10 of 12 +DoD items. The remaining 2 are documented here so Phase 3 owners pick them up +in the right sub-PR. + +## Item 1: Delete `src/pages/TaskView.ts` + +**Original DoD criterion:** "src/pages/TaskView.ts deleted; file gone; +`git grep \"from \\\".*pages/TaskView\\\"\" src/` empty; typecheck passes" + +**Why deferred:** the user has uncommitted modifications in `src/pages/TaskView.ts` +(enhanced multi-cycle support in the "switch status" context menu). Deleting +the file in Phase 0 would lose those modifications. + +**Resolution path** (assigned to Phase 3 sub-PR D.4 — view consolidation): + +1. Read the dirty `src/pages/TaskView.ts` diff. The relevant block is the + "switch status" submenu builder (around line ~1263-1440 in the dirty version). +2. Find the equivalent location in `src/pages/FluentTaskView.ts` and port the + multi-cycle context menu logic. The new code reuses these helpers from + `src/utils/status-cycle-resolver.ts`: + - `findApplicableCycles(currentMark, statusCycles)` + - `getAllStatusNames` + - `getNextStatusPrimary` + - `getAllStatusMarks` +3. Move `TASK_VIEW_TYPE` constant from `src/pages/TaskView.ts` to a new file + `src/common/view-types.ts` so the constant survives the deletion (stale + leaves in user vaults still need to be detached on next load — see + `src/index.ts:1825` and `:2116`). +4. Update the two import sites of `TASK_VIEW_TYPE`: + - `src/index.ts:72` (also remove the `TaskView` symbol from this import) + - `src/components/features/fluent/FluentIntegration.ts:13` +5. Delete the `instanceof TaskView` block at `src/index.ts:2119` — unreachable + since the class is no longer registered. +6. Delete `src/pages/TaskView.ts`. +7. Run typecheck + integration suite to confirm green. + +## Item 2: Fold `src/utils/settings-migration.ts` into the registry + +**Original DoD criterion:** "src/utils/settings-migration.ts deleted; folded into +registry; Migration.tombstone.test.ts covers the multi-cycle case" + +**Why deferred:** the file has TWO non-migration runtime callers that need +proper relocation, not just deletion: +- `repairStatusCycles` is called every plugin load at `src/index.ts:2022` +- `migrateSettings` is called from the W1 fallback path at `src/index.ts:2007` + +A naive delete would break both. The Phase 0 W1 work bundled the migration +logic into `legacy-bundle-0.ts` but kept the source file because the runtime +helpers still need a home. + +**Resolution path** (assigned to Phase 3 sub-PR D.2 — command palette rewrite, +since D.2 owns the `index.ts` import block): + +1. Move the runtime helpers to `src/utils/status-cycle-resolver.ts` (already + exists, has the right name): + - `repairStatusCycles` + - `validateStatusCycle` + - `sortCyclesByPriority` + - `findDuplicateCycleIds` +2. Confirm `migrateToMultiCycle` is no longer imported anywhere outside of + `src/utils/migration/steps/legacy-bundle-0.ts`. As of Phase 0 it's also + imported by `src/__tests__/migration/legacy-bundle-0.test.ts`. Either: + (a) move `migrateToMultiCycle` body inline into both call sites and delete + the export, OR + (b) move `migrateToMultiCycle` to a new home like + `src/utils/migration/legacy-helpers.ts` and update the two importers. + Recommendation: (a) — the body is small (~30 LOC) and inlining removes a + moving part. +3. Update `src/index.ts:110-112` to import `repairStatusCycles` from its new + home (`@/utils/status-cycle-resolver`). +4. Remove the W1 fallback in `src/index.ts:2003-2010` — the registry has + atomic semantics, so the fallback path is dead defense in depth that nobody + should ever hit. (If you're worried, leave a single-line warning that the + registry returned !ok.) +5. Delete `src/utils/settings-migration.ts`. +6. Run typecheck + integration suite to confirm green. + +## When to do this work + +Both items belong in the very first sub-PRs of Worktree D (Phase 3, 10.0.0). +They are NOT for Phase 1 or Phase 2 — those phases are about adding warnings +without touching the underlying view layer. + +## Why these were deferred (not just dropped) + +Both items are real DoD criteria from the plan. Skipping them entirely would +mean Phase 0's "no observable behavior change + dead code removed" contract +is incomplete. They're deferred to Phase 3 because: +- Item 1 is blocked on user state we can't safely modify in Phase 0 +- Item 2 needs a proper home for runtime helpers, which is more refactor than + "delete" + +Phase 3's natural workflow already touches both areas, so picking them up +there is cheap. Tracking them here ensures they don't get lost. diff --git a/WORKTREE_PLAN.md b/WORKTREE_PLAN.md new file mode 100644 index 00000000..8a00c6a8 --- /dev/null +++ b/WORKTREE_PLAN.md @@ -0,0 +1,506 @@ +# Task Genius v10 — Phase 1-4 Worktree Split + +> **Update (2026-04-07):** the 5 worktrees described below have each been +> split into a self-contained brief in [`./.v10-worktrees/`](./.v10-worktrees/). +> Hand each brief to one agent / developer. This file remains as the umbrella +> overview; individual briefs have the actionable instructions. +> +> | Worktree | Brief | +> |---|---| +> | A — banners | [`.v10-worktrees/worktree-a-banners.md`](./.v10-worktrees/worktree-a-banners.md) | +> | B — Archiver | [`.v10-worktrees/worktree-b-archiver.md`](./.v10-worktrees/worktree-b-archiver.md) | +> | C — read-only | [`.v10-worktrees/worktree-c-readonly.md`](./.v10-worktrees/worktree-c-readonly.md) | +> | D — cliff cluster | [`.v10-worktrees/worktree-d-cliff.md`](./.v10-worktrees/worktree-d-cliff.md) | +> | E — cleanup | [`.v10-worktrees/worktree-e-cleanup.md`](./.v10-worktrees/worktree-e-cleanup.md) | + +Operational follow-up to the v10 refactor plan +(`~/.claude/plans/dynamic-mixing-pie.md`). That document is the source of +truth for **what** to do in each phase. This document is the source of truth +for **how to parallelize** the work across multiple worktrees / developers. + +**Phase 0 status:** +- 10 of 12 DoD items checked ✅ +- 2 items deferred (see [PHASE0_DEFERRED.md](./PHASE0_DEFERRED.md)) +- 5 commits on `refactor/v10-phase0` +- 71 new passing tests; full suite stable at 39 pre-existing failed suites + +**Sub-plugins:** Phase 1-4 also produces 4 new repos +(`task-genius-workflow`, `task-genius-habits`, `task-genius-timer`, +`task-genius-calendar-sync`). Those are NOT worktrees of this repo — they're +independent repos. This document covers main-plugin worktrees only. + +--- + +## Worktree principles + +1. **One worktree per developer per concurrent task.** A worktree is + `git worktree add ../tg-- refactor/v10--` from + the main repo. +2. **Worktrees branch off `refactor/v10-phase0`** until that branch merges to + master. After merge they branch off master. +3. **Avoid concurrent worktrees touching the same files.** This document maps + each phase's work to a "footprint" — the files it modifies — so collisions + are predictable. +4. **Each worktree owns its own DoD subset.** Don't merge a worktree until its + subset is green. +5. **Sub-plugin extraction is NOT worktree work.** Each extracted sub-plugin + is a fresh repo. This doc only mentions them as dependencies. + +--- + +## Conflict map + +Which phases touch which key files: + +| File | Phase 0 | Phase 1 | Phase 2 | Phase 3 | Phase 4 | +|---|---|---|---|---|---| +| `src/index.ts` | onunload race fixed; W1 wiring | + banner registration | + read-only mode | **rewrite**: 16 commands, hotkeys, deletes 27 commands | drop aliases | +| `src/common/setting-definition.ts` | `_meta` field added | banner copy keys | none | **rewrite**: 25→7, 15→5 views | locale prune | +| `src/components/features/settings/SettingsModal.ts` | none | none | read-only flag | **rewrite**: `renderTabContent` 25→7 | none | +| `src/components/features/settings/tabs/*` | none | banner injection in 14 tabs | read-only mode in 14 tabs | **delete 14 tabs**, rewrite 7 | none | +| `src/components/features/quick-capture/modals/*` | none | none | none | **delete 4 modals**, rename 1 | none | +| `src/components/features/onboarding/*` | none | none | none | **delete 31 files**, add 4 | none | +| `src/utils/ObsidianUriHandler.ts:117` | none | none | none | switch to redirect map | none | +| `src/dataflow/Orchestrator.ts` | rebuild + onSettingsFieldsChanged | none | none | settings consolidation rewires scopes | none | +| `src/widgets/registerWidgets.ts` | none | none | none | + TableWidget, QuadrantWidget | none | +| `src/translations/locale/*` | none | en banner copy | en banner copy | en strings for new UI | **prune all 7 non-en** | +| `src/migration/v10/*` (new) | none | + Archiver scaffold | + ViewMigration scaffold | + tab/view migration steps | tombstones for v10-archive | +| `src/utils/migration/steps/*` | legacy bundle + sentinel | + v9.14 deprecation banners (no-op) | none | + v10 archive + view-cleanup | + v10.0.1 cleanup tombstones | + +**The hot zone is `src/index.ts` and the settings tabs.** Phase 3 owns both +ends of the rewrite. Phase 1 and Phase 2 are mostly additive (banner injection, +read-only flagging) and can run in parallel with Phase 3 if scoped carefully. + +--- + +## Worktree A — Phase 1 banners (9.14.0) + +**Branch:** `refactor/v10-phase1-banners` off `refactor/v10-phase0` +**Owner:** 1 developer +**Calendar:** ~1 week + +### Scope footprint + +- `src/components/features/settings/components/DeprecationBanner.ts` (new) +- `src/common/deprecation-messages.ts` (new) +- `src/components/features/settings/tabs/WorkflowSettingsTab.ts` (banner injection) +- `src/components/features/settings/tabs/HabitSettingsTab.ts` +- `src/components/features/settings/tabs/RewardSettingsTab.ts` +- `src/components/features/settings/tabs/TaskTimerSettingsTab.ts` +- `src/components/features/settings/tabs/TimelineSidebarSettingsTab.ts` +- `src/components/features/settings/tabs/IcsSettingsTab.ts` (OAuth section only) +- `src/components/features/settings/tabs/IndexSettingsTab.ts` (file-as-task section) +- `src/components/features/settings/tabs/DesktopIntegrationSettingsTab.ts` (electron-quick-capture section) +- `src/components/features/settings/tabs/AboutSettingsTab.ts` (deprecations panel) +- `src/components/features/settings/index.ts` (export new component) +- `src/managers/changelog-manager.ts` (auto-open v10 announcement) +- `src/translations/locale/en.ts` (new banner copy) + +### Tasks + +1. Build the `DeprecationBanner` component (~60 LOC) +2. Build the `deprecation-messages.ts` strings file (~80 LOC) +3. Wire banner into 14 deprecated tab files (one-line call each) +4. Add the "Deprecations" collapsible to AboutSettingsTab (~50 LOC) +5. Wire ChangelogManager to auto-open the v10 announcement on first launch of 9.14.0 +6. Update `CHANGELOG.md` and create the pinned GitHub Discussion (out of repo) + +### Conflicts to watch + +Phase 3 will rewrite the same 14 tabs. When Phase 1 merges first, Phase 3 +needs to pull and resolve the banner injection (which gets deleted in Phase 3 +anyway). Plan: Phase 3 starts AFTER Phase 1 is merged so the rebase is clean. + +### DoD subset + +- 14 tabs show banners in real-vault smoke test +- "Export this section" button in each banner calls a stub `Archiver` fn +- Changelog modal auto-opens once on 9.14.0 first launch +- No regression in existing tab rendering + +--- + +## Worktree B — Phase 1 Archiver (parallel to A) + +**Branch:** `refactor/v10-phase1-archiver` off `refactor/v10-phase0` +**Owner:** 1 developer (can be the same person as A, sequenced) +**Calendar:** ~1 week (overlaps with A) + +### Scope footprint + +- `src/migration/v10/Archiver.ts` (new, ~300 LOC) +- `src/migration/v10/index.ts` (new barrel) +- `src/__tests__/migration/v10/Archiver.test.ts` (new) + +### Tasks + +1. Implement `archiveWorkflows`, `archiveHabits`, `archiveRewards`, + `archiveTimerData`, `archiveCalDavSources`, `archiveRemovedViews`, + `archiveOrphans` as **pure functions** over `plugin.settings` +2. Each returns `{files: Map, summary: string}` — strict no-IO + contract +3. Test fixture: realistic settings with each section populated → assert + archive contents are correct +4. **Do NOT wire into MigrationRegistry yet** — that's Phase 3 + +### Why a separate worktree + +Worktree A modifies UI files; Worktree B modifies new files in +`src/migration/v10/`. Zero conflict. Both can land in 9.14.0 even though only +A is user-visible. + +### DoD subset + +- All 7 archive functions return correct shapes for known inputs +- `Archiver.test.ts` has 1 fixture per archive section +- Archiver functions work in jest WITHOUT a real vault (pure transformations) + +--- + +## Worktree C — Phase 2 read-only (9.15.0) + +**Branch:** `refactor/v10-phase2-readonly` off **master after A merges** +**Owner:** 1 developer +**Calendar:** ~1 week + +### Scope footprint + +- All 14 deprecated settings tab files (add `disabled` flag to inputs) +- `src/components/features/settings/components/DeprecationBanner.ts` (banner copy update: "moves to" → "read-only — export now") +- `src/index.ts` (deprecated commands wrap with first-use Notice) +- `src/common/deprecation-messages.ts` (add Notice strings) +- `src/managers/changelog-manager.ts` (10.0.0-beta.1 announcement) +- `manifest-beta.json` (bump to 10.0.0-beta.1) + +### Tasks + +1. Add a `readOnly` prop to each deprecated tab's render function; when true, + every `Setting()` chain calls `.setDisabled(true)` and exports remain enabled +2. Wrap deprecated commands with one-shot Notice (per-session memory, not + settings) +3. Cut 10.0.0-beta.1 via BRAT +4. Coordinate sub-plugin v0.1.0 releases (out of repo): `task-genius-workflow`, + `task-genius-habits`, `task-genius-timer` + +### Conflicts to watch + +Same 14 tabs as Worktree A. **Worktree C must merge after A**, no concurrency. +Phase 3 rewrite of these tabs comes after C merges. + +### DoD subset + +- All deprecated tabs are visually disabled (inputs grayed out) +- Export buttons still functional +- Deprecated commands fire Notice on first use per session +- 10.0.0-beta.1 published to BRAT and a real beta tester confirms it loads + +--- + +## Worktree D cluster — Phase 3 cliff (10.0.0) + +**Branch:** `refactor/v10-phase3-cliff` off master after C merges +**Owner:** 1-2 developers (this is the biggest worktree) +**Calendar:** ~1 week of focused work + +This worktree should be split into FOUR sub-PRs that land sequentially on the +worktree branch: + +### D.1 — Settings tab rewrite (25 → 7) + +**Files:** +- `src/components/features/settings/SettingsModal.ts` (`renderTabContent` switch) +- `src/components/features/settings/tabs/*` (delete 14, rewrite 7) +- `src/components/features/settings/index.ts` (exports) +- `src/components/features/settings/components/DeprecationBanner.ts` (delete) +- `src/common/deprecation-messages.ts` (delete) +- `src/utils/ObsidianUriHandler.ts:117` (switch to redirect map) +- `src/utils/uri-tab-redirects.ts` (new) +- `src/migration/v10/TabIdMap.ts` (new) +- All `src/components/features/settings/tabs/{Workflow,Habit,Reward,TaskTimer,TimelineSidebar}*.ts` (delete) + +**Why a separate sub-PR:** lets you review tab consolidation in isolation. The +deletions are big but mechanical. + +### D.2 — Command palette + hotkeys (43 → 16) + +**Files:** +- `src/index.ts` (command registration block — major rewrite) +- `src/commands/*.ts` (delete: completedTaskMover, sortTaskCommands, taskCycleCommands, taskMover, workflowCommands) +- New consolidated command files in `src/commands/v10/` for the 16 new commands + +**Why separate:** the command rewrite touches a huge stretch of `index.ts` and +is easy to test in isolation (jest test that the right commands are registered +with the right hotkeys). + +**Includes Phase 0 deferred Item 2:** fold `src/utils/settings-migration.ts` +into the registry. See `PHASE0_DEFERRED.md` for the resolution path. + +### D.3 — Quick capture modal consolidation (5 → 1) + +**Files:** +- `src/components/features/quick-capture/modals/{QuickCaptureModal,MinimalQuickCaptureModal,MinimalQuickCaptureModalWithSwitch,BaseQuickCaptureModal}.ts` (delete) +- `src/components/features/quick-capture/modals/QuickCaptureModalWithSwitch.ts` → rename to `QuickCaptureModal.ts` +- New `Mode` strip UI inside the survivor +- `src/utils/migration/steps/v10-quick-capture-modes.ts` (new) +- `src/index.ts` (command callback updates) + +**Why separate:** quick-capture is a self-contained subsystem with its own +tests. Easy to verify in isolation. + +### D.4 — View consolidation + onboarding compression + deletions + +**Files:** +- `src/components/features/{gantt,quadrant,habit}/*` (delete) +- `src/components/features/timeline-sidebar/*` (delete) +- `src/components/features/onboarding/**` (delete 31 files, add 4) +- `src/widgets/registerWidgets.ts` (add TableWidget, QuadrantWidget) +- `src/widgets/views/TableWidgetView.ts` (new, port from `src/components/features/table/`) +- `src/widgets/views/QuadrantWidgetView.ts` (new, ~200 LOC) +- `src/migration/v10/ViewMigration.ts` (new) +- `src/utils/migration/steps/v10-view-cleanup.ts` (new) +- `src/common/setting-definition.ts` (`viewConfiguration` defaults: 15 → 5) +- `src/managers/{habit-manager,reward-manager,timer-manager,electron-quick-capture}.ts` (delete) +- `src/services/{timer-export-service,timer-format-service,timer-metadata-service}.ts` (delete) +- `src/managers/calendar-auth-manager.ts` (delete — moves to calendar-sync sub-plugin) +- `src/providers/*` (delete entire dir) +- `src/parsers/holiday-detector.ts` (delete) +- `src/dataflow/sources/FileSource.ts` (delete) +- `src/editor-extensions/workflow/*` (delete entire dir) +- `src/editor-extensions/date-time/task-timer.ts` (delete) +- `src/pages/TaskView.ts` (delete after porting dirty changes — see `PHASE0_DEFERRED.md` Item 1) + +**Why separate:** this is the biggest deletion sweep. ~50% of the LOC reduction +lands here. Splitting it from D.1-D.3 lets reviewers digest the conceptual +deletions separately from the structural rewrites. + +**Includes Phase 0 deferred Item 1:** delete `src/pages/TaskView.ts`. See +`PHASE0_DEFERRED.md` for the resolution path (port multi-cycle context menu +to FluentTaskView first). + +### D.5 — Migration confirmation modal (cross-sub-PR, lands last) + +The migration confirmation modal touches files from multiple sub-PRs: +- `src/migration/v10/ConfirmationModal.ts` (new) +- `src/utils/migration/steps/v10-archive.ts` (new — wraps Archiver from Worktree B) +- `src/index.ts` (first-launch detection in onload) + +This work should land last on the D branch, after D.1-D.4 are stable. It +depends on the Archiver from Worktree B being available. + +### Conflicts within Worktree D + +D.1-D.4 each touch `src/index.ts` differently: +- D.1 changes settings tab routing +- D.2 rewrites commands +- D.3 changes quick-capture commands +- D.4 deletes timer/workflow commands + +**Sequence them: D.2 first (it owns the command block), then D.1, D.3, D.4 in +any order, then D.5 last.** + +### DoD subset + +The §3.10 acceptance test from the main plan — the "single hardest test" with +47 workflows + 12 habits + etc: + +> A user upgrades from 9.13.1 with: 47 workflows, 12 habits, 3 ICS sources, +> 2 OAuth calendars, 5 custom views, 3 workspaces, all 25 settings tabs touched +> at least once. After upgrading to 10.0.0, they: +> 1. See the migration modal within 3 seconds. +> 2. Click Apply. +> 3. See their Inbox. +> 4. Capture a task with `Mod+Shift+T`. +> 5. Mark it done. +> 6. Open `Task Genius Archive/` and see all their data. +> +> If any of those six steps requires reading docs, opening Settings, or +> restarting Obsidian — **it's not done.** + +--- + +## Worktree E — Phase 4 cleanup (10.0.1) + +**Branch:** `refactor/v10-phase4-cleanup` off master after D merges +**Owner:** 1 developer +**Calendar:** ~1 week + +### Scope footprint + +- `src/migration/v10/Archiver.ts` (delete) +- `src/utils/migration/steps/v10-archive.ts` → tombstone-only marker +- `src/utils/migration/steps/v10-view-cleanup.ts` → tombstone marker +- `src/index.ts` (drop one-version command aliases) +- `scripts/prune-locale-orphans.mjs` (new) +- All non-en `src/translations/locale/*.ts` (prune) +- `src/__tests__/locale-parity.test.ts` (new) + +### Tasks + +1. Run the locale prune script for real +2. Tombstone the migration code itself (the registry will find it's already + applied via `_meta` and skip) +3. Drop command aliases that were kept for one version +4. Release `task-genius-calendar-sync` v0.1.0 (the riskiest sub-plugin, held + until now) + +### Conflicts to watch + +None. By this point Phase 3 is merged and stable. + +--- + +## Sequencing diagram + +``` +phase0 (DONE on refactor/v10-phase0) + | + +---> Worktree A (banners, 9.14.0) ---+ + | | + +---> Worktree B (Archiver, parallel) -+--> [merge to master, tag 9.14.0] + | + v + Worktree C (read-only, 9.15.0) + | + v + [merge to master, tag 9.15.0 + 10.0.0-beta.1] + | + v + Worktree D cluster (cliff, 10.0.0) + /----D.2 (commands) + | | + | +---D.1 (settings tabs) + | | + | +---D.3 (quick capture) + | | + | +---D.4 (views + deletions + onboarding) + | | + | +---D.5 (confirmation modal, last) + | + +--> [merge to master, tag 10.0.0] + | + v + Worktree E (cleanup, 10.0.1) + | + v + [merge to master, tag 10.0.1] +``` + +**Critical path:** A → C → D.2 → D.1/3/4 (parallel) → D.5 → E + +**Off-critical path:** Worktree B (Archiver) — can merge any time before D.5 + +### Calendar estimates + +- **Solo dev:** ~7 weeks +- **2 devs running A+B in parallel, then sequencing C→D→E:** ~6 weeks +- **2 devs running D.1-D.4 in parallel during Phase 3:** ~5 weeks + +--- + +## Setup commands for each worktree + +From the main repo root: + +```bash +# Worktree A +git worktree add ../tg-phase1-banners refactor/v10-phase0 +cd ../tg-phase1-banners +git checkout -b refactor/v10-phase1-banners + +# Worktree B (parallel to A) +git worktree add ../tg-phase1-archiver refactor/v10-phase0 +cd ../tg-phase1-archiver +git checkout -b refactor/v10-phase1-archiver + +# Worktree C (after A merges to master) +git worktree add ../tg-phase2-readonly master +cd ../tg-phase2-readonly +git checkout -b refactor/v10-phase2-readonly + +# Worktree D (after C merges to master) +git worktree add ../tg-phase3-cliff master +cd ../tg-phase3-cliff +git checkout -b refactor/v10-phase3-cliff + +# Worktree E (after D merges to master) +git worktree add ../tg-phase4-cleanup master +cd ../tg-phase4-cleanup +git checkout -b refactor/v10-phase4-cleanup +``` + +To clean up after merging: + +```bash +# From main repo +git worktree remove ../tg-phase1-banners +git branch -d refactor/v10-phase1-banners +``` + +--- + +## Pre-flight checks before starting any worktree + +Before beginning work on a worktree, the assignee should: + +1. **Read** `~/.claude/plans/dynamic-mixing-pie.md` (the main v10 plan) — this + document is operational; the main plan has the rationale. +2. **Check Phase 0 is merged** — `git log master --oneline | grep "Phase 0"`. + If not, the worktree should branch off `refactor/v10-phase0`, not master. +3. **Run the integration tests on the base branch** — + `npm test -- --testPathPattern="integration/"` should report 73+ passing in + the Phase 0 suite. If it doesn't, the base branch is broken and needs + investigation before any new work. +4. **Confirm no overlap with active worktrees** — check the conflict map above. + +--- + +## Risks specific to the worktree split + +| Risk | Mitigation | +|---|---| +| Worktree A and Worktree C touching the same 14 tab files cause merge headaches | Hard sequencing rule: C does not start until A is merged to master | +| Worktree D.1-D.4 both touch `src/index.ts` | D.2 (commands) must merge first; D.1/3/4 rebase onto it | +| Worktree B (Archiver) ships before Worktree D.5 wires it in | Worktree B's tests are pure-function tests — nothing wires it to user-facing UI in Phase 1, so it's safe to ship "dormant" code | +| Sub-plugin extraction blocks Worktree D.4 | Sub-plugins ship v0.1.0 in parallel with Worktree C (Phase 2). By the time D.4 starts, the sub-plugins exist as ghost installations | +| Phase 0 deferred items leak into Phase 1+ | See [PHASE0_DEFERRED.md](./PHASE0_DEFERRED.md) — items are explicitly assigned to D.2 and D.4 | + +--- + +## Communication plan + +### Multi-developer execution + +- Daily standup: which worktree are you on, what's blocked +- Worktree owner pings before merging so others can pull +- Block all worktrees during Phase 3 merge weekend — D's merge is the + highest-conflict event +- Tag releases ONLY after the corresponding DoD subset is verified + +### Solo execution (one developer running the whole plan) + +Just go A → B → C → D.2 → D.1 → D.3 → D.4 → D.5 → E in order. The worktree +split is then mostly informational; you can use a single working tree if you +prefer. + +--- + +## Verification + +Each worktree's "done" criterion is its DoD subset (listed in each worktree +section). The overall verification is the §3.10 acceptance test from the main +plan — the 6-step test where a real user upgrades from 9.13.1 with 47 workflows ++ 12 habits + etc. + +After all worktrees merge, run the full integration suite from the master +branch: + +```bash +cd +git checkout master +git pull +npm install +npm test -- --testPathPattern="integration/" +# Expect: 80+ passing, 0 failing in the integration namespace +npm test +# Expect: pre-existing failure count, no new failures introduced by v10 work +``` diff --git a/jest.config.js b/jest.config.js index 4b9feda5..c36f53df 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { moduleNameMapper: { "^obsidian$": "/src/__mocks__/obsidian.ts", "^moment$": "/src/__mocks__/moment.js", + "^localforage$": "/src/__mocks__/localforage.ts", "^@codemirror/state$": "/src/__mocks__/codemirror-state.ts", "^@codemirror/view$": "/src/__mocks__/codemirror-view.ts", "^@codemirror/language$": diff --git a/src/__mocks__/localforage.ts b/src/__mocks__/localforage.ts new file mode 100644 index 00000000..5c8fed76 --- /dev/null +++ b/src/__mocks__/localforage.ts @@ -0,0 +1,120 @@ +/** + * In-memory mock for `localforage` used in jest tests. + * + * The real localforage requires a browser IndexedDB or localStorage runtime + * which jsdom does not reliably provide. This mock implements the minimum + * surface area used by `src/cache/local-storage-cache.ts`: + * + * localforage.createInstance({name}) + * localforage.dropInstance({name}) + * localforage.INDEXEDDB / LOCALSTORAGE / WEBSQL (driver constants) + * + * Each instance backs its data in a Map keyed by `${name}::${key}`. State is + * shared across instances with the same name (matching real localforage + * semantics) and persists for the lifetime of the test process. + * + * To reset state between tests, call `(localforage as any).__resetAll()`. + */ + +const stores = new Map>(); + +function getStore(name: string): Map { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + return store; +} + +interface MockInstanceOptions { + name: string; + driver?: any; + description?: string; + storeName?: string; +} + +function createInstance(options: MockInstanceOptions) { + const name = options.name; + const store = getStore(name); + + return { + __name: name, + async setItem(key: string, value: T): Promise { + store.set(key, value); + return value; + }, + async getItem(key: string): Promise { + return store.has(key) ? (store.get(key) as T) : null; + }, + async removeItem(key: string): Promise { + store.delete(key); + }, + async clear(): Promise { + store.clear(); + }, + async length(): Promise { + return store.size; + }, + async keys(): Promise { + return [...store.keys()]; + }, + async iterate( + fn: (value: T, key: string, iterationNumber: number) => any, + ): Promise { + let i = 0; + for (const [k, v] of store.entries()) { + const result = fn(v as T, k, ++i); + if (result !== undefined) return; + } + }, + // localforage instances expose ready() returning a resolved promise. + async ready(): Promise {}, + // Driver introspection no-ops. + driver(): string { + return "MOCK"; + }, + setDriver(): Promise { + return Promise.resolve(); + }, + // Configuration is a no-op in the mock. + config(): boolean { + return true; + }, + }; +} + +async function dropInstance(options: { name: string }): Promise { + stores.delete(options.name); +} + +const localforage = { + createInstance, + dropInstance, + // Driver constants — values don't matter, only that they're truthy strings. + INDEXEDDB: "asyncStorage", + LOCALSTORAGE: "localStorageWrapper", + WEBSQL: "webSQLStorage", + // Module-level shortcuts (rarely used by our code, but localforage exports them). + async setItem(key: string, value: T): Promise { + return createInstance({ name: "__default__" }).setItem(key, value); + }, + async getItem(key: string): Promise { + return createInstance({ name: "__default__" }).getItem(key); + }, + async removeItem(key: string): Promise { + return createInstance({ name: "__default__" }).removeItem(key); + }, + async clear(): Promise { + return createInstance({ name: "__default__" }).clear(); + }, + // Test helper: clear all instances. NOT part of real localforage. + __resetAll(): void { + stores.clear(); + }, + __instanceCount(): number { + return stores.size; + }, +}; + +export default localforage; diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index f5ec0e6a..d65f060d 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -395,6 +395,18 @@ export class Component { unload(): void { this.loaded = false; this.children.forEach((child) => child.unload()); + // Drain registered event refs by calling their unload() — this matches + // real Obsidian Component behavior so that listener-leak tests work. + // Without this, code that correctly uses registerEvent() still appears + // to leak in jest because the mock never calls eventRef.unload(). + for (const ref of this._events) { + try { + ref.unload(); + } catch { + /* listener cleanup must not throw */ + } + } + this._events = []; this.onunload(); } diff --git a/src/__tests__/cache/local-storage-cache.version.test.ts b/src/__tests__/cache/local-storage-cache.version.test.ts new file mode 100644 index 00000000..9e09d665 --- /dev/null +++ b/src/__tests__/cache/local-storage-cache.version.test.ts @@ -0,0 +1,80 @@ +/** + * Phase 0 W4b — LocalStorageCache version-mismatch read fix. + * + * Prior to W4b, storeFile() tagged entries with `currentVersion` but loadFile() + * never validated it on read. After an upgrade, every stale entry would be + * happily returned. This test asserts: + * - write at v1.0.0, read at v1.0.0 → returns the entry + * - write at v1.0.0, read at v2.0.0 → returns null (treated as cache miss) + * - the stale entry is removed from the underlying store on the mismatched read + */ + +import { LocalStorageCache } from "@/cache/local-storage-cache"; + +describe("LocalStorageCache version-mismatch read (W4b)", () => { + beforeEach(() => { + // Reset the static "logged once" flag between tests so each test + // independently exercises the log path. + (LocalStorageCache as any)._versionMismatchLogged = false; + }); + + it("returns entries written at the same version", async () => { + const cache = new LocalStorageCache("test-app-1", "1.0.0"); + await cache.storeFile("foo.md", { value: 42 }); + const loaded = await cache.loadFile<{ value: number }>("foo.md"); + expect(loaded).not.toBeNull(); + expect(loaded?.data.value).toBe(42); + expect(loaded?.version).toBe("1.0.0"); + }); + + it("returns null when the cache entry was written at a different version", async () => { + const oldCache = new LocalStorageCache("test-app-2", "1.0.0"); + await oldCache.storeFile("foo.md", { value: 42 }); + + // New cache instance with bumped version pointing at the SAME backing + // store (the localforage mock keys by `name` which is appId-derived). + const newCache = new LocalStorageCache("test-app-2", "2.0.0"); + const loaded = await newCache.loadFile("foo.md"); + expect(loaded).toBeNull(); + }); + + it("removes the stale entry on a mismatched read", async () => { + const oldCache = new LocalStorageCache("test-app-3", "1.0.0"); + await oldCache.storeFile("foo.md", { value: 42 }); + + const newCache = new LocalStorageCache("test-app-3", "2.0.0"); + // First read: triggers prune + await newCache.loadFile("foo.md"); + + // Second read: should still be a miss (the entry was removed, not just + // returned null on the fly). + const persister: any = (newCache as any).persister; + const raw = await persister.getItem(newCache.fileKey("foo.md")); + expect(raw).toBeNull(); + }); + + it("prunes multiple stale entries on subsequent mismatched reads", async () => { + // We don't assert log noise because the log-once optimization uses a + // process-wide static field that's hard to reason about across test + // boundaries — what matters is that every mismatched read independently + // returns null and prunes its entry. + const oldCache = new LocalStorageCache("test-app-4", "1.0.0"); + await oldCache.storeFile("a.md", { v: 1 }); + await oldCache.storeFile("b.md", { v: 2 }); + await oldCache.storeFile("c.md", { v: 3 }); + + const newCache = new LocalStorageCache("test-app-4", "2.0.0"); + const a = await newCache.loadFile("a.md"); + const b = await newCache.loadFile("b.md"); + const c = await newCache.loadFile("c.md"); + + expect(a).toBeNull(); + expect(b).toBeNull(); + expect(c).toBeNull(); + + const persister: any = (newCache as any).persister; + expect(await persister.getItem(newCache.fileKey("a.md"))).toBeNull(); + expect(await persister.getItem(newCache.fileKey("b.md"))).toBeNull(); + expect(await persister.getItem(newCache.fileKey("c.md"))).toBeNull(); + }); +}); diff --git a/src/__tests__/cache/scope-map.test.ts b/src/__tests__/cache/scope-map.test.ts new file mode 100644 index 00000000..fd33a84d --- /dev/null +++ b/src/__tests__/cache/scope-map.test.ts @@ -0,0 +1,151 @@ +/** + * Phase 0 W4a — typed cache scope map. + * + * Asserts: + * 1. Direct field lookups resolve to the documented scopes. + * 2. Nested field paths walk parents correctly. + * 3. Unknown fields contribute no scopes. + * 4. Multiple fields union their scopes. + * 5. The map covers every field that current call sites of + * Orchestrator.onSettingsChange refer to (ratchet test — fails if a + * caller is added without updating the map). + */ + +import { + scopesForFields, + listMappedFields, + SETTINGS_FIELD_TO_SCOPES, + type CacheScope, +} from "@/dataflow/cache/scope-map"; + +describe("scope-map (W4a)", () => { + describe("direct lookups", () => { + it("taskStatuses → parser", () => { + expect(scopesForFields(["taskStatuses"])).toEqual(["parser"]); + }); + + it("projectConfig → project + augment", () => { + const scopes = scopesForFields(["projectConfig"]); + expect(new Set(scopes)).toEqual(new Set(["project", "augment"])); + }); + + it("fileMetadataInheritance → augment", () => { + expect(scopesForFields(["fileMetadataInheritance"])).toEqual([ + "augment", + ]); + }); + }); + + describe("nested field paths walk parents", () => { + it("fileMetadataInheritance.inheritFromFrontmatter resolves via parent", () => { + expect( + scopesForFields([ + "fileMetadataInheritance.inheritFromFrontmatter", + ]), + ).toEqual(["augment"]); + }); + + it("fileSource.recognitionStrategies.tags.taskTags resolves via parent", () => { + expect( + scopesForFields([ + "fileSource.recognitionStrategies.tags.taskTags", + ]), + ).toEqual(["parser"]); + }); + + it("projectConfig.pathMappings.foo resolves via parent", () => { + const scopes = scopesForFields([ + "projectConfig.pathMappings.foo", + ]); + expect(new Set(scopes)).toEqual(new Set(["project", "augment"])); + }); + }); + + describe("unknown fields", () => { + it("returns empty scope list for completely unknown field", () => { + expect(scopesForFields(["someTotallyUnknownField"])).toEqual([]); + }); + + it("returns empty scope list when no parent prefix matches", () => { + // "fileSource" is not in the map (only "fileSource.recognitionStrategies" + // etc. are). A made-up sibling like "fileSource.somethingNotInTheMap" + // has no matching prefix and should yield no scopes. + expect( + scopesForFields(["fileSource.somethingNotInTheMap"]), + ).toEqual([]); + }); + + it("does not match descendants (only proper prefixes)", () => { + // "fileSource" alone is not in the map; only "fileSource.recognitionStrategies" + // etc. So a bare "fileSource" lookup should yield nothing — the + // map intentionally requires the caller to be specific. + expect(scopesForFields(["fileSource"])).toEqual([]); + }); + }); + + describe("union semantics", () => { + it("merges scopes across multiple fields", () => { + const scopes = scopesForFields([ + "taskStatuses", // parser + "fileMetadataInheritance", // augment + ]); + expect(new Set(scopes)).toEqual(new Set(["parser", "augment"])); + }); + + it("dedupes overlapping scopes", () => { + const scopes = scopesForFields([ + "taskStatuses", + "preferMetadataFormat", + ]); + // Both → parser, expect only one entry. + expect(scopes).toEqual(["parser"]); + }); + + it("returns empty array when given empty input", () => { + expect(scopesForFields([])).toEqual([]); + }); + }); + + describe("ratchet: every existing onSettingsChange caller's intent is covered", () => { + // The 3 existing callers in IndexSettingsTab.ts all change + // fileMetadataInheritance.* fields. They currently pass ["parser"] + // (which is technically wrong — these are augment-time settings), + // but the typed map reflects the CORRECT scope. Phase 1's migration + // fixes the call sites. + it("fileMetadataInheritance.* covered by the map", () => { + const fields = [ + "fileMetadataInheritance", + "fileMetadataInheritance.enabled", + "fileMetadataInheritance.inheritFromFrontmatter", + "fileMetadataInheritance.inheritFromFrontmatterForSubtasks", + ]; + for (const f of fields) { + expect(scopesForFields([f])).toEqual(["augment"]); + } + }); + + it("listMappedFields exposes a non-empty list", () => { + expect(listMappedFields().length).toBeGreaterThan(0); + }); + + it("every mapped value is a non-empty array of CacheScope", () => { + const validScopes = new Set([ + "parser", + "augment", + "project", + "index", + ]); + for (const [field, scopes] of Object.entries( + SETTINGS_FIELD_TO_SCOPES, + )) { + expect(scopes.length).toBeGreaterThan(0); + for (const s of scopes) { + expect(validScopes.has(s)).toBe(true); + } + // Field paths must be non-empty strings + expect(field.length).toBeGreaterThan(0); + } + }); + }); +}); + diff --git a/src/__tests__/integration/CacheInvariants.sequence.test.ts b/src/__tests__/integration/CacheInvariants.sequence.test.ts new file mode 100644 index 00000000..375c7b7b --- /dev/null +++ b/src/__tests__/integration/CacheInvariants.sequence.test.ts @@ -0,0 +1,102 @@ +/** + * Phase 0 W5.4 — Cache invariants hold at every step of a realistic sequence. + * + * The smoke test (CacheInvariants.smoke.test.ts) verifies the checker logic + * itself. This test verifies that the checker doesn't false-positive on + * normal pipeline operation: a fresh vault, processing several files, + * modifying some, deleting some, running a settings change. After every step + * the invariants should hold (modulo I3 indexer-vs-augmented drift, which is + * expected during transitional states and is documented in invariants.ts). + * + * Phase 1 deprecation work that breaks invariants will be caught here. + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; +import { checkCacheInvariants } from "@/dataflow/cache/invariants"; + +// I3 (indexer ↔ augmented namespace agreement) is informational rather than a +// hard error during transitional states. Filter it out for sequence assertions. +function nonTransientViolations(report: { violations: any[] }) { + return report.violations.filter( + (v: any) => v.id !== "I3-index-augmented-drift", + ); +} + +describe("CacheInvariants — realistic sequence (W5.4)", () => { + it("invariants hold across process / modify / delete / settings-change", async () => { + const fx = await buildOrchestrator({ + files: { + "a.md": "- [ ] alpha\n", + "b.md": "- [x] beta\n", + "c.md": "no tasks\n", + }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + try { + // --- Step 0: fresh orchestrator, nothing in cache yet --- + let report = await checkCacheInvariants(fx.orchestrator); + expect(nonTransientViolations(report)).toEqual([]); + + // --- Step 1: process a, b, c --- + for (const path of ["a.md", "b.md", "c.md"]) { + const file = fx.vault.getFileByPath(path)!; + await (fx.orchestrator as any).processFileImmediate(file, true); + } + report = await checkCacheInvariants(fx.orchestrator); + expect(nonTransientViolations(report)).toEqual([]); + // Both files with tasks should be in raw and augmented + expect(report.stats.rawCount).toBeGreaterThanOrEqual(2); + expect(report.stats.augmentedCount).toBeGreaterThanOrEqual(2); + expect(report.stats.missingAugmented).toBe(0); + + // --- Step 2: modify a --- + const fileA = fx.vault.getFileByPath("a.md")!; + await fx.vault.modify(fileA, "- [ ] alpha\n- [ ] alpha2\n"); + await (fx.orchestrator as any).processFileImmediate(fileA, true); + report = await checkCacheInvariants(fx.orchestrator); + expect(nonTransientViolations(report)).toEqual([]); + + // --- Step 3: delete b --- + const repo: any = (fx.orchestrator as any).repository; + await repo.removeFile("b.md"); + report = await checkCacheInvariants(fx.orchestrator); + expect(nonTransientViolations(report)).toEqual([]); + + // --- Step 4: settings change (parser scope) — clears raw namespace --- + // Block rebuild so we don't reprocess everything in this test. + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + await fx.orchestrator.onSettingsChange(["parser"]); + report = await checkCacheInvariants(fx.orchestrator); + // After clearing raw, augmented entries become orphans relative to + // raw. Our I1 invariant says "every raw has augmented" — that's + // still trivially true (zero raws). The reverse isn't asserted by + // I1, so this is fine. + expect(nonTransientViolations(report)).toEqual([]); + expect(report.stats.rawCount).toBe(0); + } finally { + await fx.dispose(); + } + }); + + it("invariants hold immediately after dispose has been called", async () => { + // Belt and braces: we shouldn't be able to even call the checker after + // dispose without it crashing. (It might return errors, but no throws.) + const fx = await buildOrchestrator({ files: { "a.md": "- [ ] x\n" } }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + const file = fx.vault.getFileByPath("a.md")!; + await (fx.orchestrator as any).processFileImmediate(file, true); + + await fx.dispose(); + + // After dispose, storage may still be reachable but the orchestrator's + // other dependencies (e.g. workspaceManager event refs) are gone. + // The checker should still complete without throwing. + const report = await checkCacheInvariants(fx.orchestrator); + // We don't assert ok=true here; just that the call returns a structured + // report rather than throwing. + expect(report).toBeDefined(); + expect(Array.isArray(report.violations)).toBe(true); + }); +}); diff --git a/src/__tests__/integration/CacheInvariants.smoke.test.ts b/src/__tests__/integration/CacheInvariants.smoke.test.ts new file mode 100644 index 00000000..80e196b7 --- /dev/null +++ b/src/__tests__/integration/CacheInvariants.smoke.test.ts @@ -0,0 +1,93 @@ +/** + * Phase 0 W4b — smoke test for the cache invariants checker. + * + * This test validates the checker doesn't false-positive on a freshly built + * orchestrator and correctly reports violations when the cache is in a known + * inconsistent state. The fuller "checker reports ok at every step of a + * realistic operation sequence" lives in W5.4 (CacheInvariants.test.ts). + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; +import { checkCacheInvariants } from "@/dataflow/cache/invariants"; + +describe("checkCacheInvariants smoke (W4b)", () => { + it("reports ok on a freshly built orchestrator with no cached data", async () => { + const fx = await buildOrchestrator(); + try { + const report = await checkCacheInvariants(fx.orchestrator); + // Empty cache → no raw entries, no augmented entries, no + // invariants to violate. + expect(report.ok).toBe(true); + expect(report.violations).toEqual([]); + expect(report.stats.rawCount).toBe(0); + expect(report.stats.augmentedCount).toBe(0); + } finally { + await fx.dispose(); + } + }); + + it("flags I1 when a raw entry has no augmented counterpart", async () => { + const fx = await buildOrchestrator(); + try { + // Reach into Storage and inject a raw entry without writing the + // matching augmented entry. This is the canonical "augmentation + // crashed mid-batch" failure mode. + const storage: any = (fx.orchestrator as any).storage; + await storage.storeRaw("ghost.md", [], "content", 1); + + const report = await checkCacheInvariants(fx.orchestrator); + expect(report.ok).toBe(false); + expect(report.stats.rawCount).toBe(1); + expect(report.stats.augmentedCount).toBe(0); + expect(report.stats.missingAugmented).toBe(1); + expect( + report.violations.some( + (v) => v.id === "I1-missing-augmented", + ), + ).toBe(true); + } finally { + await fx.dispose(); + } + }); + + it("does not flag I1 when raw and augmented are in sync", async () => { + const fx = await buildOrchestrator(); + try { + const storage: any = (fx.orchestrator as any).storage; + await storage.storeRaw("a.md", [], "content", 1); + await storage.storeAugmented("a.md", []); + + const report = await checkCacheInvariants(fx.orchestrator); + // I1 satisfied. Other invariants may or may not flag depending on + // indexer state — what matters is missingAugmented === 0. + expect(report.stats.missingAugmented).toBe(0); + expect( + report.violations.some( + (v) => v.id === "I1-missing-augmented", + ), + ).toBe(false); + } finally { + await fx.dispose(); + } + }); + + it("never throws — returns a violation report on internal errors", async () => { + const fx = await buildOrchestrator(); + try { + // Sabotage storage so listRawPaths throws. The checker should + // catch and report, never propagate. + const storage: any = (fx.orchestrator as any).storage; + storage.listRawPaths = jest + .fn() + .mockRejectedValue(new Error("disk on fire")); + + const report = await checkCacheInvariants(fx.orchestrator); + expect(report.ok).toBe(false); + expect( + report.violations.some((v) => v.id === "list-raw-failed"), + ).toBe(true); + } finally { + await fx.dispose(); + } + }); +}); diff --git a/src/__tests__/integration/Lifecycle.unload.test.ts b/src/__tests__/integration/Lifecycle.unload.test.ts new file mode 100644 index 00000000..9105e722 --- /dev/null +++ b/src/__tests__/integration/Lifecycle.unload.test.ts @@ -0,0 +1,98 @@ +/** + * Phase 0 W2 — verify the dataflow orchestrator cleans up cleanly. + * + * Background: prior to W2, src/index.ts:1758 fired `dataflowOrchestrator.cleanup()` + * without awaiting it, so workers / event refs / debounced timers could outlive + * Obsidian's onunload contract. The fix exposes `plugin.unloadComplete` as a + * promise that resolves once async cleanup work has settled. + * + * This test boots a real orchestrator via the W0 fixture, simulates teardown, + * and asserts the event-bus listener counts on the fake app are zero. We don't + * test the plugin class directly (that requires booting the entire plugin under + * jsdom which the test infrastructure isn't set up for), but the orchestrator + * is the only async work in onunload — its cleanup is what unloadComplete + * actually awaits. + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; + +describe("Orchestrator lifecycle (W2)", () => { + it("registers workspace listeners and removes them on cleanup", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one", "b.md": "- [x] two" }, + }); + + // After construction the orchestrator subscribes to workspace events + // for things like ICS updates and write operations. The exact count is + // an implementation detail; just assert it's > 0 so the test would + // catch a regression where listeners stop attaching at all (which would + // silently break the dataflow). + const beforeCleanup = fx.app.__totalListenerCount(); + expect(beforeCleanup).toBeGreaterThan(0); + + await fx.dispose(); + + // After dispose, every listener attached via the orchestrator's + // eventRefs (which cleanup walks via app.workspace.offref) must be gone. + // Vault listeners attached by sub-sources should also be gone. + const afterCleanup = fx.app.__totalListenerCount(); + expect(afterCleanup).toBe(0); + }); + + it("dispose is idempotent and does not throw on a re-dispose", async () => { + const fx = await buildOrchestrator({ files: { "a.md": "- [ ] one" } }); + await fx.dispose(); + // Second dispose path: orchestrator was already nulled out, so the + // fixture's stored reference still points to the old instance. Calling + // cleanup() on it again should not throw. + await expect( + (fx.orchestrator as any).cleanup(), + ).resolves.not.toThrow(); + }); + + it("file events fire to listeners before dispose, but not after", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one" }, + }); + + const events: string[] = []; + fx.vault.on("modify", (file: any) => { + events.push(`modify:${file.path}`); + }); + + await fx.writeFile("a.md", "- [x] one"); + expect(events).toContain("modify:a.md"); + + await fx.dispose(); + + // Note: the W0 fixture's vault still has its own listener bus. The + // "modify" listener we attached in this test is on the fake vault, + // not on the orchestrator's eventRefs, so it's still active here. + // What we care about is that the orchestrator-level listeners are gone + // (covered by the first test) and that the orchestrator no longer + // processes new vault events that arrive post-cleanup. + events.length = 0; + await fx.writeFile("a.md", "- [ ] one"); + // Test-local listener still fires, that's fine. + expect(events).toEqual(["modify:a.md"]); + }); + + it("cleanup awaits Repository.cleanup (the async cost in onunload)", async () => { + const fx = await buildOrchestrator(); + // Spy on the repository cleanup method (accessed via the QueryAPI's + // repository) to confirm it's awaited. + const repo = (fx.orchestrator as any).repository; + expect(repo).toBeDefined(); + const repoCleanupSpy = jest.spyOn(repo, "cleanup"); + + await fx.dispose(); + + expect(repoCleanupSpy).toHaveBeenCalledTimes(1); + // And the spy must have been called and resolved BEFORE dispose returned. + // Jest's awaited spies satisfy that just by virtue of dispose having + // returned. If repo.cleanup() were fire-and-forget the spy would still + // be in a pending state when dispose returned, but Jest can't observe + // that distinction directly — the orchestrator's own `await` enforces it. + repoCleanupSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/integration/Orchestrator.roundtrip.test.ts b/src/__tests__/integration/Orchestrator.roundtrip.test.ts new file mode 100644 index 00000000..026e894f --- /dev/null +++ b/src/__tests__/integration/Orchestrator.roundtrip.test.ts @@ -0,0 +1,141 @@ +/** + * Phase 0 W5.1 — Orchestrator end-to-end roundtrip. + * + * The vital-signs test for the dataflow pipeline. Exercises: + * parse → augment → store raw → store augmented → store project → query → modify + * → re-parse → re-query → delete → re-query + * + * Workers are forced off so parsing routes through main-thread fallback + * (ConfigurableTaskParser) — the worker mock doesn't return TaskParseResult + * shapes. The test isn't validating worker behavior, it's validating that + * a task added to a file ends up in the index and a task removed from a + * file disappears from the index. + * + * Scope notes: this test directly invokes the orchestrator's private + * `processFileImmediate` to bypass the 300ms debounce in `processFile`. + * That's the canonical pattern for fast roundtrip tests; the debounce is + * a UI affordance, not a correctness boundary. + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; + +describe("Orchestrator roundtrip (W5.1)", () => { + it("parses, indexes, and queries tasks from a markdown file", async () => { + const fx = await buildOrchestrator({ + files: { + "notes/a.md": "- [ ] task one\n- [x] task two\n", + }, + }); + + // Force main-thread parsing — we don't want the worker mock involved. + const workerOrchestrator: any = (fx.orchestrator as any) + .workerOrchestrator; + workerOrchestrator.setWorkersEnabled(false); + + try { + const file = fx.vault.getFileByPath("notes/a.md"); + expect(file).not.toBeNull(); + + // Process the file directly (bypass debounce) + await (fx.orchestrator as any).processFileImmediate(file, true); + + // Query through the public QueryAPI + const queryAPI = fx.orchestrator.getQueryAPI(); + const tasks = await queryAPI.getAllTasks(); + + // We expect at least the two tasks we wrote. The exact shape comes + // from ConfigurableTaskParser; we only assert the count and contents. + const inFile = tasks.filter((t: any) => t.filePath === "notes/a.md"); + expect(inFile.length).toBe(2); + const contents = inFile.map((t: any) => t.content).sort(); + expect(contents).toEqual(["task one", "task two"].sort()); + // One completed, one not + const completedFlags = inFile.map((t: any) => t.completed).sort(); + expect(completedFlags).toEqual([false, true]); + } finally { + await fx.dispose(); + } + }); + + it("reflects file modifications in the next query", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one\n" }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + try { + const file = fx.vault.getFileByPath("a.md")!; + await (fx.orchestrator as any).processFileImmediate(file, true); + + let tasks = await fx.orchestrator.getQueryAPI().getAllTasks(); + expect(tasks.filter((t: any) => t.filePath === "a.md").length).toBe( + 1, + ); + + // Modify the file: add a second task + await fx.vault.modify(file, "- [ ] one\n- [ ] two\n"); + // Re-process directly (forceInvalidate=true to bypass mtime cache) + await (fx.orchestrator as any).processFileImmediate(file, true); + + tasks = await fx.orchestrator.getQueryAPI().getAllTasks(); + const inFile = tasks.filter((t: any) => t.filePath === "a.md"); + expect(inFile.length).toBe(2); + } finally { + await fx.dispose(); + } + }); + + it("removes tasks from the index when a file is deleted", async () => { + const fx = await buildOrchestrator({ + files: { "doomed.md": "- [ ] gone\n" }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + try { + const file = fx.vault.getFileByPath("doomed.md")!; + await (fx.orchestrator as any).processFileImmediate(file, true); + + let tasks = await fx.orchestrator.getQueryAPI().getAllTasks(); + expect( + tasks.filter((t: any) => t.filePath === "doomed.md").length, + ).toBe(1); + + // Delete via repository.removeFile (the public API the FILE_UPDATED + // "delete" event handler uses internally). + const repo: any = (fx.orchestrator as any).repository; + await repo.removeFile("doomed.md"); + + tasks = await fx.orchestrator.getQueryAPI().getAllTasks(); + expect( + tasks.filter((t: any) => t.filePath === "doomed.md").length, + ).toBe(0); + } finally { + await fx.dispose(); + } + }); + + it("dispose releases all listeners after a roundtrip", async () => { + const fx = await buildOrchestrator({ + files: { + "a.md": "- [ ] one\n", + "b.md": "- [x] two\n", + "c.md": "no tasks here\n", + }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + const fileA = fx.vault.getFileByPath("a.md")!; + const fileB = fx.vault.getFileByPath("b.md")!; + await (fx.orchestrator as any).processFileImmediate(fileA, true); + await (fx.orchestrator as any).processFileImmediate(fileB, true); + + const beforeDispose = fx.app.__totalListenerCount(); + expect(beforeDispose).toBeGreaterThan(0); + + await fx.dispose(); + + // All event-bus listeners that the orchestrator/sources/indexer + // attached should be gone. + expect(fx.app.__totalListenerCount()).toBe(0); + }); +}); diff --git a/src/__tests__/integration/Orchestrator.settingsChange.test.ts b/src/__tests__/integration/Orchestrator.settingsChange.test.ts new file mode 100644 index 00000000..832f311c --- /dev/null +++ b/src/__tests__/integration/Orchestrator.settingsChange.test.ts @@ -0,0 +1,120 @@ +/** + * Phase 0 W5.2 — settings change → cache invalidation → re-query. + * + * Verifies the contract that subsequent settings consolidation work in + * Phase 1 will rely on: when a settings change is announced via + * onSettingsChange (or its typed sibling onSettingsFieldsChanged), the + * affected cache namespaces are cleared and a subsequent query reflects + * the new state. + * + * This test does NOT exercise rebuild() — that's a separate (slower) + * concern. It just verifies the cache layer reacts to settings changes + * the way Phase 1's deprecation work needs. + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; + +describe("Orchestrator settings change → cache invalidation (W5.2)", () => { + it("parser scope clears the raw namespace", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] task one\n" }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + try { + // Block rebuild to keep this test fast. + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + // Process a file so the raw namespace has content. + const file = fx.vault.getFileByPath("a.md")!; + await (fx.orchestrator as any).processFileImmediate(file, true); + + const storage: any = (fx.orchestrator as any).storage; + const beforeStats = await storage.getStats(); + expect(beforeStats.byNamespace.raw).toBeGreaterThan(0); + + // Trigger a parser-scope settings change. + await fx.orchestrator.onSettingsChange(["parser"]); + + const afterStats = await storage.getStats(); + expect(afterStats.byNamespace.raw).toBe(0); + } finally { + await fx.dispose(); + } + }); + + it("augment scope clears augmented + project namespaces", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one\n" }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + try { + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + const file = fx.vault.getFileByPath("a.md")!; + await (fx.orchestrator as any).processFileImmediate(file, true); + + const storage: any = (fx.orchestrator as any).storage; + const before = await storage.getStats(); + expect(before.byNamespace.augmented).toBeGreaterThan(0); + + await fx.orchestrator.onSettingsChange(["augment"]); + + const after = await storage.getStats(); + expect(after.byNamespace.augmented).toBe(0); + expect(after.byNamespace.project).toBe(0); + } finally { + await fx.dispose(); + } + }); + + it("emits SETTINGS_CHANGED event with the scopes payload", async () => { + const fx = await buildOrchestrator(); + try { + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + const events: any[] = []; + fx.app.workspace.on( + "task-genius:settings-changed", + (payload: any) => { + events.push(payload); + }, + ); + + await fx.orchestrator.onSettingsChange(["parser", "augment"]); + + expect(events.length).toBe(1); + expect(events[0].scopes).toEqual(["parser", "augment"]); + expect(typeof events[0].timestamp).toBe("number"); + } finally { + await fx.dispose(); + } + }); + + it("typed onSettingsFieldsChanged path also clears caches via the legacy delegate", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one\n" }, + }); + (fx.orchestrator as any).workerOrchestrator.setWorkersEnabled(false); + + try { + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + const file = fx.vault.getFileByPath("a.md")!; + await (fx.orchestrator as any).processFileImmediate(file, true); + + const storage: any = (fx.orchestrator as any).storage; + const before = await storage.getStats(); + expect(before.byNamespace.raw).toBeGreaterThan(0); + + // taskStatuses is a parser-scope field per scope-map.ts. + await fx.orchestrator.onSettingsFieldsChanged(["taskStatuses"]); + + const after = await storage.getStats(); + expect(after.byNamespace.raw).toBe(0); + } finally { + await fx.dispose(); + } + }); +}); diff --git a/src/__tests__/integration/OrchestratorScopeMap.test.ts b/src/__tests__/integration/OrchestratorScopeMap.test.ts new file mode 100644 index 00000000..c36afba6 --- /dev/null +++ b/src/__tests__/integration/OrchestratorScopeMap.test.ts @@ -0,0 +1,96 @@ +/** + * Phase 0 W4a — verify the typed sibling Orchestrator.onSettingsFieldsChanged + * resolves field paths to scopes via the typed map and delegates to the + * existing onSettingsChange. + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; + +describe("Orchestrator.onSettingsFieldsChanged (W4a)", () => { + it("delegates known fields to onSettingsChange with the right scopes", async () => { + const fx = await buildOrchestrator(); + try { + const onSettingsChange = jest.spyOn( + fx.orchestrator, + "onSettingsChange", + ); + // Block the rebuild path so we don't actually rebuild during the test. + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + await fx.orchestrator.onSettingsFieldsChanged([ + "fileMetadataInheritance.inheritFromFrontmatter", + ]); + + expect(onSettingsChange).toHaveBeenCalledTimes(1); + expect(onSettingsChange.mock.calls[0][0]).toEqual(["augment"]); + + onSettingsChange.mockRestore(); + } finally { + await fx.dispose(); + } + }); + + it("merges scopes when multiple fields are passed", async () => { + const fx = await buildOrchestrator(); + try { + const onSettingsChange = jest.spyOn( + fx.orchestrator, + "onSettingsChange", + ); + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + await fx.orchestrator.onSettingsFieldsChanged([ + "taskStatuses", // parser + "projectConfig", // project + augment + ]); + + expect(onSettingsChange).toHaveBeenCalledTimes(1); + const passedScopes = onSettingsChange.mock.calls[0][0] as string[]; + expect(new Set(passedScopes)).toEqual( + new Set(["parser", "project", "augment"]), + ); + + onSettingsChange.mockRestore(); + } finally { + await fx.dispose(); + } + }); + + it("skips delegation when no field maps to a scope, but still emits SETTINGS_CHANGED", async () => { + const fx = await buildOrchestrator(); + try { + const onSettingsChange = jest.spyOn( + fx.orchestrator, + "onSettingsChange", + ); + + const events: any[] = []; + fx.app.workspace.on( + "task-genius:settings-changed", + (payload: any) => { + events.push(payload); + }, + ); + + await fx.orchestrator.onSettingsFieldsChanged([ + "someTotallyUnknownField", + "anotherUnknownField", + ]); + + // onSettingsChange should NOT have been invoked because no scopes resolved. + expect(onSettingsChange).not.toHaveBeenCalled(); + // But the SETTINGS_CHANGED event should still fire so UI observers + // can refresh. + expect(events.length).toBe(1); + expect(events[0].scopes).toEqual([]); + expect(events[0].fields).toEqual([ + "someTotallyUnknownField", + "anotherUnknownField", + ]); + + onSettingsChange.mockRestore(); + } finally { + await fx.dispose(); + } + }); +}); diff --git a/src/__tests__/integration/Rebuild.failure.test.ts b/src/__tests__/integration/Rebuild.failure.test.ts new file mode 100644 index 00000000..955d6ece --- /dev/null +++ b/src/__tests__/integration/Rebuild.failure.test.ts @@ -0,0 +1,93 @@ +/** + * Phase 0 W2-bis — verify that a failed rebuild doesn't leave a partial cache. + * + * The fix in src/dataflow/Orchestrator.ts wraps rebuild() in try/catch. On any + * thrown error, every cache namespace is cleared so the next plugin load + * triggers a clean rebuild from disk instead of trusting a partial cache. + * + * The test injects a failure into processBatch (the most realistic failure + * point) by spying on the orchestrator method, then asserts: + * - rebuild() rejects with the original error + * - storage.clearNamespace was called for all 4 namespaces + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; + +describe("Orchestrator.rebuild failure handling (W2-bis)", () => { + it("clears all cache namespaces on rebuild failure and re-throws", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one", "b.md": "- [x] two" }, + }); + + // Reach into the orchestrator's storage to spy on namespace clears. + const storage = (fx.orchestrator as any).storage; + expect(storage).toBeDefined(); + const clearSpy = jest.spyOn(storage, "clearNamespace"); + + // Inject a failure: replace processBatch with a thrower. This is the + // most realistic failure mode (a worker error, a vault read error, + // or a parse error mid-batch). + const boom = new Error("simulated worker crash"); + (fx.orchestrator as any).processBatch = jest.fn(async () => { + throw boom; + }); + + await expect(fx.orchestrator.rebuild()).rejects.toBe(boom); + + // All four namespaces must have been cleared as the last-resort path. + const clearedNamespaces = clearSpy.mock.calls.map( + (call: any[]) => call[0], + ); + expect(clearedNamespaces).toEqual( + expect.arrayContaining(["raw", "augmented", "project", "consolidated"]), + ); + + clearSpy.mockRestore(); + await fx.dispose(); + }); + + it("does not clear namespaces on a successful rebuild", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one" }, + }); + + const storage = (fx.orchestrator as any).storage; + const clearSpy = jest.spyOn(storage, "clearNamespace"); + + // processBatch is the slow path; replace with a no-op so rebuild + // completes quickly without exercising worker plumbing. + (fx.orchestrator as any).processBatch = jest.fn(async () => {}); + + await expect(fx.orchestrator.rebuild()).resolves.toBeUndefined(); + + // On success, the catch branch should NOT have run, so clearNamespace + // should not have been called from rebuild's catch handler. Note: if + // repository.clear() internally calls clearNamespace, this assertion + // would fail; our implementation calls repository.clear() (a different + // path), not storage.clearNamespace, so this is safe. + expect(clearSpy).not.toHaveBeenCalled(); + + clearSpy.mockRestore(); + await fx.dispose(); + }); + + it("re-throws even when last-resort cleanup itself fails", async () => { + const fx = await buildOrchestrator({ files: { "a.md": "- [ ] one" } }); + + const storage = (fx.orchestrator as any).storage; + // Make every clearNamespace call fail. The catch-of-catch path should + // still re-throw the original error. + jest.spyOn(storage, "clearNamespace").mockRejectedValue( + new Error("storage is on fire"), + ); + + const boom = new Error("primary failure"); + (fx.orchestrator as any).processBatch = jest.fn(async () => { + throw boom; + }); + + await expect(fx.orchestrator.rebuild()).rejects.toBe(boom); + + await fx.dispose(); + }); +}); diff --git a/src/__tests__/integration/WorkerTimeout.test.ts b/src/__tests__/integration/WorkerTimeout.test.ts new file mode 100644 index 00000000..c531ad1f --- /dev/null +++ b/src/__tests__/integration/WorkerTimeout.test.ts @@ -0,0 +1,178 @@ +/** + * Phase 0 W3 — verify TaskWorkerManager per-task timeout fires, terminates the + * hung worker, and rejects with WorkerTimeoutError; verify WorkerOrchestrator + * counts the timeout in its metrics and falls back to main thread. + * + * Two test groups: + * 1. TaskWorkerManager: monkey-patch the underlying worker.worker.postMessage + * to a no-op so the worker never responds, then drive the per-task timeout + * with a short workerTimeoutMs and assert the rejection shape. + * 2. WorkerOrchestrator: stub a TaskWorkerManager whose processFile rejects + * with WorkerTimeoutError, assert metrics.taskWorkerTimeouts increments + * and the main-thread fallback runs. + */ + +import { TaskWorkerManager } from "@/dataflow/workers/TaskWorkerManager"; +import { WorkerOrchestrator } from "@/dataflow/workers/WorkerOrchestrator"; +import { WorkerTimeoutError } from "@/dataflow/workers/errors"; + +// Build a minimal TFile-shaped object — TaskWorkerManager only reads .path, +// .extension, and .stat from it during processFile. +function fakeFile(path: string, content = "- [ ] task") { + return { + path, + basename: path.replace(/\.md$/, ""), + extension: "md", + name: path.split("/").pop() ?? path, + parent: null, + stat: { mtime: 1, ctime: 1, size: content.length }, + } as any; +} + +describe("TaskWorkerManager per-task timeout (W3)", () => { + it("rejects active task with WorkerTimeoutError when worker hangs", async () => { + // Minimal vault/metadataCache mocks. Only methods TaskWorkerManager + // reaches into during processFile/getTaskMetadata are needed. + const vault: any = { + cachedRead: async () => "- [ ] task", + }; + const metadataCache: any = { + getFileCache: () => null, + }; + + // Construct manager with a 100ms timeout — long enough that the + // jest event loop can schedule it, short enough that the test stays fast. + const mgr = new TaskWorkerManager(vault, metadataCache, { + maxWorkers: 1, + cpuUtilization: 1, + workerTimeoutMs: 100, + }); + + // Force-spawn one worker by reaching into the private state, then + // monkey-patch its underlying worker.postMessage to do nothing so the + // worker never responds. (Workers are constructed eagerly in the + // constructor via initializeWorkers().) + const workersMap: Map = (mgr as any).workers; + expect(workersMap.size).toBeGreaterThan(0); + for (const w of workersMap.values()) { + w.worker.postMessage = jest.fn(); // black hole + } + + const file = fakeFile("hang.md"); + const result = mgr.processFile(file).then( + (value) => ({ kind: "resolved", value }), + (error) => ({ kind: "rejected", error }), + ); + + // Wait long enough for the timeout to fire (100ms + slack) + await new Promise((resolve) => setTimeout(resolve, 200)); + const settled = await result; + + expect(settled.kind).toBe("rejected"); + const err = (settled as any).error; + expect(err).toBeInstanceOf(WorkerTimeoutError); + expect(err.filePath).toBe("hang.md"); + expect(err.timeoutMs).toBe(100); + + // Timeout was counted on the manager. + expect(mgr.getTimeoutCount()).toBe(1); + + // Cleanup + (mgr as any).onunload(); + }); + + it("spawns a replacement worker after a timeout so the pool stays warm", async () => { + const vault: any = { + cachedRead: async () => "- [ ] task", + }; + const metadataCache: any = { + getFileCache: () => null, + }; + + const mgr = new TaskWorkerManager(vault, metadataCache, { + maxWorkers: 1, + cpuUtilization: 1, + workerTimeoutMs: 80, + }); + + const workersMap: Map = (mgr as any).workers; + const initialIds = new Set([...workersMap.keys()]); + for (const w of workersMap.values()) { + w.worker.postMessage = jest.fn(); + } + + await mgr + .processFile(fakeFile("hang.md")) + .catch(() => undefined); // we expect rejection + + // Give the timeout time to fire and replacement to spawn + await new Promise((resolve) => setTimeout(resolve, 200)); + + // After the timeout: the pool size should be back to maxWorkers (1) + // and the worker IDs should NOT all match the initial set (one was + // replaced). The timeout counter should be exactly 1. + expect(workersMap.size).toBe(1); + expect(mgr.getTimeoutCount()).toBe(1); + const finalIds = new Set([...workersMap.keys()]); + const allReplaced = [...finalIds].every((id) => !initialIds.has(id)); + expect(allReplaced).toBe(true); + + (mgr as any).onunload(); + }); +}); + +describe("WorkerOrchestrator counts WorkerTimeoutError separately (W3)", () => { + it("increments metrics.taskWorkerTimeouts and falls back to main thread", async () => { + // Stub a TaskWorkerManager whose processFile always rejects with the + // timeout error. The orchestrator should catch it, bump the counter, + // and fall through to parseFileTasksMainThread. + const stubTaskMgr: any = { + processFile: jest.fn(async (file: any) => { + throw new WorkerTimeoutError(file.path, 100); + }), + processBatch: jest.fn(), + isProcessingBatchTask: () => false, + getPendingTaskCount: () => 0, + getBatchProgress: () => ({ current: 0, total: 0, percentage: 0 }), + getStats: () => ({}), + }; + const stubProjectMgr: any = { + getProjectData: jest.fn(), + getBatchProjectData: jest.fn(), + isWorkersEnabled: () => true, + getMemoryStats: () => ({}), + }; + + const orch = new WorkerOrchestrator(stubTaskMgr, stubProjectMgr, { + enableWorkerProcessing: true, + }); + + // Patch the orchestrator's main-thread fallback so the test doesn't + // need a real ConfigurableTaskParser. We just need to confirm it's + // called and returns something. + const fallbackResult: any[] = [{ id: "fallback", content: "ok" }]; + (orch as any).parseFileTasksMainThread = jest.fn(async () => { + return fallbackResult; + }); + + const file = { + path: "hang.md", + extension: "md", + stat: { mtime: 1, ctime: 1, size: 1 }, + } as any; + const result = await orch.parseFileTasks(file); + + // Fallback was reached + expect(result).toBe(fallbackResult); + expect( + (orch as any).parseFileTasksMainThread, + ).toHaveBeenCalledTimes(1); + + // Metric was incremented + const metrics = orch.getMetrics(); + expect(metrics.taskWorkerTimeouts).toBe(1); + // And the generic failure count went up too — timeouts are a subset + // of failures, not a replacement for them. + expect(metrics.taskParsingFailures).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/__tests__/integration/_fixtures/buildOrchestrator.smoke.test.ts b/src/__tests__/integration/_fixtures/buildOrchestrator.smoke.test.ts new file mode 100644 index 00000000..24edd9a2 --- /dev/null +++ b/src/__tests__/integration/_fixtures/buildOrchestrator.smoke.test.ts @@ -0,0 +1,165 @@ +/** + * Smoke test for the buildOrchestrator fixture itself. + * + * This is W0 in the v10 Phase 0 plan: prove the fixture constructs a real + * DataflowOrchestrator with mock dependencies, exposes the documented API, + * and disposes cleanly. Subsequent integration tests (W5) build on top. + * + * This is intentionally NOT a test of orchestrator behavior — just plumbing. + */ + +import { buildOrchestrator } from "./buildOrchestrator"; +import { InMemoryStorage } from "./inMemoryStorage"; + +describe("buildOrchestrator fixture (W0 smoke)", () => { + it("constructs an orchestrator with no files", async () => { + const fx = await buildOrchestrator(); + try { + expect(fx.orchestrator).toBeDefined(); + expect(fx.plugin).toBeDefined(); + expect(fx.plugin.dataflowOrchestrator).toBe(fx.orchestrator); + expect(fx.app.appId).toMatch(/^test-app-/); + expect(fx.vault.getMarkdownFiles()).toHaveLength(0); + } finally { + await fx.dispose(); + } + }); + + it("seeds vault files from the files option", async () => { + const fx = await buildOrchestrator({ + files: { + "notes/a.md": "- [ ] task one #x", + "notes/b.md": "- [x] task two", + "notes/c.txt": "not markdown", + }, + }); + try { + const md = fx.vault.getMarkdownFiles(); + expect(md.map((f) => f.path).sort()).toEqual([ + "notes/a.md", + "notes/b.md", + ]); + expect(await fx.vault.read({ path: "notes/a.md" })).toBe( + "- [ ] task one #x", + ); + } finally { + await fx.dispose(); + } + }); + + it("merges settings overrides onto DEFAULT_SETTINGS", async () => { + const fx = await buildOrchestrator({ + settings: { preferMetadataFormat: "dataview" }, + }); + try { + expect(fx.plugin.settings.preferMetadataFormat).toBe("dataview"); + // Other defaults still present + expect(fx.plugin.settings.taskStatuses).toBeDefined(); + } finally { + await fx.dispose(); + } + }); + + it("writeFile triggers vault modify event", async () => { + const fx = await buildOrchestrator({ + files: { "a.md": "- [ ] one" }, + }); + try { + const events: string[] = []; + fx.vault.on("modify", (file: any) => { + events.push(`modify:${file.path}`); + }); + await fx.writeFile("a.md", "- [x] one"); + expect(events).toEqual(["modify:a.md"]); + expect(await fx.vault.read({ path: "a.md" })).toBe("- [x] one"); + } finally { + await fx.dispose(); + } + }); + + it("createFile triggers vault create event", async () => { + const fx = await buildOrchestrator(); + try { + const events: string[] = []; + fx.vault.on("create", (file: any) => { + events.push(`create:${file.path}`); + }); + await fx.createFile("new.md", "- [ ] new"); + expect(events).toEqual(["create:new.md"]); + } finally { + await fx.dispose(); + } + }); + + it("deleteFile triggers vault delete event", async () => { + const fx = await buildOrchestrator({ files: { "doomed.md": "x" } }); + try { + const events: string[] = []; + fx.vault.on("delete", (file: any) => { + events.push(`delete:${file.path}`); + }); + await fx.deleteFile("doomed.md"); + expect(events).toEqual(["delete:doomed.md"]); + expect(fx.vault.getFileByPath("doomed.md")).toBeNull(); + } finally { + await fx.dispose(); + } + }); + + it("dispose calls orchestrator.cleanup() and does not throw", async () => { + const fx = await buildOrchestrator(); + // Spy on orchestrator.cleanup to make sure dispose actually invokes it. + const spy = jest.spyOn(fx.orchestrator, "cleanup"); + await fx.dispose(); + expect(spy).toHaveBeenCalledTimes(1); + expect(fx.plugin.dataflowOrchestrator).toBeUndefined(); + spy.mockRestore(); + }); + + it("can call dispose without files or initialization", async () => { + const fx = await buildOrchestrator(); + await expect(fx.dispose()).resolves.not.toThrow(); + }); +}); + +describe("InMemoryStorage (W0)", () => { + it("stores and retrieves raw records", async () => { + const s = new InMemoryStorage(); + await s.storeRaw("a.md", [], "content", 100); + const r = await s.loadRaw("a.md"); + expect(r).not.toBeNull(); + expect(r?.mtime).toBe(100); + expect(r?.data).toEqual([]); + }); + + it("clearNamespace only clears matching prefix", async () => { + const s = new InMemoryStorage(); + await s.storeRaw("a.md", []); + await s.storeAugmented("a.md", []); + await s.storeProject("a.md", { enhancedMetadata: {} }); + const before = await s.getStats(); + expect(before.byNamespace.raw).toBe(1); + expect(before.byNamespace.augmented).toBe(1); + expect(before.byNamespace.project).toBe(1); + + await s.clearNamespace("raw"); + const after = await s.getStats(); + expect(after.byNamespace.raw).toBe(0); + expect(after.byNamespace.augmented).toBe(1); + expect(after.byNamespace.project).toBe(1); + }); + + it("version mismatch on read invalidates the record", async () => { + const s = new InMemoryStorage("1.0.0"); + await s.storeRaw("a.md", []); + expect(await s.loadRaw("a.md")).not.toBeNull(); + s.__setVersion("2.0.0"); + expect(await s.loadRaw("a.md")).toBeNull(); + }); + + it("meta save/load round-trips", async () => { + const s = new InMemoryStorage(); + await s.saveMeta("k", { foo: 1 }); + expect(await s.loadMeta("k")).toEqual({ foo: 1 }); + }); +}); diff --git a/src/__tests__/integration/_fixtures/buildOrchestrator.ts b/src/__tests__/integration/_fixtures/buildOrchestrator.ts new file mode 100644 index 00000000..ac0b93bb --- /dev/null +++ b/src/__tests__/integration/_fixtures/buildOrchestrator.ts @@ -0,0 +1,448 @@ +/** + * Integration test fixture for booting a real DataflowOrchestrator + * with mock Obsidian app/vault/metadataCache and a fake plugin instance. + * + * Used by Phase 0 stability tests (lifecycle, settings change, worker fallback, + * cache invariants, migration tombstones). The fixture is intentionally narrow: + * provide files and settings, get back an orchestrator + dispose. + * + * Construction is lazy — workers do not spawn until the orchestrator's + * `initialize()` is called or a query/process method runs. This means a fixture + * that only tests cleanup paths can avoid pulling worker construction into the + * test environment. + */ + +import { DataflowOrchestrator } from "@/dataflow/Orchestrator"; +import { DEFAULT_SETTINGS } from "@/common/setting-definition"; +import type { TaskProgressBarSettings } from "@/common/setting-definition"; + +export interface VaultFile { + path: string; + content: string; + mtime?: number; + extension?: string; +} + +export interface BuildOrchestratorOptions { + /** Map of path → file content (or full VaultFile records). */ + files?: Record | VaultFile[]; + /** Partial settings overrides merged onto DEFAULT_SETTINGS. */ + settings?: Partial; + /** Plugin manifest version. Defaults to "test-1.0.0". */ + version?: string; + /** Whether to call orchestrator.initialize() after construction. Default false. */ + initialize?: boolean; +} + +export interface OrchestratorFixture { + orchestrator: DataflowOrchestrator; + plugin: FakePlugin; + app: FakeApp; + vault: FakeVault; + metadataCache: FakeMetadataCache; + /** + * Update the contents of a file and trigger a vault `modify` event. + * Returns a promise that resolves once vault listeners have been notified. + */ + writeFile(path: string, content: string): Promise; + /** Delete a file and trigger a vault `delete` event. */ + deleteFile(path: string): Promise; + /** Create a new file and trigger a vault `create` event. */ + createFile(path: string, content: string): Promise; + /** + * Tear down the orchestrator. Awaits all async cleanup. Tests that need + * to assert no leaked timers should wrap the test in jest.useFakeTimers() + * and check `jest.getTimerCount() === 0` AFTER dispose returns. + */ + dispose(): Promise; +} + +// --------------------------------------------------------------------------- +// Minimal fakes — only what Orchestrator and its sub-components actually call. +// --------------------------------------------------------------------------- + +class FakeTFile { + path: string; + name: string; + basename: string; + extension: string; + stat: { mtime: number; ctime: number; size: number }; + parent: any = null; + + constructor(path: string, content: string, mtime: number = Date.now()) { + this.path = path; + const segs = path.split("/"); + this.name = segs[segs.length - 1]; + const dot = this.name.lastIndexOf("."); + this.basename = dot >= 0 ? this.name.substring(0, dot) : this.name; + this.extension = dot >= 0 ? this.name.substring(dot + 1) : ""; + this.stat = { mtime, ctime: mtime, size: content.length }; + } +} + +class EventBus { + private handlers = new Map void>>(); + + on(name: string, handler: (...args: any[]) => void) { + if (!this.handlers.has(name)) this.handlers.set(name, new Set()); + this.handlers.get(name)!.add(handler); + // Return an EventRef-shaped object. Both `name`/`handler` (used by + // our offref()) AND a working `unload()` method (used by the obsidian + // mock's Component.unload to drain registered events) must be present. + const self = this; + return { + name, + handler, + unload() { + self.off(name, handler); + }, + } as any; + } + + off(name: string, handler: (...args: any[]) => void) { + this.handlers.get(name)?.delete(handler); + } + + offref(ref: any) { + if (ref && ref.name && ref.handler) this.off(ref.name, ref.handler); + } + + trigger(name: string, ...args: any[]) { + const set = this.handlers.get(name); + if (!set) return; + for (const h of [...set]) { + try { + h(...args); + } catch (e) { + // Don't let one listener break the others; surface in console for debugging. + // eslint-disable-next-line no-console + console.error(`[buildOrchestrator] listener for ${name} threw:`, e); + } + } + } + + clear() { + this.handlers.clear(); + } + + listenerCount(): number { + let n = 0; + for (const s of this.handlers.values()) n += s.size; + return n; + } +} + +export class FakeVault { + private fileMap = new Map(); + private contentMap = new Map(); + private bus = new EventBus(); + configDir = ".obsidian"; + + /** + * Vault adapter — minimal surface for code that does + * `vault.adapter.stat(path)`. Used by Orchestrator.processFileImmediate + * for mtime checks. + */ + adapter = { + stat: async (path: string) => { + const f = this.fileMap.get(path); + if (!f) return null; + return { + mtime: f.stat.mtime, + ctime: f.stat.ctime, + size: f.stat.size, + type: "file" as const, + }; + }, + exists: async (path: string) => this.fileMap.has(path), + }; + + constructor(initialFiles?: Record | VaultFile[]) { + if (Array.isArray(initialFiles)) { + for (const f of initialFiles) this.__seed(f.path, f.content, f.mtime); + } else if (initialFiles) { + for (const [path, content] of Object.entries(initialFiles)) { + this.__seed(path, content); + } + } + } + + __seed(path: string, content: string, mtime?: number): FakeTFile { + const file = new FakeTFile(path, content, mtime ?? Date.now()); + this.fileMap.set(path, file); + this.contentMap.set(path, content); + return file; + } + + getMarkdownFiles(): FakeTFile[] { + return [...this.fileMap.values()].filter((f) => f.extension === "md"); + } + + getFiles(): FakeTFile[] { + return [...this.fileMap.values()]; + } + + getAbstractFileByPath(path: string): FakeTFile | null { + return this.fileMap.get(path) ?? null; + } + + getFileByPath(path: string): FakeTFile | null { + return this.fileMap.get(path) ?? null; + } + + async read(file: FakeTFile | { path: string }): Promise { + return this.contentMap.get(file.path) ?? ""; + } + + async cachedRead(file: FakeTFile | { path: string }): Promise { + return this.contentMap.get(file.path) ?? ""; + } + + async modify(file: FakeTFile | { path: string }, content: string): Promise { + this.contentMap.set(file.path, content); + const real = this.fileMap.get(file.path); + if (real) { + real.stat.mtime = Date.now(); + real.stat.size = content.length; + this.bus.trigger("modify", real); + } + } + + async create(path: string, content: string): Promise { + const f = this.__seed(path, content); + this.bus.trigger("create", f); + return f; + } + + async delete(file: FakeTFile | { path: string }): Promise { + const real = this.fileMap.get(file.path); + this.fileMap.delete(file.path); + this.contentMap.delete(file.path); + if (real) this.bus.trigger("delete", real); + } + + async rename(file: FakeTFile, newPath: string): Promise { + const oldPath = file.path; + const content = this.contentMap.get(oldPath) ?? ""; + this.fileMap.delete(oldPath); + this.contentMap.delete(oldPath); + const renamed = this.__seed(newPath, content); + this.bus.trigger("rename", renamed, oldPath); + } + + on(name: string, handler: (...args: any[]) => void) { + return this.bus.on(name, handler); + } + + off(name: string, handler: (...args: any[]) => void) { + this.bus.off(name, handler); + } + + offref(ref: any) { + this.bus.offref(ref); + } + + trigger(name: string, ...args: any[]) { + this.bus.trigger(name, ...args); + } + + getConfig(key: string): any { + if (key === "tabSize") return 4; + if (key === "useTab") return false; + return null; + } + + __listenerCount(): number { + return this.bus.listenerCount(); + } + + __bus(): EventBus { + return this.bus; + } +} + +export class FakeMetadataCache { + private bus = new EventBus(); + private cache = new Map(); + + getFileCache(file: { path: string }): any { + return this.cache.get(file.path) ?? null; + } + + getCache(path: string): any { + return this.cache.get(path) ?? null; + } + + __set(path: string, value: any) { + this.cache.set(path, value); + } + + on(name: string, handler: (...args: any[]) => void) { + return this.bus.on(name, handler); + } + + off(name: string, handler: (...args: any[]) => void) { + this.bus.off(name, handler); + } + + offref(ref: any) { + this.bus.offref(ref); + } + + trigger(name: string, ...args: any[]) { + this.bus.trigger(name, ...args); + } + + __listenerCount(): number { + return this.bus.listenerCount(); + } +} + +export class FakeApp { + appId = "test-app-" + Math.random().toString(36).slice(2); + vault: FakeVault; + metadataCache: FakeMetadataCache; + workspace: { + on: (n: string, h: (...args: any[]) => void) => any; + off: (n: string, h: (...args: any[]) => void) => void; + offref: (ref: any) => void; + trigger: (n: string, ...args: any[]) => void; + onLayoutReady: (cb: () => void) => void; + getActiveFile: () => any; + getLeaf: () => any; + __bus: EventBus; + __listenerCount: () => number; + }; + fileManager = { + generateMarkdownLink: () => "[[link]]", + }; + plugins = { + enabledPlugins: new Set(), + plugins: {} as Record, + }; + + constructor(vault: FakeVault, metadataCache: FakeMetadataCache) { + this.vault = vault; + this.metadataCache = metadataCache; + const bus = new EventBus(); + this.workspace = { + on: (n, h) => bus.on(n, h), + off: (n, h) => bus.off(n, h), + offref: (ref) => bus.offref(ref), + trigger: (n, ...args) => bus.trigger(n, ...args), + onLayoutReady: (cb) => cb(), + getActiveFile: () => null, + getLeaf: () => ({ openFile: () => {} }), + __bus: bus, + __listenerCount: () => bus.listenerCount(), + }; + } + + __totalListenerCount(): number { + return ( + this.workspace.__listenerCount() + + this.vault.__listenerCount() + + this.metadataCache.__listenerCount() + ); + } +} + +export interface FakePlugin { + app: FakeApp; + settings: TaskProgressBarSettings; + manifest: { id: string; name: string; version: string }; + dataflowOrchestrator?: DataflowOrchestrator; + getIcsManager(): undefined; + saveSettings(): Promise; + registerEvent(ref: any): void; +} + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +export async function buildOrchestrator( + opts: BuildOrchestratorOptions = {}, +): Promise { + const vault = new FakeVault(opts.files); + const metadataCache = new FakeMetadataCache(); + const app = new FakeApp(vault, metadataCache); + + const settings: TaskProgressBarSettings = { + ...(DEFAULT_SETTINGS as TaskProgressBarSettings), + ...(opts.settings ?? {}), + }; + + const plugin: FakePlugin = { + app, + settings, + manifest: { + id: "task-genius-test", + name: "Task Genius (test)", + version: opts.version ?? "test-1.0.0", + }, + getIcsManager: () => undefined, + saveSettings: async () => {}, + registerEvent: () => {}, + }; + + // Construct the real Orchestrator. Heavy sub-components (workers, ICS source + // retry loop, file source) are constructed lazily by their managers and only + // fully boot when `initialize()` is called or a parse path runs. + const orchestrator = new DataflowOrchestrator( + app as any, + vault as any, + metadataCache as any, + plugin, + ); + plugin.dataflowOrchestrator = orchestrator; + + if (opts.initialize) { + await orchestrator.initialize(); + } + + const fx: OrchestratorFixture = { + orchestrator, + plugin, + app, + vault, + metadataCache, + + async writeFile(path, content) { + const existing = vault.getFileByPath(path); + if (existing) { + await vault.modify(existing, content); + } else { + await vault.create(path, content); + } + // Yield to microtasks so any awaiting subscribers progress. + await Promise.resolve(); + }, + + async deleteFile(path) { + const f = vault.getFileByPath(path); + if (f) await vault.delete(f); + await Promise.resolve(); + }, + + async createFile(path, content) { + await vault.create(path, content); + await Promise.resolve(); + }, + + async dispose() { + try { + await orchestrator.cleanup(); + } catch (e) { + // Surface unexpected cleanup errors so tests can react. + // eslint-disable-next-line no-console + console.error("[buildOrchestrator.dispose] cleanup threw:", e); + throw e; + } finally { + plugin.dataflowOrchestrator = undefined; + } + }, + }; + + return fx; +} diff --git a/src/__tests__/integration/_fixtures/inMemoryStorage.ts b/src/__tests__/integration/_fixtures/inMemoryStorage.ts new file mode 100644 index 00000000..7207f34f --- /dev/null +++ b/src/__tests__/integration/_fixtures/inMemoryStorage.ts @@ -0,0 +1,253 @@ +/** + * In-memory Storage test double. + * + * Implements the public surface of `src/dataflow/persistence/Storage.ts` that the + * Orchestrator, Repository, and integration tests actually call. Backed by a Map, + * so cache tests don't need to touch IndexedDB / localStorage / localforage. + * + * Storage in production code is constructed internally by Repository and is not + * injectable. This double exists for tests that exercise the cache layer directly + * (e.g. cache invariants, scope-map verification) and for any future refactor + * that makes Storage injectable. Keep the surface narrow — add methods only when + * a test needs them. + */ + +import type { Task, TaskCache } from "../../../types/task"; +import type { + RawRecord, + ProjectRecord, + AugmentedRecord, + ConsolidatedRecord, +} from "../../../dataflow/persistence/Storage"; + +type Namespace = "raw" | "project" | "augmented" | "consolidated"; + +const PREFIX: Record = { + raw: "tasks.raw:", + project: "project.data:", + augmented: "tasks.augmented:", + consolidated: "consolidated:", +}; + +const META_PREFIX = "meta:"; + +export class InMemoryStorage { + private map = new Map(); + private currentVersion: string; + private schemaVersion: number = 1; + + constructor(version: string = "1.0.0") { + this.currentVersion = version; + } + + // --- raw --- + + async loadRaw(path: string): Promise { + const rec = this.map.get(PREFIX.raw + path) as RawRecord | undefined; + if (!rec) return null; + if (!this.versionOk(rec)) { + this.map.delete(PREFIX.raw + path); + return null; + } + return rec; + } + + async storeRaw( + path: string, + tasks: Task[], + fileContent?: string, + mtime?: number, + ): Promise { + const rec: RawRecord = { + hash: this.hash(fileContent || tasks), + time: Date.now(), + version: this.currentVersion, + schema: this.schemaVersion, + data: tasks, + mtime, + }; + this.map.set(PREFIX.raw + path, rec); + } + + isRawValid( + _path: string, + record: RawRecord, + fileContent?: string, + mtime?: number, + ): boolean { + if (!this.versionOk(record)) return false; + if (mtime !== undefined && record.mtime !== undefined && record.mtime !== mtime) + return false; + if (fileContent && record.hash !== this.hash(fileContent)) return false; + return true; + } + + // --- project --- + + async loadProject(path: string): Promise { + const rec = this.map.get(PREFIX.project + path) as ProjectRecord | undefined; + if (!rec) return null; + if (!this.versionOk(rec)) { + this.map.delete(PREFIX.project + path); + return null; + } + return rec; + } + + async storeProject( + path: string, + data: { tgProject?: any; enhancedMetadata: Record }, + ): Promise { + const rec: ProjectRecord = { + hash: this.hash(data), + time: Date.now(), + version: this.currentVersion, + schema: this.schemaVersion, + data, + }; + this.map.set(PREFIX.project + path, rec); + } + + // --- augmented --- + + async loadAugmented(path: string): Promise { + const rec = this.map.get(PREFIX.augmented + path) as AugmentedRecord | undefined; + if (!rec) return null; + if (!this.versionOk(rec)) { + this.map.delete(PREFIX.augmented + path); + return null; + } + return rec; + } + + async storeAugmented(path: string, tasks: Task[]): Promise { + const rec: AugmentedRecord = { + hash: this.hash(tasks), + time: Date.now(), + version: this.currentVersion, + schema: this.schemaVersion, + data: tasks, + }; + this.map.set(PREFIX.augmented + path, rec); + } + + // --- consolidated --- + + async loadConsolidated(): Promise { + const rec = this.map.get(PREFIX.consolidated + "taskIndex") as + | ConsolidatedRecord + | undefined; + if (!rec) return null; + if (!this.versionOk(rec)) { + this.map.delete(PREFIX.consolidated + "taskIndex"); + return null; + } + return rec; + } + + async storeConsolidated(taskCache: TaskCache): Promise { + const rec: ConsolidatedRecord = { + time: Date.now(), + version: this.currentVersion, + schema: this.schemaVersion, + data: taskCache, + }; + this.map.set(PREFIX.consolidated + "taskIndex", rec); + } + + // --- meta --- + + async saveMeta(key: string, value: T): Promise { + this.map.set(META_PREFIX + key, value); + } + + async loadMeta(key: string): Promise { + return (this.map.get(META_PREFIX + key) as T) ?? null; + } + + // --- listing / lifecycle --- + + async listRawPaths(): Promise { + const out: string[] = []; + for (const k of this.map.keys()) { + if (k.startsWith(PREFIX.raw)) out.push(k.substring(PREFIX.raw.length)); + } + return out; + } + + async listAugmentedPaths(): Promise { + const out: string[] = []; + for (const k of this.map.keys()) { + if (k.startsWith(PREFIX.augmented)) + out.push(k.substring(PREFIX.augmented.length)); + } + return out; + } + + async clearFile(path: string): Promise { + this.map.delete(PREFIX.raw + path); + this.map.delete(PREFIX.project + path); + this.map.delete(PREFIX.augmented + path); + } + + async clear(): Promise { + this.map.clear(); + } + + async clearNamespace(namespace: Namespace): Promise { + const prefix = PREFIX[namespace]; + const toDelete: string[] = []; + for (const k of this.map.keys()) { + if (k.startsWith(prefix)) toDelete.push(k); + } + for (const k of toDelete) this.map.delete(k); + } + + async getStats(): Promise<{ + totalKeys: number; + byNamespace: Record; + }> { + const byNamespace: Record = { + raw: 0, + project: 0, + augmented: 0, + consolidated: 0, + meta: 0, + }; + for (const k of this.map.keys()) { + if (k.startsWith(PREFIX.raw)) byNamespace.raw++; + else if (k.startsWith(PREFIX.project)) byNamespace.project++; + else if (k.startsWith(PREFIX.augmented)) byNamespace.augmented++; + else if (k.startsWith(PREFIX.consolidated)) byNamespace.consolidated++; + else if (k.startsWith(META_PREFIX)) byNamespace.meta++; + } + return { totalKeys: this.map.size, byNamespace }; + } + + // --- test helpers (not on real Storage) --- + + /** Direct access to underlying map for invariant checks. */ + __inspect(): ReadonlyMap { + return this.map; + } + + __setVersion(version: string): void { + this.currentVersion = version; + } + + // --- private --- + + private versionOk(rec: { version?: string; schema?: number }): boolean { + return rec.version === this.currentVersion && rec.schema === this.schemaVersion; + } + + private hash(content: any): string { + const str = JSON.stringify(content); + let h = 0; + for (let i = 0; i < str.length; i++) { + h = (h << 5) - h + str.charCodeAt(i); + h = h & h; + } + return Math.abs(h).toString(16); + } +} diff --git a/src/__tests__/migration/MigrationRegistry.test.ts b/src/__tests__/migration/MigrationRegistry.test.ts new file mode 100644 index 00000000..10e92778 --- /dev/null +++ b/src/__tests__/migration/MigrationRegistry.test.ts @@ -0,0 +1,328 @@ +/** + * Phase 0 W1 — MigrationRegistry unit tests. + * + * Covers: + * - ordering by targetVersion + * - version filtering (skip steps with targetVersion <= fromVersion) + * - atomicity (one step throws → settings untouched, error reported) + * - dry-run (no commit) + * - tombstone kind metadata + * - duplicate id rejection + * - _meta.lastMigratedVersion stamping on success + */ + +import { + MigrationRegistry, + compareSemver, + type MigrationStep, +} from "@/utils/migration"; +import type { TaskProgressBarSettings } from "@/common/setting-definition"; + +function makeStep( + id: string, + targetVersion: string, + apply: MigrationStep["apply"], + kind: MigrationStep["kind"] = "transform", +): MigrationStep { + return { + id, + targetVersion, + kind, + description: `synthetic ${id}`, + apply, + }; +} + +// Build a minimal settings object — only the fields the registry / steps touch. +function makeSettings(extras: Partial = {}): TaskProgressBarSettings { + return { + // Required default fields are filled in only as needed; we cast to make + // the type system happy without dragging in DEFAULT_SETTINGS. + ...({} as TaskProgressBarSettings), + ...extras, + }; +} + +describe("compareSemver (W1)", () => { + it("orders major.minor.patch correctly", () => { + expect(compareSemver("1.0.0", "1.0.0")).toBe(0); + expect(compareSemver("1.0.0", "1.0.1")).toBeLessThan(0); + expect(compareSemver("1.0.1", "1.0.0")).toBeGreaterThan(0); + expect(compareSemver("0.9.9", "1.0.0")).toBeLessThan(0); + expect(compareSemver("2.0.0", "1.99.99")).toBeGreaterThan(0); + }); + + it("ignores pre-release suffix", () => { + expect(compareSemver("1.0.0-beta.1", "1.0.0")).toBe(0); + expect(compareSemver("9.14.0-beta.4", "9.14.0")).toBe(0); + }); + + it("treats missing components as zero", () => { + expect(compareSemver("1", "1.0.0")).toBe(0); + expect(compareSemver("1.5", "1.5.0")).toBe(0); + expect(compareSemver("1.5", "1.4.9")).toBeGreaterThan(0); + }); +}); + +describe("MigrationRegistry (W1)", () => { + it("rejects duplicate step ids", () => { + const reg = new MigrationRegistry(); + reg.register(makeStep("dup", "0.0.1", () => ({ changed: false, details: [] }))); + expect(() => + reg.register( + makeStep("dup", "0.0.2", () => ({ changed: false, details: [] })), + ), + ).toThrow(/duplicate step id/); + }); + + it("runs steps in semver order regardless of registration order", async () => { + const reg = new MigrationRegistry(); + const order: string[] = []; + reg.register( + makeStep("c", "0.0.3", () => { + order.push("c"); + return { changed: false, details: [] }; + }), + ); + reg.register( + makeStep("a", "0.0.1", () => { + order.push("a"); + return { changed: false, details: [] }; + }), + ); + reg.register( + makeStep("b", "0.0.2", () => { + order.push("b"); + return { changed: false, details: [] }; + }), + ); + + const settings = makeSettings(); + await reg.run(settings, { fromVersion: "0.0.0", toVersion: "0.0.5" }); + expect(order).toEqual(["a", "b", "c"]); + }); + + it("skips steps whose targetVersion is <= fromVersion", async () => { + const reg = new MigrationRegistry(); + const ran: string[] = []; + reg.register( + makeStep("a", "0.0.1", () => { + ran.push("a"); + return { changed: false, details: [] }; + }), + ); + reg.register( + makeStep("b", "0.0.2", () => { + ran.push("b"); + return { changed: false, details: [] }; + }), + ); + reg.register( + makeStep("c", "0.0.3", () => { + ran.push("c"); + return { changed: false, details: [] }; + }), + ); + + // from=0.0.2 means a (0.0.1) and b (0.0.2) should NOT run, only c (0.0.3). + await reg.run(makeSettings(), { + fromVersion: "0.0.2", + toVersion: "0.0.5", + }); + expect(ran).toEqual(["c"]); + }); + + it("skips steps whose targetVersion exceeds toVersion", async () => { + const reg = new MigrationRegistry(); + const ran: string[] = []; + reg.register( + makeStep("future", "9.9.9", () => { + ran.push("future"); + return { changed: true, details: ["should not run"] }; + }), + ); + reg.register( + makeStep("current", "0.0.5", () => { + ran.push("current"); + return { changed: true, details: ["did run"] }; + }), + ); + + await reg.run(makeSettings(), { + fromVersion: "0.0.0", + toVersion: "1.0.0", + }); + expect(ran).toEqual(["current"]); + }); + + it("commits changes from steps when ok and not dry-run", async () => { + const reg = new MigrationRegistry(); + reg.register( + makeStep("set-name", "0.0.1", (settings: any) => { + settings.testField = "added by migration"; + return { changed: true, details: ["set testField"] }; + }), + ); + + const settings = makeSettings() as any; + expect(settings.testField).toBeUndefined(); + + const result = await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + }); + + expect(result.ok).toBe(true); + expect(result.changed).toBe(true); + expect(settings.testField).toBe("added by migration"); + // _meta should be stamped + expect(settings._meta?.lastMigratedVersion).toBe("0.0.5"); + }); + + it("dryRun does NOT commit changes", async () => { + const reg = new MigrationRegistry(); + reg.register( + makeStep("set-name", "0.0.1", (settings: any) => { + settings.testField = "added by migration"; + return { changed: true, details: ["set testField"] }; + }), + ); + + const settings = makeSettings() as any; + const result = await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + dryRun: true, + }); + + expect(result.ok).toBe(true); + expect(result.changed).toBe(true); + // But the actual settings object is untouched + expect(settings.testField).toBeUndefined(); + expect(settings._meta).toBeUndefined(); + // And the per-step results still report the change + expect(result.results["set-name"].changed).toBe(true); + }); + + it("is atomic: a failing step leaves settings untouched and reports the error", async () => { + const reg = new MigrationRegistry(); + reg.register( + makeStep("ok", "0.0.1", (settings: any) => { + settings.firstField = "ok ran"; + return { changed: true, details: ["set firstField"] }; + }), + ); + reg.register( + makeStep("boom", "0.0.2", () => { + throw new Error("intentional"); + }), + ); + reg.register( + makeStep("never", "0.0.3", (settings: any) => { + settings.shouldNeverRun = true; + return { changed: true, details: [] }; + }), + ); + + const settings = makeSettings() as any; + const result = await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + }); + + expect(result.ok).toBe(false); + expect(result.error?.stepId).toBe("boom"); + expect(result.error?.error.message).toBe("intentional"); + // Original settings untouched — even the successful step's mutation + // did NOT commit, because the run as a whole failed. + expect(settings.firstField).toBeUndefined(); + expect(settings.shouldNeverRun).toBeUndefined(); + expect(settings._meta).toBeUndefined(); + // Executed list reflects what we tried before the failure. + expect(result.executed.map((s) => s.id)).toEqual(["ok"]); + }); + + it("does NOT stamp _meta when no steps changed anything", async () => { + const reg = new MigrationRegistry(); + reg.register( + makeStep("noop", "0.0.1", () => ({ changed: false, details: [] })), + ); + + const settings = makeSettings() as any; + const result = await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + }); + + expect(result.ok).toBe(true); + expect(result.changed).toBe(false); + expect(settings._meta).toBeUndefined(); + }); + + it("preserves tombstone kind metadata in step list and results", async () => { + const reg = new MigrationRegistry(); + const tombstone = makeStep( + "v0.0.2-test-tombstone", + "0.0.2", + (settings: any) => { + if ("legacyField" in settings) { + delete settings.legacyField; + return { + changed: true, + details: ["removed legacyField"], + }; + } + return { changed: false, details: [] }; + }, + "tombstone", + ); + reg.register(tombstone); + + const settings = makeSettings({ ...({ legacyField: 42 } as any) }) as any; + const result = await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + }); + + expect(result.ok).toBe(true); + expect(settings.legacyField).toBeUndefined(); + expect(reg.list().find((s) => s.id === "v0.0.2-test-tombstone")?.kind).toBe( + "tombstone", + ); + }); + + it("supports async apply functions", async () => { + const reg = new MigrationRegistry(); + reg.register( + makeStep("async", "0.0.1", async (settings: any) => { + await new Promise((r) => setTimeout(r, 5)); + settings.asyncRan = true; + return { changed: true, details: ["async ok"] }; + }), + ); + + const settings = makeSettings() as any; + await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + }); + expect(settings.asyncRan).toBe(true); + }); + + it("removes deleted keys from the committed settings (not just adds)", async () => { + const reg = new MigrationRegistry(); + reg.register( + makeStep("delete-key", "0.0.1", (settings: any) => { + delete settings.toBeRemoved; + return { changed: true, details: ["removed toBeRemoved"] }; + }), + ); + + const settings = { toBeRemoved: "x" } as any; + await reg.run(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + }); + expect("toBeRemoved" in settings).toBe(false); + }); +}); diff --git a/src/__tests__/migration/legacy-bundle-0.test.ts b/src/__tests__/migration/legacy-bundle-0.test.ts new file mode 100644 index 00000000..39f89d11 --- /dev/null +++ b/src/__tests__/migration/legacy-bundle-0.test.ts @@ -0,0 +1,216 @@ +/** + * Phase 0 W1 — backward compatibility guarantee for legacy-bundle-0. + * + * The legacy bundle wraps three migration paths (multi-cycle, inheritance, + * fluent defaults). For Phase 0 to ship with zero observable behavior change, + * running the bundle must produce a settings object indistinguishable from + * running the original three paths directly. + * + * These tests parameterize over realistic settings shapes and assert deep + * equality between: + * (a) bundle output: clone settings → applyLegacyBundle → result + * (b) direct output: clone settings → migrateToMultiCycle + manual fluent + * defaults + manual inheritance migration → result + * + * Plus a smoke test that the registry-driven path produces the same final + * shape (registry overhead doesn't perturb the data). + */ + +import { applyLegacyBundle } from "@/utils/migration/steps/legacy-bundle-0"; +import { sentinelTombstoneStep } from "@/utils/migration/steps/tombstone-0.0.2-sentinel"; +import { createMigrationRegistry } from "@/utils/migration"; +import { migrateToMultiCycle } from "@/utils/settings-migration"; +import type { TaskProgressBarSettings } from "@/common/setting-definition"; + +function clone(v: T): T { + return JSON.parse(JSON.stringify(v)); +} + +// Minimal direct-path simulation: walk the same logic the legacy bundle wraps, +// without using the bundle. Used as the source of truth for backward-compat. +function applyDirectPath( + settings: TaskProgressBarSettings, + savedData?: any, +): TaskProgressBarSettings { + migrateToMultiCycle(settings); + + // Inheritance migration (mirrors src/index.ts:2000-2028) + const sourceConfig = savedData?.projectConfig?.metadataConfig + ? savedData.projectConfig.metadataConfig + : (settings as any)?.projectConfig?.metadataConfig; + if (sourceConfig && !settings.fileMetadataInheritance) { + settings.fileMetadataInheritance = { + enabled: true, + inheritFromFrontmatter: + sourceConfig.inheritFromFrontmatter ?? true, + inheritFromFrontmatterForSubtasks: + sourceConfig.inheritFromFrontmatterForSubtasks ?? false, + }; + if (settings.projectConfig?.metadataConfig) { + delete (settings.projectConfig.metadataConfig as any) + .inheritFromFrontmatter; + delete (settings.projectConfig.metadataConfig as any) + .inheritFromFrontmatterForSubtasks; + } + } + + // Fluent migration (mirrors FluentIntegration.ts:176-205) + if (!settings.fluentView) { + settings.fluentView = { enableFluent: false } as any; + } + if (!settings.fluentView!.workspaces) { + settings.fluentView!.workspaces = [ + { id: "default", name: "Default", color: "#3498db" }, + ] as any; + } + if ((settings.fluentView as any).fluentConfig === undefined) { + (settings.fluentView as any).fluentConfig = { + enableWorkspaces: true, + defaultWorkspace: "default", + maxOtherViewsBeforeOverflow: 5, + }; + } + if ((settings.fluentView as any).useWorkspaceSideLeaves === undefined) { + (settings.fluentView as any).useWorkspaceSideLeaves = false; + } + + return settings; +} + +// --- Realistic fixtures --- + +// Fixture A: user with legacy multi-cycle config and no fluent settings +const fixtureA = { + taskStatusCycle: ["Not Started", "In Progress", "Completed"], + taskStatusMarks: { "Not Started": " ", "In Progress": "/", Completed: "x" }, +} as any as TaskProgressBarSettings; + +// Fixture B: user with old projectConfig.metadataConfig inheritance +const fixtureB = { + projectConfig: { + metadataConfig: { + inheritFromFrontmatter: true, + inheritFromFrontmatterForSubtasks: true, + }, + }, + statusCycles: [ + { + id: "default", + name: "Default", + priority: 0, + cycle: ["Todo", "Done"], + marks: { Todo: " ", Done: "x" }, + enabled: true, + }, + ], +} as any as TaskProgressBarSettings; + +// Fixture C: fresh install — empty everything +const fixtureC = {} as TaskProgressBarSettings; + +// Fixture D: user with fluentView already partially configured (real-world +// case where someone enabled fluent but never touched workspaces) +const fixtureD = { + fluentView: { enableFluent: true }, +} as any as TaskProgressBarSettings; + +describe("legacy-bundle-0 backward compatibility (W1)", () => { + const cases: Array<[string, TaskProgressBarSettings, any?]> = [ + ["A: legacy multi-cycle", fixtureA, undefined], + ["B: legacy inheritance + new statusCycles", fixtureB, undefined], + ["C: fresh install", fixtureC, undefined], + ["D: partial fluentView only", fixtureD, undefined], + ]; + + for (const [label, fixture] of cases) { + it(`bundle output matches direct path: ${label}`, () => { + const viaBundle = clone(fixture); + applyLegacyBundle(viaBundle); + + const viaDirect = clone(fixture); + applyDirectPath(viaDirect); + + expect(viaBundle).toEqual(viaDirect); + }); + } + + it("registry path produces the same shape as the direct bundle call", async () => { + // Pick one fixture for this end-to-end run. + const reg = createMigrationRegistry(); + + const viaRegistry = clone(fixtureA); + const result = await reg.run(viaRegistry, { toVersion: "1.0.0" }); + expect(result.ok).toBe(true); + + const viaBundle = clone(fixtureA); + applyLegacyBundle(viaBundle); + + // Strip the _meta stamp the registry adds — direct path doesn't stamp it. + const meta = viaRegistry._meta; + delete viaRegistry._meta; + expect(viaRegistry).toEqual(viaBundle); + + // Verify the stamp went on + expect(meta?.lastMigratedVersion).toBe("1.0.0"); + }); + + it("bundle is idempotent: running it twice produces the same shape", () => { + const settings = clone(fixtureA); + applyLegacyBundle(settings); + const afterFirst = clone(settings); + applyLegacyBundle(settings); + expect(settings).toEqual(afterFirst); + }); +}); + +describe("sentinel tombstone (W1)", () => { + it("removes _meta._sentinelMarker when present and reports change", () => { + const settings = { + _meta: { _sentinelMarker: "test" }, + } as any as TaskProgressBarSettings; + const result = sentinelTombstoneStep.apply(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + dryRun: false, + log: () => {}, + }) as any; + expect(result.changed).toBe(true); + expect((settings._meta as any)?._sentinelMarker).toBeUndefined(); + }); + + it("is a no-op when _meta._sentinelMarker is absent", () => { + const settings = { _meta: {} } as any as TaskProgressBarSettings; + const result = sentinelTombstoneStep.apply(settings, { + fromVersion: "0.0.0", + toVersion: "0.0.5", + dryRun: false, + log: () => {}, + }) as any; + expect(result.changed).toBe(false); + }); + + it("kind is 'tombstone'", () => { + expect(sentinelTombstoneStep.kind).toBe("tombstone"); + }); + + it("registered in createMigrationRegistry()", () => { + const reg = createMigrationRegistry(); + expect( + reg.list().find((s) => s.id === sentinelTombstoneStep.id), + ).toBeDefined(); + }); + + it("runs end-to-end via the registry: removes the marker", async () => { + const reg = createMigrationRegistry(); + const settings = { + _meta: { _sentinelMarker: "test" }, + } as any as TaskProgressBarSettings; + + const result = await reg.run(settings, { toVersion: "1.0.0" }); + expect(result.ok).toBe(true); + // The marker is gone + expect((settings._meta as any)?._sentinelMarker).toBeUndefined(); + // And the migration version was stamped + expect(settings._meta?.lastMigratedVersion).toBe("1.0.0"); + }); +}); diff --git a/src/cache/local-storage-cache.ts b/src/cache/local-storage-cache.ts index 98439aff..621b93c1 100644 --- a/src/cache/local-storage-cache.ts +++ b/src/cache/local-storage-cache.ts @@ -87,6 +87,13 @@ export class LocalStorageCache { * Load metadata for a file from cache * @param path File path to load * @returns Cached data or null if not found + * + * Phase 0 W4b: validates the wrapper's version against the current plugin + * version. Prior to this fix, `storeFile` was tagging entries with + * `currentVersion` but `loadFile` never checked it, so plugin upgrades did + * NOT auto-invalidate stale cache entries — the inner Storage records had + * their own version validation, but the outer wrapper layer's was dead + * code. On mismatch we now treat the entry as a cache miss and remove it. */ public async loadFile(path: string): Promise | null> { if (!this.initialized) await this.initialize(); @@ -94,6 +101,24 @@ export class LocalStorageCache { try { const key = this.fileKey(path); const data = await this.persister.getItem>(key); + if (data && data.version && data.version !== this.currentVersion) { + // Version mismatch — log once per session via _versionMismatchLogged + // to keep noise down on first load after an upgrade with thousands + // of cache entries. + if (!LocalStorageCache._versionMismatchLogged) { + console.log( + `[LocalStorageCache] Cache version mismatch (cached=${data.version}, current=${this.currentVersion}); treating as cache miss and pruning stale entries on access.`, + ); + LocalStorageCache._versionMismatchLogged = true; + } + // Prune the stale entry so we don't keep paying the read cost. + try { + await this.persister.removeItem(key); + } catch { + /* best effort */ + } + return null; + } return data; } catch (error) { console.error(`Error loading cache for ${path}:`, error); @@ -101,6 +126,9 @@ export class LocalStorageCache { } } + /** Process-wide flag so we only log version mismatch once per session. */ + private static _versionMismatchLogged = false; + /** * Store metadata for a file in cache * @param path File path to store diff --git a/src/common/setting-definition.ts b/src/common/setting-definition.ts index bfeb673b..95ee75fe 100644 --- a/src/common/setting-definition.ts +++ b/src/common/setting-definition.ts @@ -989,6 +989,13 @@ export interface TaskProgressBarSettings { // Custom Calendar Views Settings customCalendarViews?: CustomCalendarViewConfig[]; + + // Migration metadata. Written by the MigrationRegistry on successful run. + // Phase 0 W1 — used to gate version-keyed migration steps. + _meta?: { + /** Plugin version of the last successful migration run, semver. */ + lastMigratedVersion?: string; + }; } /** Define the default settings */ diff --git a/src/dataflow/Orchestrator.ts b/src/dataflow/Orchestrator.ts index 0caca624..e95c84bc 100644 --- a/src/dataflow/Orchestrator.ts +++ b/src/dataflow/Orchestrator.ts @@ -24,6 +24,7 @@ import { ConfigurableTaskParser } from "./core/ConfigurableTaskParser"; import { MetadataParseMode } from "../types/TaskParserConfig"; import { TimeParsingService } from "../services/time-parsing-service"; import type { EnhancedTimeParsingConfig } from "../types/time-parsing"; +import { scopesForFields } from "./cache/scope-map"; /** * DataflowOrchestrator - Coordinates all dataflow components @@ -1699,40 +1700,76 @@ export class DataflowOrchestrator { } /** - * Clear all data and rebuild + * Clear all data and rebuild. + * + * If rebuild fails partway through (e.g. a worker crash, vault read error, + * or persistence failure), we don't want to leave the cache in a partial + * state — the next plugin load would think it's loading a complete index + * but actually be missing files. As a last-resort, on any thrown error we + * clear every cache namespace so the next load triggers a clean rebuild + * from disk. The error is re-thrown so the caller can react. + * + * This is W2-bis in the v10 Phase 0 plan. It's intentionally minimal — + * snapshot/restore is deferred to a future phase. */ async rebuild(): Promise { - // Clear all data - await this.repository.clear(); + try { + // Clear all data + await this.repository.clear(); - // Process all markdown and canvas files - const files = this.vault.getMarkdownFiles(); - const canvasFiles = this.vault - .getFiles() - .filter((f) => f.extension === "canvas"); + // Process all markdown and canvas files + const files = this.vault.getMarkdownFiles(); + const canvasFiles = this.vault + .getFiles() + .filter((f) => f.extension === "canvas"); - const allFiles = [...files, ...canvasFiles]; + const allFiles = [...files, ...canvasFiles]; - // Process in batches for performance - const BATCH_SIZE = 50; - for (let i = 0; i < allFiles.length; i += BATCH_SIZE) { - const batch = allFiles.slice(i, i + BATCH_SIZE); - await this.processBatch(batch); + // Process in batches for performance + const BATCH_SIZE = 50; + for (let i = 0; i < allFiles.length; i += BATCH_SIZE) { + const batch = allFiles.slice(i, i + BATCH_SIZE); + await this.processBatch(batch); + } + + // Persist the rebuilt index + await this.repository.persist(); + + // Emit ready event + emit(this.app, Events.CACHE_READY, { + initial: false, + timestamp: Date.now(), + seq: Seq.next(), + }); + } catch (err) { + console.error( + "[DataflowOrchestrator] Rebuild failed; clearing caches to force a clean rebuild on next load:", + err, + ); + // Last-resort cleanup: clear every namespace so the next plugin load + // can't read a partial cache that looks complete. + try { + await this.storage.clearNamespace("raw"); + await this.storage.clearNamespace("augmented"); + await this.storage.clearNamespace("project"); + await this.storage.clearNamespace("consolidated"); + } catch (clearErr) { + console.error( + "[DataflowOrchestrator] Last-resort cache clear ALSO failed; cache may be in inconsistent state:", + clearErr, + ); + } + throw err; } - - // Persist the rebuilt index - await this.repository.persist(); - - // Emit ready event - emit(this.app, Events.CACHE_READY, { - initial: false, - timestamp: Date.now(), - seq: Seq.next(), - }); } /** - * Handle settings change + * Handle settings change. + * + * Pass the cache scopes that the changed settings touch. Existing call + * sites pass scope strings directly; new call sites should prefer + * `onSettingsFieldsChanged()` (W4a) which derives scopes from a typed + * field map. */ async onSettingsChange(scopes: string[]): Promise { // Clear relevant caches based on scope @@ -1762,6 +1799,34 @@ export class DataflowOrchestrator { } } + /** + * Handle settings change by typed field path. Resolves the field paths to + * cache scopes via `SETTINGS_FIELD_TO_SCOPES` and delegates to the + * stringly-typed `onSettingsChange`. + * + * Phase 0 W4a — adds the typed entry point. Phase 1+ migrates existing + * call sites away from raw scope strings as features are touched. The + * primary value is centralizing "which fields invalidate which scopes" + * so the migration can be mechanical. + * + * Field paths are dot-separated (e.g. "fileMetadataInheritance.inheritFromFrontmatter"). + * Unknown fields contribute no scopes (no cache impact). + */ + async onSettingsFieldsChanged(fields: readonly string[]): Promise { + const scopes = scopesForFields(fields); + if (scopes.length === 0) { + // Nothing cache-affecting changed; still emit the change event so + // observers that don't care about scopes (e.g. UI refresh) can react. + emit(this.app, Events.SETTINGS_CHANGED, { + scopes: [], + fields, + timestamp: Date.now(), + }); + return; + } + await this.onSettingsChange(scopes); + } + /** * Update project configuration options */ diff --git a/src/dataflow/cache/invariants.ts b/src/dataflow/cache/invariants.ts new file mode 100644 index 00000000..96b8e77a --- /dev/null +++ b/src/dataflow/cache/invariants.ts @@ -0,0 +1,217 @@ +/** + * Phase 0 W4b — Cache invariants checker. + * + * A debug-mode safety net that walks the cache namespaces and asserts the + * relationships the dataflow pipeline assumes are true. Used by: + * 1. Integration tests, to fail loudly if a refactor breaks an invariant. + * 2. The hidden `Task Genius (debug): Check cache invariants` command, + * gated on `(globalThis as any).__taskGeniusDebug === true`. + * + * What "cache" means here + * ----------------------- + * The Storage layer (src/dataflow/persistence/Storage.ts) maintains four + * namespaces in LocalStorageCache: + * - raw — parsed Task[] per file, pre-augmentation + * - project — project metadata per file + * - augmented — Task[] per file, post-inheritance + * - consolidated — single TaskCache holding the merged view + * + * The pipeline goes: parse → raw → augment → augmented → index → consolidated. + * If any of these layers drift apart (e.g. a file has raw entries but no + * augmented ones), queries return inconsistent results. + * + * Invariants checked + * ------------------ + * I1. Every entry in `raw` has a corresponding entry in `augmented`. + * (Augmentation should run after parsing; missing augmented = lost data.) + * + * I2. Every cached entry's inner version field matches the plugin's current + * version. (Storage records carry a `version` field for invalidation.) + * + * I3. The number of files in the in-memory TaskIndexer matches the number of + * files in `augmented` (within tolerance — the indexer can include files + * loaded from the consolidated namespace that haven't been re-parsed yet). + * + * I4. Every namespace returned by `storage.getStats()` has a non-negative + * count and the totals add up. (Sanity check; mostly catches counting + * bugs in Storage itself.) + * + * Phase 0 intentionally does NOT check: + * - "every task in the index has a backing entry in consolidated" — the + * consolidated namespace is one big record, not per-file, so this would + * require deserializing the whole thing. Defer to a future phase. + * - Project resolver in-memory vs storage `project` consistency — the + * resolver's cache is keyed differently than the storage namespace, so + * a clean mapping needs more design. Defer. + * + * The checker NEVER throws — it returns a report with `ok` and a list of + * violations. Callers decide whether to log, fail a test, or surface to UI. + */ + +import type { DataflowOrchestrator } from "../Orchestrator"; + +export interface CacheInvariantViolation { + id: string; + message: string; +} + +export interface CacheInvariantReport { + ok: boolean; + violations: CacheInvariantViolation[]; + stats: { + rawCount: number; + augmentedCount: number; + // Number of paths present in raw but missing in augmented (I1). + missingAugmented: number; + // Number of cache entries with version mismatch (I2). + versionMismatches: number; + }; +} + +/** + * Walk the orchestrator's storage and report any invariant violations. + * + * Reaches into private state via `as any` because Storage is not part of the + * orchestrator's public API. The checker is debug-only — production code paths + * never invoke it. Keep it independent of the public API surface so it can be + * deleted or refactored without churning consumers. + */ +export async function checkCacheInvariants( + orch: DataflowOrchestrator, +): Promise { + const violations: CacheInvariantViolation[] = []; + const stats = { + rawCount: 0, + augmentedCount: 0, + missingAugmented: 0, + versionMismatches: 0, + }; + + const storage: any = (orch as any).storage; + if (!storage) { + violations.push({ + id: "no-storage", + message: + "Orchestrator has no storage instance; nothing to check (this is normal pre-initialize)", + }); + return { ok: false, violations, stats }; + } + + let rawPaths: string[] = []; + let augPaths: string[] = []; + + try { + rawPaths = await storage.listRawPaths(); + } catch (e) { + violations.push({ + id: "list-raw-failed", + message: `storage.listRawPaths() threw: ${e}`, + }); + } + + try { + augPaths = await storage.listAugmentedPaths(); + } catch (e) { + violations.push({ + id: "list-augmented-failed", + message: `storage.listAugmentedPaths() threw: ${e}`, + }); + } + + stats.rawCount = rawPaths.length; + stats.augmentedCount = augPaths.length; + + // I1: every raw entry has an augmented counterpart + const augSet = new Set(augPaths); + for (const rawPath of rawPaths) { + if (!augSet.has(rawPath)) { + stats.missingAugmented++; + violations.push({ + id: "I1-missing-augmented", + message: `raw entry has no augmented counterpart: ${rawPath}`, + }); + } + } + + // I2: every cached record carries a current version + const currentVersion: string | undefined = storage.currentVersion; + if (currentVersion) { + // Sample up to N entries to keep this O(reasonable). The full walk is + // only useful in tests; in production we just want a smoke check. + const maxToCheck = 64; + const toCheck = rawPaths.slice(0, maxToCheck); + for (const path of toCheck) { + try { + const rec = await storage.loadRaw(path); + if (rec && rec.version && rec.version !== currentVersion) { + stats.versionMismatches++; + violations.push({ + id: "I2-version-mismatch", + message: `raw record version mismatch for ${path}: cached=${rec.version} current=${currentVersion}`, + }); + } + } catch { + /* loadRaw already logs; just skip */ + } + } + } + + // I3: indexer file count vs augmented count (with tolerance) + const repository: any = (orch as any).repository; + if (repository && typeof repository.getIndexedFilePaths === "function") { + try { + const indexedPaths: string[] = + await repository.getIndexedFilePaths(); + const indexedSet = new Set(indexedPaths); + // Files in augmented that the in-memory index doesn't know about + // is a sign of stale storage that nobody's loading. + for (const augPath of augPaths) { + if (!indexedSet.has(augPath)) { + violations.push({ + id: "I3-index-augmented-drift", + message: `augmented entry not present in in-memory index: ${augPath}`, + }); + } + } + } catch (e) { + violations.push({ + id: "I3-index-walk-failed", + message: `repository.getIndexedFilePaths() threw: ${e}`, + }); + } + } + + // I4: getStats sanity + try { + const sgs = await storage.getStats(); + for (const [ns, count] of Object.entries(sgs.byNamespace ?? {})) { + if (typeof count !== "number" || count < 0) { + violations.push({ + id: "I4-bad-namespace-count", + message: `storage.getStats().byNamespace[${ns}] is not a non-negative number: ${count}`, + }); + } + } + const sum: number = Object.values(sgs.byNamespace ?? {}).reduce( + (a, b) => a + (typeof b === "number" ? b : 0), + 0, + ); + if (sum > sgs.totalKeys) { + violations.push({ + id: "I4-namespace-sum-overflow", + message: `sum of byNamespace counts (${sum}) exceeds totalKeys (${sgs.totalKeys})`, + }); + } + } catch (e) { + violations.push({ + id: "I4-getstats-failed", + message: `storage.getStats() threw: ${e}`, + }); + } + + return { + ok: violations.length === 0, + violations, + stats, + }; +} diff --git a/src/dataflow/cache/scope-map.ts b/src/dataflow/cache/scope-map.ts new file mode 100644 index 00000000..76f04fca --- /dev/null +++ b/src/dataflow/cache/scope-map.ts @@ -0,0 +1,150 @@ +/** + * Typed cache scope map. + * + * Phase 0 W4a — Background + * ------------------------ + * `Orchestrator.onSettingsChange(scopes: string[])` takes a free-form list of + * scope names and uses them to decide which cache namespaces to invalidate. + * Today, every caller has to: + * 1. know which scope name applies to the field they're touching + * 2. spell it correctly (no type checking) + * 3. remember to update its scopes if the field's caching impact changes + * + * That works for one or two callers but breaks down when settings consolidation + * begins in Phase 1 (25 tabs → 7) and field paths shift around. This file gives + * us a single source of truth for "which fields invalidate which cache scopes" + * so the migration can be mechanical instead of error-prone. + * + * Usage + * ----- + * Phase 0 ships this map and a `scopesForFields()` helper, but does NOT migrate + * any existing callers. The new sibling method `Orchestrator.onSettingsFieldsChanged` + * (added separately) wraps the existing onSettingsChange — Phase 1+ will switch + * call sites over progressively as features are touched. + * + * The keys are dot-separated field paths into `TaskProgressBarSettings`. We + * intentionally use string keys instead of `keyof` because (a) many fields + * are nested objects whose entire subtree is one cache concern, and (b) Phase 1 + * will rename and consolidate these fields, so a stringly-typed map is more + * resilient to refactoring than a structural type that fights every move. + * + * Discovery + * --------- + * The current set of mappings was derived by walking every existing caller of + * `Orchestrator.onSettingsChange` (only 3 sites at the time of writing, all in + * `IndexSettingsTab.ts`). Note: those callers currently pass `["parser"]` for + * `fileMetadataInheritance` changes which is technically wrong — file metadata + * inheritance is an augment-time concern. The map below reflects the CORRECT + * mapping; Phase 1's migration will fix the call sites. + */ + +/** The four cache namespaces the Orchestrator manages. */ +export type CacheScope = "parser" | "augment" | "project" | "index"; + +/** + * Field path → list of cache scopes that need invalidation when that field changes. + * + * Field paths are dot-separated (e.g. "fileSource.recognitionStrategies"). A path + * matches both itself and any descendant — so a change to + * "fileMetadataInheritance.inheritFromFrontmatter" matches the entry for + * "fileMetadataInheritance". + * + * If a field is not in this map, it has no cache impact. + */ +export const SETTINGS_FIELD_TO_SCOPES: Readonly< + Record +> = Object.freeze({ + // --- Parser scope --- + // Anything that changes how raw markdown / canvas / file-metadata is parsed + // into Task records. Invalidates the "raw" cache namespace. + taskStatuses: ["parser"], + preferMetadataFormat: ["parser"], + enableCustomDateFormats: ["parser"], + customDateFormats: ["parser"], + projectTagPrefix: ["parser"], + contextTagPrefix: ["parser"], + areaTagPrefix: ["parser"], + useDailyNotePathAsDate: ["parser"], + dailyNoteFormat: ["parser"], + useAsDateType: ["parser"], + dailyNotePath: ["parser"], + ignoreHeading: ["parser"], + focusHeading: ["parser"], + "fileSource.recognitionStrategies": ["parser"], + "fileSource.fileTaskProperties": ["parser"], + "fileSource.relationships": ["parser"], + + // --- Augment scope --- + // Anything that changes how parsed tasks are merged with file/project metadata. + // Invalidates the "augmented" cache namespace. + fileMetadataInheritance: ["augment"], + + // --- Project scope --- + // Anything that changes project detection or project metadata enrichment. + // Invalidates "project" + "augment" namespaces (project changes ripple + // through augmentation). + projectConfig: ["project", "augment"], + + // --- Index scope --- + // Anything that changes the consolidated index shape itself. Rare — most + // settings are parser/augment concerns. Currently no fields use this. + // Reserved for future use; left here as documentation. +}); + +/** + * Resolve a list of changed field paths into the minimal set of cache scopes + * that need invalidation. Unknown fields contribute no scopes (no cache impact). + * + * Field path matching is prefix-based on dotted segments: a change to + * "fileMetadataInheritance.inheritFromFrontmatter" matches the entry for + * "fileMetadataInheritance" (because "fileMetadataInheritance" is a prefix + * along a `.` boundary). This lets callers pass either the parent or any + * leaf without needing to know the granularity of the map. + */ +export function scopesForFields( + fields: readonly string[], +): CacheScope[] { + const out = new Set(); + for (const field of fields) { + const matchedScopes = lookupField(field); + for (const s of matchedScopes) out.add(s); + } + return [...out]; +} + +/** + * Look up the cache scopes for a single field path. + * + * Returns the scopes for the longest matching prefix in the map. For example: + * - "fileMetadataInheritance.inheritFromFrontmatter" → ["augment"] + * (matches "fileMetadataInheritance") + * - "fileSource.recognitionStrategies.tags.taskTags" → ["parser"] + * (matches "fileSource.recognitionStrategies") + * - "fileSource.somethingElse" → [] + * (no entry, falls through) + */ +function lookupField(field: string): readonly CacheScope[] { + // Direct hit + if (field in SETTINGS_FIELD_TO_SCOPES) { + return SETTINGS_FIELD_TO_SCOPES[field]; + } + // Walk parents (drop one segment at a time from the right) + let current = field; + while (true) { + const lastDot = current.lastIndexOf("."); + if (lastDot === -1) return []; + current = current.substring(0, lastDot); + if (current in SETTINGS_FIELD_TO_SCOPES) { + return SETTINGS_FIELD_TO_SCOPES[current]; + } + } +} + +/** + * Test-only helper: enumerate every field path registered in the map. Used by + * scope-map.test.ts to assert all known caller fields are covered without + * having to import the test fixture. + */ +export function listMappedFields(): readonly string[] { + return Object.keys(SETTINGS_FIELD_TO_SCOPES); +} diff --git a/src/dataflow/indexer/Repository.ts b/src/dataflow/indexer/Repository.ts index ed9ab3d6..42ba6b98 100644 --- a/src/dataflow/indexer/Repository.ts +++ b/src/dataflow/indexer/Repository.ts @@ -562,11 +562,27 @@ export class Repository { } /** - * Cleanup and ensure all pending data is persisted + * Cleanup and ensure all pending data is persisted. + * + * IMPORTANT: also unloads the underlying TaskIndexer (a Component) so its + * vault listeners (modify/delete/create) are deregistered. Prior to this, + * those listeners leaked across plugin reloads — TaskIndexer was constructed + * eagerly in the Repository constructor but never explicitly unloaded. + * See W2 in the v10 Phase 0 plan. */ async cleanup(): Promise { // Execute any pending persist operations await this.executePersist(); + + // Tear down the indexer's Component lifecycle so its registered events + // (vault.on modify/delete/create in TaskIndexer.setupEventListeners) + // are released. The Component base class' unload() walks _events and + // calls offref on each. + try { + (this.indexer as any).unload?.(); + } catch (error) { + console.error("[Repository] Error unloading TaskIndexer:", error); + } } /** diff --git a/src/dataflow/workers/TaskWorkerManager.ts b/src/dataflow/workers/TaskWorkerManager.ts index 9ad47d2d..70f2f7c1 100644 --- a/src/dataflow/workers/TaskWorkerManager.ts +++ b/src/dataflow/workers/TaskWorkerManager.ts @@ -28,6 +28,7 @@ import { // @ts-ignore Ignore type error for worker import import TaskWorker from "./TaskIndex.worker"; import { Deferred, deferred } from "./deferred-promise"; +import { WorkerTimeoutError } from "./errors"; // Using similar queue structure as importer.ts import { Queue } from "@datastructures-js/queue"; @@ -42,6 +43,14 @@ export interface WorkerPoolOptions { cpuUtilization: number; /** Whether to enable debug logging */ debug?: boolean; + /** + * Per-task timeout in milliseconds. If a worker doesn't post a response + * within this window the worker is terminated, the active task's promise + * is rejected with WorkerTimeoutError, and a replacement worker is spawned. + * Default: 8000 (8s) — enough headroom for a worst-case 200KB markdown + * parse on a slow desktop. See W3 in v10 Phase 0 plan. + */ + workerTimeoutMs?: number; /** Settings for the task indexer */ settings?: { preferMetadataFormat: "dataview" | "tasks"; @@ -62,6 +71,14 @@ export interface WorkerPoolOptions { }; } +/** + * Default per-task timeout in ms for the task worker pool. A debug override + * can be supplied via `(globalThis as any).__taskGeniusDebug?.workerTimeoutMs` + * which the constructor reads at boot — used by integration tests to shrink + * the timeout to a couple hundred ms. + */ +export const DEFAULT_TASK_WORKER_TIMEOUT_MS = 8000; + /** * Default worker pool options */ @@ -103,6 +120,12 @@ interface PoolWorker { availableAt: number; /** The active task this worker is processing, if any */ active?: [TFile, Deferred, number, TaskPriority]; + /** + * Per-task timeout handle. Set when a task is dispatched to this worker + * (in schedule()), cleared when finish() runs, fires onTaskTimeout() if + * the worker doesn't respond within workerTimeoutMs. See W3 in plan. + */ + timeoutHandle?: ReturnType; } /** @@ -169,6 +192,10 @@ export class TaskWorkerManager extends Component { private initialized: boolean = false; /** Reference to task indexer for cache checking */ private taskIndexer?: any; + /** Effective per-task timeout in ms (resolved from options + debug override) */ + private workerTimeoutMs: number; + /** Count of timed-out tasks. Exposed via getStats() and the orchestrator metrics. */ + private timeoutCount: number = 0; /** Performance statistics */ private stats = { filesSkipped: 0, @@ -189,10 +216,36 @@ export class TaskWorkerManager extends Component { this.vault = vault; this.metadataCache = metadataCache; + // Resolve effective per-task timeout. Precedence: + // 1. options.workerTimeoutMs (explicit) + // 2. debug override on globalThis (for tests) + // 3. DEFAULT_TASK_WORKER_TIMEOUT_MS + const debugOverride = + (globalThis as any).__taskGeniusDebug?.workerTimeoutMs; + this.workerTimeoutMs = + this.options.workerTimeoutMs ?? + (typeof debugOverride === "number" ? debugOverride : undefined) ?? + DEFAULT_TASK_WORKER_TIMEOUT_MS; + // Initialize workers up to max this.initializeWorkers(); } + /** + * Get the count of timed-out tasks since the last reset. + * Used by WorkerOrchestrator metrics. + */ + public getTimeoutCount(): number { + return this.timeoutCount; + } + + /** + * Reset the timeout counter (e.g. when metrics are reset). + */ + public resetTimeoutCount(): void { + this.timeoutCount = 0; + } + /** * Set file parsing configuration */ @@ -689,6 +742,14 @@ export class TaskWorkerManager extends Component { const {file, promise, priority} = queueItem; worker.active = [file, promise, 0, priority]; // 0 表示重试次数 + // Arm a per-task timeout. If the worker doesn't respond within + // workerTimeoutMs, onTaskTimeout will terminate it, reject the promise + // with WorkerTimeoutError, spawn a replacement, and continue scheduling. + // Cleared in finish() and on direct terminate(). + worker.timeoutHandle = setTimeout( + () => this.onTaskTimeout(worker), + this.workerTimeoutMs, + ); try { this.getTaskMetadata(file) @@ -722,6 +783,10 @@ export class TaskWorkerManager extends Component { }) .catch((error) => { console.error(`Error reading file ${file.path}:`, error); + if (worker.timeoutHandle) { + clearTimeout(worker.timeoutHandle); + worker.timeoutHandle = undefined; + } promise.reject(error); worker.active = undefined; @@ -733,6 +798,10 @@ export class TaskWorkerManager extends Component { }); } catch (error) { console.error(`Error processing file ${file.path}:`, error); + if (worker.timeoutHandle) { + clearTimeout(worker.timeoutHandle); + worker.timeoutHandle = undefined; + } promise.reject(error); worker.active = undefined; @@ -751,6 +820,14 @@ export class TaskWorkerManager extends Component { worker: PoolWorker, data: IndexerResult ): Promise { + // Cancel the timeout before doing any work — the worker did respond, + // so we no longer want to consider it hung. Even if the response is + // itself an error, the timeout path is no longer correct here. + if (worker.timeoutHandle) { + clearTimeout(worker.timeoutHandle); + worker.timeoutHandle = undefined; + } + if (!worker.active) { console.log("Received a stale worker message. Ignoring.", data); return; @@ -881,6 +958,13 @@ export class TaskWorkerManager extends Component { * Terminate a worker */ private terminate(worker: PoolWorker): void { + // Always clear the timeout — orphaned timers are exactly what plugin + // reload races complain about. + if (worker.timeoutHandle) { + clearTimeout(worker.timeoutHandle); + worker.timeoutHandle = undefined; + } + worker.worker.terminate(); if (worker.active) { @@ -891,6 +975,68 @@ export class TaskWorkerManager extends Component { this.log(`Terminated worker #${worker.id}`); } + /** + * Called when a worker doesn't respond within workerTimeoutMs. + * + * Strategy: kill the worker, reject the active promise with + * WorkerTimeoutError so the WorkerOrchestrator can fall back to main-thread + * processing for this file, then spawn a replacement worker so the pool + * stays warm. Without termination, a hung worker would hold its slot and + * starve the queue until the existing 30s circuit breaker tripped. + */ + private onTaskTimeout(worker: PoolWorker): void { + // Defensive: if the timeout fires after finish() already cleared the + // handle, do nothing. + if (!worker.active) return; + + const [file, promise] = worker.active; + console.warn( + `[TaskWorkerManager] Worker #${worker.id} timed out on ${file.path} after ${this.workerTimeoutMs}ms; terminating and replacing.`, + ); + + this.timeoutCount++; + + // Terminate the hung worker. Note: terminate() will also clear the + // handle (no-op here since we just fired) and reject worker.active[1] + // with "Terminated", but we want a more specific error, so we reject + // here first then null out worker.active before terminate runs. + try { + worker.worker.terminate(); + } catch (e) { + console.error("[TaskWorkerManager] Error terminating worker:", e); + } + + // Remove from pool + this.workers.delete(worker.id); + + // Reject with the typed error so the orchestrator can distinguish + // timeouts from other failures. + promise.reject(new WorkerTimeoutError(file.path, this.workerTimeoutMs)); + this.outstanding.delete(file.path); + worker.active = undefined; + worker.timeoutHandle = undefined; + + // Keep the pool warm: if we're below capacity (we just dropped one), + // spawn a replacement so subsequent tasks aren't starved. + if (this.active && this.workers.size < this.options.maxWorkers) { + try { + const replacement = this.newWorker(); + this.workers.set(replacement.id, replacement); + this.log( + `Spawned replacement worker #${replacement.id} after timeout`, + ); + } catch (e) { + console.error( + "[TaskWorkerManager] Failed to spawn replacement worker after timeout:", + e, + ); + } + } + + // Drain the queue with whatever workers remain. + this.schedule(); + } + /** * Clean up existing workers without affecting the active state */ diff --git a/src/dataflow/workers/WorkerOrchestrator.ts b/src/dataflow/workers/WorkerOrchestrator.ts index 8e3e5380..f5d536db 100644 --- a/src/dataflow/workers/WorkerOrchestrator.ts +++ b/src/dataflow/workers/WorkerOrchestrator.ts @@ -5,6 +5,7 @@ import { TaskWorkerManager, DEFAULT_WORKER_OPTIONS } from "./TaskWorkerManager"; import { ProjectDataWorkerManager } from "./ProjectDataWorkerManager"; import { MetadataParseMode } from "../../types/TaskParserConfig"; import { ConfigurableTaskParser } from "@/dataflow/core/ConfigurableTaskParser"; +import { WorkerTimeoutError } from "./errors"; /** * WorkerOrchestrator - Unified task and project worker management @@ -24,6 +25,9 @@ export class WorkerOrchestrator { private metrics = { taskParsingSuccess: 0, taskParsingFailures: 0, + // Subset of taskParsingFailures: hung-worker timeouts (W3). Tracked + // separately so we can tell "worker hung" from "worker errored". + taskWorkerTimeouts: 0, projectDataSuccess: 0, projectDataFailures: 0, averageTaskParsingTime: 0, @@ -87,8 +91,12 @@ export class WorkerOrchestrator { error ); - // Update failure metrics + // Update failure metrics. Track timeouts separately so observability + // can distinguish "worker hung" from "worker threw" (W3). this.metrics.taskParsingFailures++; + if (error instanceof WorkerTimeoutError) { + this.metrics.taskWorkerTimeouts++; + } this.handleWorkerFailure(); // Fallback to main thread @@ -132,8 +140,12 @@ export class WorkerOrchestrator { error ); - // Update failure metrics + // Update failure metrics. A timeout in a batch is counted once for + // the offending file, not for the whole batch. this.metrics.taskParsingFailures += files.length; + if (error instanceof WorkerTimeoutError) { + this.metrics.taskWorkerTimeouts++; + } this.handleWorkerFailure(); // Fallback to main thread @@ -220,7 +232,12 @@ export class WorkerOrchestrator { } /** - * Generic retry mechanism with exponential backoff + * Generic retry mechanism with exponential backoff. + * + * Skips retries for WorkerTimeoutError: a worker that just hung is being + * replaced anyway, and the file content is the most likely cause of the + * hang. Retrying would just waste 7s of exponential backoff before falling + * back to main thread, which is the right answer for hung tasks. (W3) */ private async retryOperation( operation: () => Promise, @@ -248,6 +265,11 @@ export class WorkerOrchestrator { error ); + // Don't retry timeout errors — see method docs. + if (error instanceof WorkerTimeoutError) { + break; + } + // If this is the last attempt, don't wait if (attempt === maxRetries) { break; @@ -525,6 +547,7 @@ export class WorkerOrchestrator { this.metrics = { taskParsingSuccess: 0, taskParsingFailures: 0, + taskWorkerTimeouts: 0, projectDataSuccess: 0, projectDataFailures: 0, averageTaskParsingTime: 0, diff --git a/src/dataflow/workers/errors.ts b/src/dataflow/workers/errors.ts new file mode 100644 index 00000000..438a643c --- /dev/null +++ b/src/dataflow/workers/errors.ts @@ -0,0 +1,24 @@ +/** + * Worker-related error types. + * + * Phase 0 W3: distinguish "worker hung past its timeout" from other worker + * failures so the WorkerOrchestrator can: + * - increment a dedicated metric (`workerTimeouts`) separate from generic + * parsing failures + * - decide whether the fallback to main thread is appropriate + * - allow tests to assert specifically on the timeout path + */ + +export class WorkerTimeoutError extends Error { + readonly filePath: string; + readonly timeoutMs: number; + + constructor(filePath: string, timeoutMs: number) { + super( + `Task worker timed out after ${timeoutMs}ms while processing ${filePath}`, + ); + this.name = "WorkerTimeoutError"; + this.filePath = filePath; + this.timeoutMs = timeoutMs; + } +} diff --git a/src/index.ts b/src/index.ts index b332bfae..511c007f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,7 @@ import { migrateSettings, repairStatusCycles, } from "./utils/settings-migration"; +import { createMigrationRegistry } from "./utils/migration"; import { VersionManager } from "./managers/version-manager"; import { RebuildProgressManager } from "./managers/rebuild-progress-manager"; import DesktopIntegrationManager from "./managers/desktop-integration-manager"; @@ -159,6 +160,13 @@ export default class TaskProgressBarPlugin extends Plugin { // Write API for dataflow architecture writeAPI?: WriteAPI; + // Resolves once all async cleanup work scheduled by onunload() has settled. + // Obsidian's onunload() signature is sync (void), so async cleanup work has + // to be fired off without awaiting. Tests and any code that needs to know + // when the plugin is fully torn down should `await plugin.unloadComplete`. + // Reset on each onload(); see W2 in the v10 Phase 0 plan. + public unloadComplete: Promise = Promise.resolve(); + // Notification manager (desktop) notificationManager?: DesktopIntegrationManager; @@ -1746,30 +1754,49 @@ export default class TaskProgressBarPlugin extends Plugin { } onunload() { - // Clean up global suggest manager + // Synchronous cleanup paths run immediately. Asynchronous cleanup + // (currently just dataflowOrchestrator.cleanup() which awaits Repository + // persistence) is gathered into a single promise exposed as + // `unloadComplete` so tests and any external observer can await full + // teardown. Obsidian itself never awaits this — its onunload signature + // is sync — but at least listeners + workers + persistence get a chance + // to settle before the next plugin lifecycle, instead of racing. + const asyncTasks: Array> = []; + + // Clean up global suggest manager (sync) if (this.globalSuggestManager) { this.globalSuggestManager.cleanup(); } // Bases views are automatically unregistered by Obsidian when plugin unloads - // Clean up dataflow orchestrator (experimental) + // Clean up dataflow orchestrator (async — capture into asyncTasks) if (this.dataflowOrchestrator) { - this.dataflowOrchestrator.cleanup().catch((error) => { - console.error( - "Error cleaning up dataflow orchestrator:", - error, - ); - }); - // Set to undefined to prevent any further access + const orch = this.dataflowOrchestrator; + // Null out immediately so any other code path that fires during + // teardown can't reach into a half-cleaned-up orchestrator. this.dataflowOrchestrator = undefined; + asyncTasks.push( + orch.cleanup().catch((error) => { + console.error( + "Error cleaning up dataflow orchestrator:", + error, + ); + }), + ); } - // Clean up MCP server manager (desktop only) + // Clean up MCP server manager (desktop only, sync) if (this.mcpServerManager) { this.mcpServerManager.cleanup(); } + // Task Genius Icon Manager cleanup is handled automatically by Component system + + // Expose a promise so tests / external observers can know when async + // cleanup is fully done. Never rejects — individual catches above + // already log errors. + this.unloadComplete = Promise.all(asyncTasks).then(() => undefined); } /** @@ -1957,18 +1984,45 @@ export default class TaskProgressBarPlugin extends Plugin { ); } catch {} - // Migrate settings to new formats - migrateSettings(this.settings); + // Run migrations through the version-keyed registry. Phase 0 W1. + // The legacy bundle step wraps the prior migrateSettings + inheritance + // + fluent default-backfill paths under one atomic try/commit, so a + // throw in any of them leaves settings untouched. The bundle reads + // `savedData` to detect old projectConfig.metadataConfig.* keys that + // got dropped during the Object.assign with DEFAULT_SETTINGS — we + // stash it on the settings object briefly so the step can see it. + try { + (this.settings as any).__transient_savedData__ = savedData; + const registry = createMigrationRegistry(); + const result = await registry.run(this.settings, { + toVersion: this.manifest.version, + }); + if (!result.ok) { + console.error( + "[Task Genius] MigrationRegistry run failed:", + result.error, + ); + // Fall back to the legacy direct calls so the user isn't left + // in a half-migrated state. Behavior is identical to before W1. + migrateSettings(this.settings); + this.migrateInheritanceSettings(savedData); + } else if (result.changed) { + console.log( + "[Task Genius] Migrations applied:", + Object.values(result.results).flatMap((r) => r.details), + ); + } + } finally { + delete (this.settings as any).__transient_savedData__; + } - // Repair and validate status cycles + // Repair and validate status cycles (independent of migration registry — + // runs every load to clean up dynamically-corrupted state). if (this.settings.statusCycles) { this.settings.statusCycles = repairStatusCycles( this.settings.statusCycles, ); } - - // Migrate old inheritance settings to new structure - this.migrateInheritanceSettings(savedData); } private migrateInheritanceSettings(savedData: any) { diff --git a/src/utils/migration/MigrationRegistry.ts b/src/utils/migration/MigrationRegistry.ts new file mode 100644 index 00000000..4f4baa3b --- /dev/null +++ b/src/utils/migration/MigrationRegistry.ts @@ -0,0 +1,289 @@ +/** + * MigrationRegistry — Phase 0 W1. + * + * A version-keyed registry for settings migration steps. The goals over the + * three pre-existing migration call sites it replaces: + * 1. **Atomic** — clone settings, run all applicable steps in-memory, only + * commit if every step succeeds. On any throw, the original settings + * object is left untouched. No partial migrations. + * 2. **Dry-run** — `run({dryRun: true})` returns the diff without committing, + * so Phase 1's deprecation modals can show a preview before applying. + * 3. **Tombstone-aware** — a "tombstone" is a first-class step kind. Phase 1 + * will use these to remove deprecated fields with cleanup logic, optionally + * salvaging their content into a successor field. + * + * Phase 0 explicitly DOES NOT migrate any callers off the existing + * `migrateSettings`/`migrateInheritanceSettings`/`fluentIntegration.migrateSettings` + * paths. It just registers them as a single bundled step (`legacy-bundle-0`) + * so they get atomic semantics. Subsequent phases break the bundle apart and + * version-key individual migrations. + * + * Why a registry instead of feature-by-feature lifecycle hooks + * ------------------------------------------------------------ + * Centralizing the steps in one ordered list makes "what changed between + * version A and version B?" answerable in one read. Per-feature lifecycle + * hooks scatter that knowledge and make Phase 1 audits painful. + * + * Versioning + * ---------- + * Steps declare a `targetVersion` (semver). The registry runs every step + * whose `targetVersion ∈ (fromVersion, toVersion]`, in semver order. The + * `fromVersion` comes from `settings._meta.lastMigratedVersion` (default + * "0.0.0" for users who upgrade from before this system existed). The + * `toVersion` is the current `manifest.json` version, passed in by the + * caller. + */ + +import type { TaskProgressBarSettings } from "@/common/setting-definition"; + +export type MigrationKind = "transform" | "tombstone" | "validate"; + +export interface MigrationContext { + /** + * Plugin version stored on disk before this run. Defaults to "0.0.0" for + * settings that have never been touched by the registry. + */ + fromVersion: string; + /** Current plugin version (manifest.version). */ + toVersion: string; + /** When true, do not commit results — just compute the diff. */ + dryRun: boolean; + /** Append a debug message. The registry collects these into the run result. */ + log(msg: string): void; +} + +export interface MigrationStepResult { + /** Whether the step actually changed anything. */ + changed: boolean; + /** Per-step detail messages, e.g. "renamed taskStatusCycle → statusCycles". */ + details: string[]; + /** Non-fatal warnings the user should know about. */ + warnings?: string[]; +} + +export interface MigrationStep { + /** Stable identifier, e.g. "v0.0.1-legacy-bundle". Used in logs and tests. */ + id: string; + /** + * The plugin version that introduced this migration step. Steps run in + * semver order, and only when targetVersion > fromVersion (i.e. the user + * is upgrading past this point). Use "0.0.0" for steps that should always + * run on first registry adoption (e.g. the legacy bundle). + */ + targetVersion: string; + /** Hint for tooling and reporting. Doesn't affect execution. */ + kind: MigrationKind; + /** Human-readable description shown in dry-run previews. */ + description: string; + /** + * Apply this step to the (already-cloned) settings object. The step is + * free to mutate `settings` in place. The registry handles the + * clone-before-mutate boundary; do not deep-copy here. + * + * Throw to abort the entire run. The registry catches the throw and + * leaves the original settings untouched. + */ + apply( + settings: TaskProgressBarSettings, + ctx: MigrationContext, + ): Promise | MigrationStepResult; +} + +export interface MigrationRunResult { + /** True if every step succeeded (or there were no steps to run). */ + ok: boolean; + /** True if any step actually changed something. */ + changed: boolean; + /** Steps that were considered (in execution order). */ + considered: MigrationStep[]; + /** Steps that were actually run (subset of considered). */ + executed: MigrationStep[]; + /** Per-step results, keyed by step id. */ + results: Record; + /** Aggregated logs from ctx.log(). */ + logs: string[]; + /** + * If a step threw, this is set and `ok` is false. The original settings + * object passed to `run()` is untouched in this case. + */ + error?: { stepId: string; error: Error }; + /** Resolved fromVersion / toVersion for this run. */ + fromVersion: string; + toVersion: string; +} + +/** + * Compare two semver-ish strings. Returns negative if ab, + * zero if equal. Tolerant of missing patch / pre-release components. + * + * This is intentionally minimal — we don't need full semver semantics, just + * enough to order our own internal steps. We DO NOT depend on a semver lib + * because the plugin already has enough deps and this is the only place that + * needs comparison. + */ +export function compareSemver(a: string, b: string): number { + const parse = (s: string): number[] => { + // Strip any pre-release suffix (e.g. "1.0.0-beta.4" → "1.0.0"). + const core = s.split("-")[0]; + const parts = core + .split(".") + .map((p) => parseInt(p, 10)) + .map((n) => (Number.isFinite(n) ? n : 0)); + while (parts.length < 3) parts.push(0); + return parts; + }; + const [a0, a1, a2] = parse(a); + const [b0, b1, b2] = parse(b); + if (a0 !== b0) return a0 - b0; + if (a1 !== b1) return a1 - b1; + return a2 - b2; +} + +/** + * Deep clone via structured serialization. We don't bring in lodash for one + * call — JSON round-trip is fine for settings (no functions, no Dates we care + * about preserving as Date instances, no Maps). + */ +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +export class MigrationRegistry { + private steps: MigrationStep[] = []; + + register(step: MigrationStep): void { + // Reject duplicate IDs so a Phase 1 PR can't accidentally shadow an + // existing tombstone. + if (this.steps.some((s) => s.id === step.id)) { + throw new Error( + `MigrationRegistry: duplicate step id "${step.id}"`, + ); + } + this.steps.push(step); + } + + list(): readonly MigrationStep[] { + return [...this.steps]; + } + + clear(): void { + this.steps = []; + } + + /** + * Run all applicable steps. See class docs for atomicity and dry-run semantics. + * + * The settings object is mutated only if every step succeeds AND dryRun is + * false. Otherwise the caller's settings reference is untouched. + */ + async run( + settings: TaskProgressBarSettings, + opts: { + fromVersion?: string; + toVersion: string; + dryRun?: boolean; + }, + ): Promise { + const fromVersion = + opts.fromVersion ?? + settings._meta?.lastMigratedVersion ?? + "0.0.0"; + const toVersion = opts.toVersion; + const dryRun = opts.dryRun ?? false; + const logs: string[] = []; + const ctx: MigrationContext = { + fromVersion, + toVersion, + dryRun, + log: (msg) => logs.push(msg), + }; + + // Filter to steps in (fromVersion, toVersion]. Sort ascending by + // targetVersion to preserve historical ordering. + const considered = this.steps + .filter( + (s) => + compareSemver(s.targetVersion, fromVersion) > 0 && + compareSemver(s.targetVersion, toVersion) <= 0, + ) + .sort((a, b) => compareSemver(a.targetVersion, b.targetVersion)); + + const executed: MigrationStep[] = []; + const results: Record = {}; + + // Operate on a clone — we only commit at the end. + const draft = deepClone(settings); + + for (const step of considered) { + try { + const result = await Promise.resolve(step.apply(draft, ctx)); + results[step.id] = result; + executed.push(step); + if (result.changed) { + ctx.log( + `[${step.id}] applied (${result.details.length} changes)`, + ); + } else { + ctx.log(`[${step.id}] no-op`); + } + } catch (error) { + ctx.log( + `[${step.id}] FAILED: ${error instanceof Error ? error.message : String(error)}`, + ); + return { + ok: false, + changed: false, + considered, + executed, + results, + logs, + error: { + stepId: step.id, + error: + error instanceof Error + ? error + : new Error(String(error)), + }, + fromVersion, + toVersion, + }; + } + } + + const anyChanged = Object.values(results).some((r) => r.changed); + + // Commit. The atomic boundary is: clone above, copy properties back here. + // We mutate the caller's reference instead of replacing it because the + // plugin holds a reference and would otherwise see stale data. + if (!dryRun && anyChanged) { + // Wipe owned keys and re-copy from draft. Object.assign would + // leave keys present in `settings` but absent in `draft` intact, + // which is wrong if a step deleted a key. Walk the union. + const allKeys = new Set([ + ...Object.keys(settings), + ...Object.keys(draft), + ]); + for (const key of allKeys) { + if (key in draft) { + (settings as any)[key] = (draft as any)[key]; + } else { + delete (settings as any)[key]; + } + } + // Stamp the new lastMigratedVersion so subsequent runs skip these steps. + settings._meta = settings._meta ?? {}; + settings._meta.lastMigratedVersion = toVersion; + } + + return { + ok: true, + changed: anyChanged, + considered, + executed, + results, + logs, + fromVersion, + toVersion, + }; + } +} diff --git a/src/utils/migration/index.ts b/src/utils/migration/index.ts new file mode 100644 index 00000000..5b3f2094 --- /dev/null +++ b/src/utils/migration/index.ts @@ -0,0 +1,35 @@ +/** + * Migration entry point — Phase 0 W1. + * + * `createMigrationRegistry()` returns a registry pre-loaded with the steps + * that should run on plugin load. The set is stable for Phase 0; Phase 1+ + * will add steps as features are deprecated. + */ + +export { + MigrationRegistry, + compareSemver, +} from "./MigrationRegistry"; +export type { + MigrationKind, + MigrationContext, + MigrationStep, + MigrationStepResult, + MigrationRunResult, +} from "./MigrationRegistry"; + +import { MigrationRegistry } from "./MigrationRegistry"; +import { legacyBundleStep } from "./steps/legacy-bundle-0"; +import { sentinelTombstoneStep } from "./steps/tombstone-0.0.2-sentinel"; + +/** + * Build the canonical registry used by the plugin at load time. Centralized + * here so tests can construct an identical registry without depending on + * `index.ts`. + */ +export function createMigrationRegistry(): MigrationRegistry { + const registry = new MigrationRegistry(); + registry.register(legacyBundleStep); + registry.register(sentinelTombstoneStep); + return registry; +} diff --git a/src/utils/migration/steps/legacy-bundle-0.ts b/src/utils/migration/steps/legacy-bundle-0.ts new file mode 100644 index 00000000..d4c75a3e --- /dev/null +++ b/src/utils/migration/steps/legacy-bundle-0.ts @@ -0,0 +1,158 @@ +/** + * legacy-bundle-0 — Phase 0 W1. + * + * A single MigrationStep that wraps the three existing migration paths the + * plugin used before MigrationRegistry existed: + * + * 1. `migrateSettings()` from `src/utils/settings-migration.ts` + * (legacy multi-cycle status migration: taskStatusCycle → statusCycles) + * 2. `migrateInheritanceSettings(savedData)` from `src/index.ts` + * (projectConfig.metadataConfig → fileMetadataInheritance) + * 3. `fluentIntegration.migrateSettings()` from FluentIntegration.ts + * (initialize default fluentView config) + * + * Why a single bundled step? + * -------------------------- + * Phase 0's contract is "no observable behavior change". The current load path + * runs all three of these unconditionally on every load. We want to: + * (a) get them under registry's atomic try/commit semantics + * (b) NOT change ordering, NOT change which fields they touch + * (c) avoid teaching the registry about each one's quirks + * + * Bundling them as one step is the cleanest path — Phase 1 will progressively + * split this bundle into version-keyed individual steps as features are + * touched and audited. + * + * targetVersion is "0.0.1" so it runs on FIRST adoption (any settings whose + * `_meta.lastMigratedVersion` is "0.0.0" — the default for users upgrading + * from before the registry existed). Subsequent loads see + * `_meta.lastMigratedVersion >= "0.0.1"` and skip the bundle. + * + * Important: this step is PURE in the sense that it doesn't do I/O. The + * `migrateInheritanceSettings` legacy implementation called `saveSettings` + * inline, but we don't replicate that — the registry's commit phase handles + * persistence, and the plugin caller saves after the run. This eliminates a + * subtle bug where the legacy code triggered an extra save mid-load. + * + * The fluentIntegration.migrateSettings() logic is reproduced inline (rather + * than imported) so the bundle step has no Component dependencies. The + * inlined logic is byte-equivalent to FluentIntegration.ts:176-205. + */ + +import type { TaskProgressBarSettings } from "@/common/setting-definition"; +import { migrateToMultiCycle } from "@/utils/settings-migration"; +import type { MigrationStep, MigrationStepResult } from "../MigrationRegistry"; + +/** + * Apply the bundled legacy migrations. + * + * The function is exported separately so legacy-bundle-0.test.ts can run it + * against fixture data without going through the registry. + */ +export function applyLegacyBundle( + settings: TaskProgressBarSettings, + savedData?: any, +): MigrationStepResult { + const details: string[] = []; + const warnings: string[] = []; + let changed = false; + + // --- 1. Multi-cycle status migration (taskStatusCycle → statusCycles) --- + const beforeCycles = settings.statusCycles?.length ?? 0; + migrateToMultiCycle(settings); + const afterCycles = settings.statusCycles?.length ?? 0; + if (afterCycles > beforeCycles) { + details.push( + `Migrated ${afterCycles - beforeCycles} status cycle(s) from legacy taskStatusCycle`, + ); + changed = true; + } + + // --- 2. Inheritance settings (projectConfig.metadataConfig → fileMetadataInheritance) --- + // Pure rewrite of the legacy migrateInheritanceSettings(savedData) function. + // The legacy version inspected `savedData` (the raw JSON from disk) instead + // of `settings` because some keys may have been stripped during loadSettings' + // Object.assign with DEFAULT_SETTINGS. We accept savedData here so callers + // can preserve that behavior. If savedData is omitted we fall back to settings. + const sourceConfig = savedData?.projectConfig?.metadataConfig + ? savedData.projectConfig.metadataConfig + : (settings as any)?.projectConfig?.metadataConfig; + + if (sourceConfig && !settings.fileMetadataInheritance) { + settings.fileMetadataInheritance = { + enabled: true, + inheritFromFrontmatter: + sourceConfig.inheritFromFrontmatter ?? true, + inheritFromFrontmatterForSubtasks: + sourceConfig.inheritFromFrontmatterForSubtasks ?? false, + }; + // Strip the old keys from projectConfig.metadataConfig — exactly what + // the legacy code did. + if (settings.projectConfig?.metadataConfig) { + delete (settings.projectConfig.metadataConfig as any) + .inheritFromFrontmatter; + delete (settings.projectConfig.metadataConfig as any) + .inheritFromFrontmatterForSubtasks; + } + details.push( + "Migrated projectConfig.metadataConfig.* → fileMetadataInheritance.*", + ); + changed = true; + } + + // --- 3. Fluent view defaults (FluentIntegration.migrateSettings inlined) --- + // Note: this is byte-equivalent to FluentIntegration.ts:176-205 minus the + // final saveSettings() call (registry handles persistence). + if (!settings.fluentView) { + settings.fluentView = { enableFluent: false } as any; + details.push("Initialized fluentView with default { enableFluent: false }"); + changed = true; + } + if (!settings.fluentView!.workspaces) { + settings.fluentView!.workspaces = [ + { id: "default", name: "Default", color: "#3498db" }, + ] as any; + details.push("Added default fluentView workspace"); + changed = true; + } + if ((settings.fluentView as any).fluentConfig === undefined) { + (settings.fluentView as any).fluentConfig = { + enableWorkspaces: true, + defaultWorkspace: "default", + maxOtherViewsBeforeOverflow: 5, + }; + details.push("Initialized default fluentView.fluentConfig"); + changed = true; + } + if ((settings.fluentView as any).useWorkspaceSideLeaves === undefined) { + (settings.fluentView as any).useWorkspaceSideLeaves = false; + // Cosmetic backfill, don't count as a "real" change so the registry + // doesn't churn `_meta.lastMigratedVersion` on every load for users + // who never touched fluent settings. + } + + return { changed, details, warnings }; +} + +export const legacyBundleStep: MigrationStep = { + id: "v0.0.1-legacy-bundle", + targetVersion: "0.0.1", + kind: "transform", + description: + "Bundled legacy migrations from before MigrationRegistry: multi-cycle status, inheritance, fluent view defaults", + apply(settings) { + // The plugin's loadSettings stashes the raw `savedData` (the JSON + // straight off disk, before the merge with DEFAULT_SETTINGS) on a + // transient field so this step can detect old projectConfig.metadataConfig.* + // keys that got dropped by the merge. The transient field is removed + // by loadSettings after the registry returns. + const savedData = (settings as any).__transient_savedData__; + const result = applyLegacyBundle(settings, savedData); + // Strip the transient field from the registry's working copy so it + // doesn't end up persisted. (loadSettings also strips it from the + // caller's reference, but the registry commits via key-by-key copy + // and would carry over the transient field otherwise.) + delete (settings as any).__transient_savedData__; + return result; + }, +}; diff --git a/src/utils/migration/steps/tombstone-0.0.2-sentinel.ts b/src/utils/migration/steps/tombstone-0.0.2-sentinel.ts new file mode 100644 index 00000000..c722e620 --- /dev/null +++ b/src/utils/migration/steps/tombstone-0.0.2-sentinel.ts @@ -0,0 +1,62 @@ +/** + * tombstone-0.0.2-sentinel — Phase 0 W1. + * + * Proves the `tombstone` MigrationStep kind works end-to-end before Phase 1 + * starts using it for real deprecations. + * + * Why a synthetic field? + * ---------------------- + * The plan suggested tombstoning `taskStatusCycle` / `taskStatusMarks` (legacy + * single-cycle status fields, replaced by `statusCycles[]`). But a quick grep + * shows 22+ files in the codebase still read those names directly — they're + * NOT actually vestigial yet, and tombstoning them in Phase 0 would break + * behavior. Phase 1 will retire them properly once the readers are gone. + * + * For Phase 0 we need a sentinel that exercises the tombstone code path + * WITHOUT touching any real settings. The solution: tombstone a synthetic + * field `_meta._sentinelMarker` that production never has, and that tests + * can inject before running the registry. This: + * - exercises the kind="tombstone" code path + * - is verifiable in unit tests + * - is a no-op for real users (no-change branch) + * - leaves the legacy taskStatus* fields untouched until Phase 1 audits them + * + * If you're reading this in Phase 1 and want to retire taskStatusCycle, the + * checklist is: + * 1. Audit every reader (grep `taskStatusCycle`, `taskStatusMarks`). + * 2. Replace each reader with the equivalent `statusCycles` lookup. + * 3. Add a real tombstone step (e.g. v0.10.0-tombstone-status-cycles). + * 4. Delete THIS file. + */ + +import type { TaskProgressBarSettings } from "@/common/setting-definition"; +import type { MigrationStep, MigrationStepResult } from "../MigrationRegistry"; + +export function applySentinelTombstone( + settings: TaskProgressBarSettings, +): MigrationStepResult { + const details: string[] = []; + let changed = false; + + // Synthetic marker — production never has it. Tests inject it to verify + // the tombstone path runs. + const meta: any = settings._meta; + if (meta && "_sentinelMarker" in meta) { + delete meta._sentinelMarker; + details.push("Tombstoned settings._meta._sentinelMarker"); + changed = true; + } + + return { changed, details }; +} + +export const sentinelTombstoneStep: MigrationStep = { + id: "v0.0.2-sentinel-tombstone", + targetVersion: "0.0.2", + kind: "tombstone", + description: + "Tombstone legacy taskStatusCycle / taskStatusMarks (replaced by statusCycles)", + apply(settings) { + return applySentinelTombstone(settings); + }, +};