mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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:
commit
55d7dd6e8d
40 changed files with 6229 additions and 45 deletions
88
.v10-worktrees/README.md
Normal file
88
.v10-worktrees/README.md
Normal 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)
|
||||
234
.v10-worktrees/worktree-a-banners.md
Normal file
234
.v10-worktrees/worktree-a-banners.md
Normal 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).
|
||||
291
.v10-worktrees/worktree-b-archiver.md
Normal file
291
.v10-worktrees/worktree-b-archiver.md
Normal 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.
|
||||
233
.v10-worktrees/worktree-c-readonly.md
Normal file
233
.v10-worktrees/worktree-c-readonly.md
Normal 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.
|
||||
459
.v10-worktrees/worktree-d-cliff.md
Normal file
459
.v10-worktrees/worktree-d-cliff.md
Normal 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.
|
||||
308
.v10-worktrees/worktree-e-cleanup.md
Normal file
308
.v10-worktrees/worktree-e-cleanup.md
Normal 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
96
PHASE0_DEFERRED.md
Normal 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
506
WORKTREE_PLAN.md
Normal 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
|
||||
```
|
||||
|
|
@ -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$":
|
||||
|
|
|
|||
120
src/__mocks__/localforage.ts
Normal file
120
src/__mocks__/localforage.ts
Normal 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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
80
src/__tests__/cache/local-storage-cache.version.test.ts
vendored
Normal file
80
src/__tests__/cache/local-storage-cache.version.test.ts
vendored
Normal 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
151
src/__tests__/cache/scope-map.test.ts
vendored
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
102
src/__tests__/integration/CacheInvariants.sequence.test.ts
Normal file
102
src/__tests__/integration/CacheInvariants.sequence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
src/__tests__/integration/CacheInvariants.smoke.test.ts
Normal file
93
src/__tests__/integration/CacheInvariants.smoke.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
98
src/__tests__/integration/Lifecycle.unload.test.ts
Normal file
98
src/__tests__/integration/Lifecycle.unload.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
141
src/__tests__/integration/Orchestrator.roundtrip.test.ts
Normal file
141
src/__tests__/integration/Orchestrator.roundtrip.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
src/__tests__/integration/Orchestrator.settingsChange.test.ts
Normal file
120
src/__tests__/integration/Orchestrator.settingsChange.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
96
src/__tests__/integration/OrchestratorScopeMap.test.ts
Normal file
96
src/__tests__/integration/OrchestratorScopeMap.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
93
src/__tests__/integration/Rebuild.failure.test.ts
Normal file
93
src/__tests__/integration/Rebuild.failure.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
178
src/__tests__/integration/WorkerTimeout.test.ts
Normal file
178
src/__tests__/integration/WorkerTimeout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
448
src/__tests__/integration/_fixtures/buildOrchestrator.ts
Normal file
448
src/__tests__/integration/_fixtures/buildOrchestrator.ts
Normal 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;
|
||||
}
|
||||
253
src/__tests__/integration/_fixtures/inMemoryStorage.ts
Normal file
253
src/__tests__/integration/_fixtures/inMemoryStorage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
328
src/__tests__/migration/MigrationRegistry.test.ts
Normal file
328
src/__tests__/migration/MigrationRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
216
src/__tests__/migration/legacy-bundle-0.test.ts
Normal file
216
src/__tests__/migration/legacy-bundle-0.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
28
src/cache/local-storage-cache.ts
vendored
28
src/cache/local-storage-cache.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
217
src/dataflow/cache/invariants.ts
vendored
Normal 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
150
src/dataflow/cache/scope-map.ts
vendored
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
24
src/dataflow/workers/errors.ts
Normal file
24
src/dataflow/workers/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
src/index.ts
86
src/index.ts
|
|
@ -110,6 +110,7 @@ import {
|
|||
migrateSettings,
|
||||
repairStatusCycles,
|
||||
} from "./utils/settings-migration";
|
||||
import { createMigrationRegistry } from "./utils/migration";
|
||||
import { VersionManager } from "./managers/version-manager";
|
||||
import { RebuildProgressManager } from "./managers/rebuild-progress-manager";
|
||||
import DesktopIntegrationManager from "./managers/desktop-integration-manager";
|
||||
|
|
@ -159,6 +160,13 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
// Write API for dataflow architecture
|
||||
writeAPI?: WriteAPI;
|
||||
|
||||
// Resolves once all async cleanup work scheduled by onunload() has settled.
|
||||
// Obsidian's onunload() signature is sync (void), so async cleanup work has
|
||||
// to be fired off without awaiting. Tests and any code that needs to know
|
||||
// when the plugin is fully torn down should `await plugin.unloadComplete`.
|
||||
// Reset on each onload(); see W2 in the v10 Phase 0 plan.
|
||||
public unloadComplete: Promise<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) {
|
||||
|
|
|
|||
289
src/utils/migration/MigrationRegistry.ts
Normal file
289
src/utils/migration/MigrationRegistry.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/utils/migration/index.ts
Normal file
35
src/utils/migration/index.ts
Normal 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;
|
||||
}
|
||||
158
src/utils/migration/steps/legacy-bundle-0.ts
Normal file
158
src/utils/migration/steps/legacy-bundle-0.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
62
src/utils/migration/steps/tombstone-0.0.2-sentinel.ts
Normal file
62
src/utils/migration/steps/tombstone-0.0.2-sentinel.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue