playbook-composite-design.md11.3 KBView on GitHub
# Unified Composite Playbook — Design

Merge each AOP's split **user** and **org** playbooks into a single, central
`Playbook.md` that opens as one editable document. Each section (Global, each
Stage, Always Loaded, On Demand) shows the org content and the user content
side-by-side as bordered sub-cards; edits split back to the correct underlying
doc on save.

## Goals

1. One `Playbook.md` per AOP — replaces the separate **User** / **Org** folder rows.
2. Opening it shows a composite grouped by section:

   ```
   Global                    ← section group (no border)
   ┌─ Org ─────────────────┐ ← bordered sub-card
   │ org global content    │
   └───────────────────────┘
   ┌─ User ────────────────┐ ← bordered sub-card
   │ user global content   │
   └───────────────────────┘
   ```

3. The **user** sub-card of each Stage hides Entry / Exit criteria (org keeps them). Hidden, not
   deleted — the underlying user playbook retains them; they just don't render in the composite.
4. Section group containers are borderless; the Org / User sub-cards carry the borders.
5. Single editable document — edits split back to the org vs user playbook on save.
6. **Manual save only** — no autosave. Saves only on the Save button / Cmd-S.
7. **Unsaved-changes guard** — warn before navigating away or closing the tab with unsaved edits.

## Current state

- Each AOP has **two persisted documents**, distinguished by path + `userId`:
  - User: `user/playbooks/{aopId}/PLAYBOOK.md` (`userId` set)
  - Org:  `organisation/playbooks/{orgAopId}/PLAYBOOK.md` (`userId` null)
- Content is an XML string in `documents.content`:
  `<playbook>` → `<always-loaded>`, `<on-demand>`, `<global>` (`<cross-cutting>`, `<trigger>`…),
  and `<stage id label>` (`<entry>`, `<exit>`, `<instructions>`, `<trigger>`…).
- XML ⇄ ProseMirror JSON via
  [serialize-playbook-xml.ts](../../server/src/services/document-saving/serialize-playbook-xml.ts) /
  [parse-playbook-xml.ts](../../server/src/services/document-saving/parse-playbook-xml.ts).
  Structural TipTap nodes live in
  [structure/index.tsx](../app/../modules/documents/playbook/structure/index.tsx)
  (`globalSection`, `stageSection`, `stageEntry`, `stageExit`, `stageInstructions`, …) —
  each currently renders **with** its own border.
- Editing today: [PlaybookDocument.tsx](../modules/documents/playbook/PlaybookDocument.tsx)
  renders a Yjs-collaborative `<Document>` bound to **one** doc (`isOrgAop` flag picks which).
  Save = `forceFlush()` → server [playbook-compile.ts](../../server/src/services/document-saving/hooks/playbook-compile.ts)
  hook serializes JSON→XML, runs `compilePlaybook`, writes `content` + `metadata.compiled_playbook`.
  It already uses `manualFlushOnly` (Yjs debounce off) + a Save button + Cmd-S — but there is **no leave-guard**.
- The two docs are surfaced as **User** / **Org** folder rows in
  [PlaybookRoutingCard.tsx](../modules/brain/components/PlaybookRoutingCard.tsx),
  and the editor is reached at `/agents/playbook?aop={id}&org={0|1}`
  ([agents/playbook/page.tsx](../app/(routes)/agents/playbook/page.tsx)) under a `[Playbook | CRM | Settings]` tab set.

### Key constraint

`<Document>` binds exactly **one** `Y.Doc` per `documentId` via a refcounted provider, and
the **only** persistence path is the Yjs flush + compile hook. A single editor that spans
**two** rows cannot ride that infra. → The composite must be a **non-collaborative** TipTap
editor with an explicit save that splits the merged doc and persists both rows through a
**new mutation** (Yjs is not involved for the composite). This also gives requirements 6 & 7 for free.

## Proposed changes

### A. New structural nodes (frontend)

In `modules/documents/playbook/structure/`:

- `compositeSection` — borderless group wrapper. Attrs: `sectionKey` (`always-loaded` |
  `on-demand` | `global` | `stage`), `stageId`, `label`. Renders the section title only
  (requirement 4: **no** border). Content: `scopeSection+`.
- `scopeSection` — bordered sub-card. Attr: `scope` (`org` | `user`). Renders a small
  "Org" / "User" header chip + border. Content: `block*` (the section's inner nodes:
  cross-cutting/instructions/entry/exit/triggers/refs).

The existing `globalSection` / `stageSection` borders are **kept as-is** for the legacy
single-doc editor (`/agents/playbook` org tab is removed, but the nodes still back parse/serialize).
The composite editor uses the new wrappers instead of the bordered section nodes.

### B. Merge / split (frontend, pure functions + tests)

`modules/documents/playbook/composite-merge.ts`:

- `mergeComposite(userJson, orgJson): ProseMirrorJson` — walk both docs' top-level section
  nodes, key them (`always-loaded`, `on-demand`, `global`, `stage:{id}`), and for each key in
  union order (org order first, then user-only) emit a `compositeSection` containing an org
  `scopeSection` (if present) and a user `scopeSection` (if present). When building the **user**
  `scopeSection` for a `stage`, HIDE `stageEntry` / `stageExit` children (requirement 3) — they
  are filtered out of the editable view only.
- `splitComposite(compositeJson, originalUserJson?): { userJson, orgJson }` — inverse: for each
  `compositeSection`, move the org `scopeSection`'s children back under a reconstructed section node
  (`globalSection` / `stageSection` w/ attrs / `alwaysLoadedSection` / `onDemandSection`) into
  `orgJson.content`, and the user `scopeSection`'s children into `userJson.content`. The user stage's
  hidden `stageEntry` / `stageExit` are restored from `originalUserJson` (preserved, not deleted),
  so the user playbook round-trips faithfully. Sections with no content for a scope are omitted.

Both are pure ProseMirror-JSON transforms — unit-tested, no I/O.

### C. Server: load + split-save

Refactor the compile+persist core out of the Yjs hook into a reusable service
`services/playbook/persist-playbook.ts`:

- `compileAndPersistPlaybook(tx, doc, afterJson): { errors, warnings, compiled }` — the body
  currently inside [playbook-compile.ts](../../server/src/services/document-saving/hooks/playbook-compile.ts)
  (build `CompileContext`, `compilePlaybook`, write `content` + `metadata` on success).
  The Yjs hook becomes a thin caller; the new mutation reuses it.

New tRPC procedures (playbook router):

- `getCompositeDoc({ aopId })` → `{ userDocId, orgDocId|null, userJson, orgJson|null }`.
  Loads both rows, parses each `content` XML → ProseMirror JSON server-side (deps `@xmldom`
  + markdown parser live on the server), find-or-creates the user row like `getDoc` does.
- `saveComposite({ aopId, userJson, orgJson|null })` → `{ user: {errors,warnings}, org: {…} }`.
  Splits are done client-side (B); this just runs `compileAndPersistPlaybook` on each scope
  inside one transaction. If **either** scope errors, abort **both** writes and return the
  issues so the editor can show them (mirrors the hook's abort-on-error semantics).

### D. Composite editor component (frontend)

`modules/documents/playbook/CompositePlaybookDocument.tsx`:

- `useQuery(getCompositeDoc)` → `mergeComposite` → initial editor content.
- Plain `useEditor` (StarterKit subset already used for playbooks + structural extensions +
  new wrappers + `@`/`#`/`[[` extensions from `createPlaybookExtensions`). **No** `Collaboration`
  extension → inherently no autosave (requirement 6).
- Dirty tracking via `editor.on('update')`.
- Save: `splitComposite` → `saveComposite` mutation → show per-scope compile errors (reuse the
  existing `ErrorPanel`). Save button + Cmd-S, identical UX to today.
- Leave-guard (requirement 7): React Router `useBlocker` for in-app nav + `beforeunload`
  listener for tab close/refresh, both gated on `isDirty`.

### E. Wiring

- [agents/playbook/page.tsx](../app/(routes)/agents/playbook/page.tsx): the **Playbook** tab
  renders `CompositePlaybookDocument` (drop the `?org` param + `isOrgAop`). CRM / Settings tabs unchanged.
- [PlaybookRoutingCard.tsx](../modules/brain/components/PlaybookRoutingCard.tsx): card layout becomes:

  ```
  Playbook            ← top-level primary row → opens composite editor
  User    (folder)    ← resources/ + subagents/ (PLAYBOOK.md hidden — it lives in the composite)
  Org     (folder)    ← resources/ + subagents/
  ```

  The PLAYBOOK.md is extracted to the top as the primary item; the `User` / `Org` folders stay for
  everything else. `PlaybookMiniTree` is given a flag to exclude the root `PLAYBOOK.md` from each scope tree.

## Critical files

| File | Change |
|---|---|
| `modules/documents/playbook/structure/index.tsx` | + `compositeSection`, `scopeSection` nodes |
| `modules/documents/playbook/composite-merge.ts` (new) | `mergeComposite` / `splitComposite` + tests |
| `modules/documents/playbook/CompositePlaybookDocument.tsx` (new) | non-collab editor, save, leave-guard |
| `modules/documents/playbook/playbookExtensions.ts` | composite extension set |
| `server/src/services/playbook/persist-playbook.ts` (new) | `compileAndPersistPlaybook` |
| `server/src/services/document-saving/hooks/playbook-compile.ts` | call shared core |
| `server/src/trpc/routes/playbook.ts` (or aop.ts) | `getCompositeDoc`, `saveComposite` |
| `modules/brain/components/PlaybookRoutingCard.tsx` | single Playbook.md row |
| `app/(routes)/agents/playbook/page.tsx` | composite in Playbook tab |

## Phased implementation plan

- [x] **Phase 1 — Server core.** Extracted `compilePlaybookForDoc` + `writeCompiledPlaybook` into
      `services/playbook/persist-playbook.ts`; the Yjs hook now calls them (270 playbook tests still
      pass). Added `aop.getCompositePlaybook` + `aop.saveCompositePlaybook`.
- [x] **Phase 2 — Merge/split.** `composite-merge.ts` + 9 unit tests (merge→split is identity for
      org AND user; user-stage entry/exit hidden in the view but restored on save; empty scopes
      omitted) — all pass.
- [x] **Phase 3 — Nodes + editor.** `compositeSection` (borderless group) / `scopeSection` (bordered
      Org/User card) nodes; `CompositePlaybookDocument` — non-collaborative editor, manual save,
      per-scope error panel.
- [x] **Phase 4 — Leave-guard + no-autosave.** Non-collab editor (no Yjs ⇒ no autosave); `useBlocker`
      (in-app nav) + `beforeunload` (tab close) gated on dirty state.
- [x] **Phase 5 — Wiring.** Primary **Playbook** row on the AOP card (User/Org folders kept below,
      PLAYBOOK.md hidden via `hideRootPlaybook`); composite in the Playbook tab; `org` param dropped.

## Verification steps

- Open an AOP with both playbooks → one document, Global/Stages each show bordered Org + User
  cards inside a borderless group; user stages hide Entry/Exit (org stages keep them). Saving and
  reopening keeps the user playbook's Entry/Exit intact in storage (hidden, not deleted).
- Edit org-side text and user-side text, Save → reload → both persisted to their own rows; the
  legacy `/agents` org editor (if reached directly) shows the same org content.
- Introduce a compile error in one scope → Save blocked, error panel names the scope, **neither**
  row written.
- Type, then navigate away / close tab → warned about unsaved changes; Cancel keeps edits.
- Wait after typing without clicking Save → nothing is persisted (no autosave).
- AOP with no org playbook → composite shows user-only sections, Save works.
```