Merge branch 'refactor/v10-phase0'

Phase 0 of the v10 refactor: stability foundation. 7 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 and assigned to Worktree D
sub-PRs in Phase 3.

Highlights:
- MigrationRegistry with version-keyed atomic steps + tombstones
- onunload async cleanup race fixed; TaskIndexer listener leak fixed
- Per-task worker timeout (8s default) with kill + main-thread fallback
- Cache invariants checker (debug-mode safety net)
- LocalStorageCache version-mismatch fix on read
- Typed cache scope map for Phase 1+ settings consolidation
- Last-resort cleanup in Orchestrator.rebuild()
- 5 critical-path integration tests + W0 fixture infrastructure

Phase 1-4 work is split into 5 self-contained worktree briefs in
.v10-worktrees/. See .v10-worktrees/README.md for the index.
This commit is contained in:
Quorafind 2026-04-07 10:47:28 +08:00
commit 55d7dd6e8d
40 changed files with 6229 additions and 45 deletions

88
.v10-worktrees/README.md Normal file
View file

@ -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)

View file

@ -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> | 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 `<vault>/Task Genius Archive/<section>/` | 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).

View file

@ -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 <vault>/Task Genius Archive/ */
files: Map<string, string>;
/** 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<ArchiveSection> {
const workflows = plugin.settings.workflow?.definitions ?? [];
const files = new Map<string, string>();
// 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<TaskProgressBarSettings> = {
workflow: {
enableWorkflow: true,
definitions: [/* 47 realistic workflow shapes */],
timestampFormat: "YYYY-MM-DD HH:mm",
autoAddTimestamp: true,
calculateSpentTime: false,
},
};
export const settingsWith12Habits: Partial<TaskProgressBarSettings> = {
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.

View file

@ -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<string, boolean> = {};
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 `<sub-plugin-data-dir>/.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.

View file

@ -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 `<vault>/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: <path or daily-note>
│ 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 <vault>/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.

View file

@ -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 `<vault>/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.

96
PHASE0_DEFERRED.md Normal file
View file

@ -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.

506
WORKTREE_PLAN.md Normal file
View file

@ -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-<phase>-<area> refactor/v10-<phase>-<area>` 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<string, string>, 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 <main-repo>
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
```

View file

@ -7,6 +7,7 @@ module.exports = {
moduleNameMapper: {
"^obsidian$": "<rootDir>/src/__mocks__/obsidian.ts",
"^moment$": "<rootDir>/src/__mocks__/moment.js",
"^localforage$": "<rootDir>/src/__mocks__/localforage.ts",
"^@codemirror/state$": "<rootDir>/src/__mocks__/codemirror-state.ts",
"^@codemirror/view$": "<rootDir>/src/__mocks__/codemirror-view.ts",
"^@codemirror/language$":

View file

@ -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<string, Map<string, any>>();
function getStore(name: string): Map<string, any> {
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<T>(key: string, value: T): Promise<T> {
store.set(key, value);
return value;
},
async getItem<T>(key: string): Promise<T | null> {
return store.has(key) ? (store.get(key) as T) : null;
},
async removeItem(key: string): Promise<void> {
store.delete(key);
},
async clear(): Promise<void> {
store.clear();
},
async length(): Promise<number> {
return store.size;
},
async keys(): Promise<string[]> {
return [...store.keys()];
},
async iterate<T>(
fn: (value: T, key: string, iterationNumber: number) => any,
): Promise<void> {
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<void> {},
// Driver introspection no-ops.
driver(): string {
return "MOCK";
},
setDriver(): Promise<void> {
return Promise.resolve();
},
// Configuration is a no-op in the mock.
config(): boolean {
return true;
},
};
}
async function dropInstance(options: { name: string }): Promise<void> {
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<T>(key: string, value: T): Promise<T> {
return createInstance({ name: "__default__" }).setItem(key, value);
},
async getItem<T>(key: string): Promise<T | null> {
return createInstance({ name: "__default__" }).getItem<T>(key);
},
async removeItem(key: string): Promise<void> {
return createInstance({ name: "__default__" }).removeItem(key);
},
async clear(): Promise<void> {
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;

View file

@ -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();
}

View file

@ -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();
});
});

151
src/__tests__/cache/scope-map.test.ts vendored Normal file
View file

@ -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<CacheScope>([
"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);
}
});
});
});

View file

@ -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);
});
});

View file

@ -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();
}
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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();
}
});
});

View file

@ -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();
}
});
});

View file

@ -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();
});
});

View file

@ -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<number, any> = (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<number, any> = (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);
});
});

View file

@ -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 });
});
});

View file

@ -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<string, string> | VaultFile[];
/** Partial settings overrides merged onto DEFAULT_SETTINGS. */
settings?: Partial<TaskProgressBarSettings>;
/** 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<void>;
/** Delete a file and trigger a vault `delete` event. */
deleteFile(path: string): Promise<void>;
/** Create a new file and trigger a vault `create` event. */
createFile(path: string, content: string): Promise<void>;
/**
* 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<void>;
}
// ---------------------------------------------------------------------------
// 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<string, Set<(...args: any[]) => 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<string, FakeTFile>();
private contentMap = new Map<string, string>();
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<string, string> | 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<string> {
return this.contentMap.get(file.path) ?? "";
}
async cachedRead(file: FakeTFile | { path: string }): Promise<string> {
return this.contentMap.get(file.path) ?? "";
}
async modify(file: FakeTFile | { path: string }, content: string): Promise<void> {
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<FakeTFile> {
const f = this.__seed(path, content);
this.bus.trigger("create", f);
return f;
}
async delete(file: FakeTFile | { path: string }): Promise<void> {
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<void> {
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<string, any>();
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<string>(),
plugins: {} as Record<string, any>,
};
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<void>;
registerEvent(ref: any): void;
}
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
export async function buildOrchestrator(
opts: BuildOrchestratorOptions = {},
): Promise<OrchestratorFixture> {
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;
}

View file

@ -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<Namespace, string> = {
raw: "tasks.raw:",
project: "project.data:",
augmented: "tasks.augmented:",
consolidated: "consolidated:",
};
const META_PREFIX = "meta:";
export class InMemoryStorage {
private map = new Map<string, any>();
private currentVersion: string;
private schemaVersion: number = 1;
constructor(version: string = "1.0.0") {
this.currentVersion = version;
}
// --- raw ---
async loadRaw(path: string): Promise<RawRecord | null> {
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<void> {
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<ProjectRecord | null> {
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<string, any> },
): Promise<void> {
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<AugmentedRecord | null> {
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<void> {
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<ConsolidatedRecord | null> {
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<void> {
const rec: ConsolidatedRecord = {
time: Date.now(),
version: this.currentVersion,
schema: this.schemaVersion,
data: taskCache,
};
this.map.set(PREFIX.consolidated + "taskIndex", rec);
}
// --- meta ---
async saveMeta<T = any>(key: string, value: T): Promise<void> {
this.map.set(META_PREFIX + key, value);
}
async loadMeta<T = any>(key: string): Promise<T | null> {
return (this.map.get(META_PREFIX + key) as T) ?? null;
}
// --- listing / lifecycle ---
async listRawPaths(): Promise<string[]> {
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<string[]> {
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<void> {
this.map.delete(PREFIX.raw + path);
this.map.delete(PREFIX.project + path);
this.map.delete(PREFIX.augmented + path);
}
async clear(): Promise<void> {
this.map.clear();
}
async clearNamespace(namespace: Namespace): Promise<void> {
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<string, number>;
}> {
const byNamespace: Record<string, number> = {
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<string, any> {
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);
}
}

View file

@ -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> = {}): 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);
});
});

View file

@ -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<T>(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");
});
});

View file

@ -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<T = any>(path: string): Promise<Cached<T> | 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<Cached<T>>(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

View file

@ -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 */

View file

@ -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<void> {
// 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<void> {
// 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<void> {
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
*/

217
src/dataflow/cache/invariants.ts vendored Normal file
View file

@ -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<CacheInvariantReport> {
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<number>(
(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,
};
}

150
src/dataflow/cache/scope-map.ts vendored Normal file
View file

@ -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<string, readonly CacheScope[]>
> = 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<CacheScope>();
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);
}

View file

@ -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<void> {
// 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);
}
}
/**

View file

@ -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<any>, 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<typeof setTimeout>;
}
/**
@ -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<void> {
// 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
*/

View file

@ -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<T>(
operation: () => Promise<T>,
@ -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,

View file

@ -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;
}
}

View file

@ -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<void> = 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<Promise<void>> = [];
// 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) {

View file

@ -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> | 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<string, MigrationStepResult>;
/** 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 a<b, positive if a>b,
* 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<T>(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<MigrationRunResult> {
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<string, MigrationStepResult> = {};
// 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,
};
}
}

View file

@ -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;
}

View file

@ -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;
},
};

View file

@ -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);
},
};