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