diff --git a/.env.example b/.env.example index 42ef528..4c18a82 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,9 @@ NEXUS_COLLAB_SERVER_PORT=1234 # Public WebSocket URL that browsers use to connect to the collab server. NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234 +# Persistent Documents Skill Library directory. Defaults to ./.nexus-library when unset. +NEXUS_LIBRARY_DATA_DIR=/path/to/nexus-library-data + # ── Authentication (OIDC/OAuth2) ───────────────────────────────────────────── # When all four AUTH_* variables below are set, Nexus requires SSO login. # When absent, the app is open (no authentication). diff --git a/.gitignore b/.gitignore index 84ea765..04a89c4 100644 --- a/.gitignore +++ b/.gitignore @@ -74,7 +74,7 @@ _workspace /graphify-out _workspace* -# Local runtime data stores (Brain / collab) +# Local runtime data stores (Brain / collab / library) /.nexus-brain/ /.nexus-collab/ - +/.nexus-library/ diff --git a/README.md b/README.md index b2fe9eb..ac1c0fd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,22 @@ Nexus is a visual workflow editor for designing, composing, and exporting AI wor - Export generated files as a ZIP or write them directly into a target folder - Include generated `run-.sh` and `run-.bat` helper scripts with exported workflow artifacts +### 📦 Documents Skill Library + +The Documents Skill Library is a versioned, sharable home for Markdown skills: + +- **Workspace** and **user-local** library scopes per workspace +- **Packs** group related skills, references, docs, rules, templates, examples, and assets +- **Real-time Markdown editing** of skill content through the Documents panel, backed by the same Hocuspocus collaboration server used for the canvas +- **Branch + fork**: derive a user-local fork from a workspace pack, three-way merge updates from base when needed, and resolve conflicts inline +- **Publish** at both pack-version (semver) and skill-version granularity; published versions are immutable snapshots +- **Workflow Skill nodes** can reference a library skill by `scope + packId + packVersion + skillId`. The reference is resolved live from the library or pinned to a published version +- **`.nexus` archive export**: a self-contained zip including `manifest.json`, `workflow.json`, every reachable pack's `manifest.json`, every referenced document's content, `runtime/resolver-metadata.json`, and `hashes.json` for integrity verification +- **Import** of `.nexus` archives and best-effort import of Agent Skills folders/zips + +Open the **Library** button in the editor header to access the panel. Persistent +storage lives under `NEXUS_LIBRARY_DATA_DIR` (defaults to `./.nexus-library`). + ### 📝 Content and Agent Authoring - Fullscreen editing for prompts and documents diff --git a/docker-compose.yml b/docker-compose.yml index db86ce6..308236b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: HOSTNAME: "0.0.0.0" NEXUS_BRAIN_DATA_DIR: /data/brain NEXUS_COLLAB_DATA_DIR: /data/collab + NEXUS_LIBRARY_DATA_DIR: /data/library # Do NOT set NEXT_PUBLIC_COLLAB_SERVER_URL — leave unset so the browser # auto-resolves to same-origin `wss:///collab`. Setting it bakes # a fixed URL into the JS bundle at build time. @@ -32,9 +33,11 @@ services: - "3000" volumes: - nexus_brain_data:/data/brain + - nexus_library_data:/data/library - nexus_collab_data:/data/collab restart: unless-stopped volumes: nexus_brain_data: nexus_collab_data: + nexus_library_data: diff --git a/docs/tasks/conditional_docs.md b/docs/tasks/conditional_docs.md index 3cdfed6..39f56f1 100644 --- a/docs/tasks/conditional_docs.md +++ b/docs/tasks/conditional_docs.md @@ -19,3 +19,15 @@ - When modifying `src/app/api/workspaces/*`, `src/lib/workspace/*`, `src/components/workspace/*`, or `src/app/workspace/[id]/**` - When changing routing between `/`, `/editor`, and `/workspace/[id]/workflow/[wid]` - When touching workspace auto-save, recent-workspace `localStorage` history, or stable Y.js room IDs for workspace workflows +- docs/tasks/documents-skill-library/doc-documents-skill-library.md + - Conditions: + - When working with the Documents Skill Library (packs, skills, documents, publish, fork/merge, validation, .nexus export/import) + - When modifying `src/app/api/library/*` routes or the `src/lib/library-store/*` layer + - When changing the workflow Skill node's `libraryRef` data or the SkillPickerDialog + - When updating the Documents panel UI or the per-document Y.js collab binding +- docs/tasks/feature-documents-skill-library-60d267bf/doc-feature-documents-skill-library-60d267bf.md + - Conditions: + - When working with the Documents Skill Library MVP (packs, skills, documents, publish, fork/merge, validation, `.nexus` export/import) + - When modifying `src/app/api/library/*` routes or the `src/lib/library-store/*` layer + - When changing the workflow Skill node's `libraryRef` data or the `SkillPickerDialog` + - When updating the Documents panel UI or the per-document Y.js collab binding for library docs diff --git a/docs/tasks/documents-skill-library/doc-documents-skill-library.md b/docs/tasks/documents-skill-library/doc-documents-skill-library.md new file mode 100644 index 0000000..f4f7427 --- /dev/null +++ b/docs/tasks/documents-skill-library/doc-documents-skill-library.md @@ -0,0 +1,133 @@ +# Documents Skill Library + +## What was built + +A new layer on top of the existing Brain-style filesystem document store that +manages **versioned packs of Markdown skills** with workspace and user-local +scopes, branch/fork flows, three-way Markdown merge, publish at pack and skill +granularity, immutable pack-version snapshots, a self-contained `.nexus` +workflow export, and best-effort Agent Skills compatibility. + +## Where things live + +### Server / storage layer +- `src/lib/library-store/config.ts` — reads `NEXUS_LIBRARY_DATA_DIR` (default + `./.nexus-library`); reuses `NEXUS_BRAIN_TOKEN_SECRET` for auth parity. +- `src/lib/library-store/types.ts` — record types + (`LibraryRecord`, `PackRecord`, `SkillRecord`, `LibraryDocumentRecord`, + `LibraryDocumentVersionRecord`, `PackVersionRecord`, `SkillVersionRecord`, + `BranchRecord`, `MergeRecord`, `ConflictRecord`, `LibraryManifest`, + `SkillBundle`, `ValidationWarning`). +- `src/lib/library-store/object-store.ts` — `ObjectStorage` interface + + `FilesystemObjectStorage` driver. Keys follow the spec layout: + `documents/{docId}/versions/{versionId}/content.md`, + `documents/{docId}/versions/{versionId}/metadata.json`, + `packs/{packId}/versions/{versionId}/manifest.json`, + `exports/{exportId}/workflow-export.nexus`. +- `src/lib/library-store/store.ts` — `LibraryStore` singleton with + `ensureLibraries`, `createPack`, `forkPack`, `softDeletePack`, `restorePack`, + `renamePack`, `searchPacks`, `createDocument`, + `saveDocumentVersion` (optimistic concurrency via `previousVersionId`), + `softDeleteDocument`, `createSkill`, `softDeleteSkill`, `publishPackVersion`, + `publishSkillVersion`, `mergeBaseIntoBranch`, `resolveMergeConflict`, + `resolveLive`, `compareDraftToPublished`, etc. +- `src/lib/library-store/manifest.ts` — `buildManifest()` emits the normalized + `ManifestSchemaV1` shape. +- `src/lib/library-store/merge.ts` — `threeWayTextMerge()` (no extra deps). +- `src/lib/library-store/validation.ts` — `validatePack()`, + `parseFrontmatter()`, `parseSkillFrontmatter()`. +- `src/lib/library-store/hashing.ts` — `sha256`, `computeContentHash`, + `buildHashManifest`. +- `src/lib/library-store/resolver.ts` — `resolveLive` and + `resolveFromArtifact` (for read-only resolution from a `.nexus` archive). +- `src/lib/library-store/export.ts` — `buildNexusArchive(workflowJson, …)` + walks the workflow for `libraryRef` references, snapshots each reachable pack + + skill + reference document into a JSZip archive, writes `manifest.json`, + `workflow.json`, `runtime/resolver-metadata.json`, and `hashes.json`. +- `src/lib/library-store/import.ts` — `importNexusArchive(buffer)` (verifies + every file's SHA-256 against `hashes.json` before importing) and + `importAgentSkillsFolder(buffer)` (best-effort). +- `src/lib/library-store/brain-migration.ts` — `migrateBrainDocsToUserLibrary` + helper that imports existing Brain docs into a new library pack. +- `src/lib/library-store/schemas.ts` — Zod-v4 schemas for manifest, frontmatter, + and every API payload. + +### API routes +Every route lives under `src/app/api/library/**/route.ts` and follows the +existing Brain-route pattern (token auth via `requireWorkspace`, +JSON in/out). Endpoints: +- `POST /api/library/session` — bootstrap or resume a library session +- `GET|POST /api/library/packs` — list / create +- `GET|PATCH|DELETE /api/library/packs/[packId]` — get / rename / soft-delete +- `POST /api/library/packs/[packId]/fork` +- `POST /api/library/packs/[packId]/merge-base` +- `GET|POST /api/library/packs/[packId]/versions` +- `GET /api/library/packs/[packId]/versions/[versionId]` +- `GET|POST /api/library/packs/[packId]/documents` +- `GET|PATCH|DELETE /api/library/packs/[packId]/documents/[docId]` +- `GET|POST /api/library/packs/[packId]/documents/[docId]/versions` +- `GET /api/library/packs/[packId]/documents/[docId]/versions/[versionId]/content` +- `GET|POST /api/library/packs/[packId]/skills` +- `GET|PATCH|DELETE /api/library/packs/[packId]/skills/[skillId]` +- `GET|POST /api/library/packs/[packId]/skills/[skillId]/versions` +- `GET|POST /api/library/packs/[packId]/merges/[mergeId]/resolve` +- `POST /api/library/resolve` +- `POST /api/library/import` (multipart form: file, format, scope) +- `POST /api/library/export` — streams a `.nexus` zip back + +### Client +- `src/lib/library-client.ts` — typed fetch wrappers around the API routes. +- `src/store/library-docs/store.ts` — Zustand store (`useLibraryDocsStore`) + exposing async actions matching the API surface plus pending-merge bookkeeping. + +### Collaboration +- `src/lib/collaboration/lib-doc-collab.ts` — `openLibraryDocRoom()` opens a + Hocuspocus room per library document with room name + `lib:{workspaceId}:{scope}:{packId}:{docId}` and binds a single `Y.Text` + (`content`). + +### UI +- `src/components/workflow/documents-panel/` — entire panel: + `panel.tsx`, `pack-browser.tsx`, `pack-detail.tsx`, `file-tree.tsx`, + `doc-editor.tsx`, `markdown-preview.tsx`, `skill-detail-panel.tsx`, + `publish-panel.tsx`, `branch-status-panel.tsx`, + `conflict-resolve-dialog.tsx`, `import-dialog.tsx`, plus + `use-documents-panel-controller.ts`, `constants.ts`, `types.ts`, `index.ts`. +- `src/components/workflow/properties/skill-picker-dialog.tsx` — Skill picker + dialog + `LibraryRefSection` used by the Skill node properties form. +- `src/components/workflow/header/session-actions.tsx` — adds a **Library** + button that dispatches `nexus:toggle-documents-panel`. +- `src/components/workflow/workflow-editor.tsx` — mounts `DocumentsPanel` and + listens for the toggle event. +- `src/components/workflow/generated-export-dialog.tsx` — adds a **Download + .nexus archive** action alongside the ZIP and folder export actions. + +### Skill node updates +- `src/types/workflow.ts` and `src/nodes/skill/types.ts` add a + `libraryRef: { scope, packId, packKey?, packVersion, skillId, skillKey?, skillName? } | null` + field. +- `src/nodes/skill/constants.ts` updates the default data and Zod schema to + include `libraryRef`. +- `src/nodes/skill/fields.tsx` adds a Library Reference section above the + inline fields. +- `src/nodes/skill/generator.ts` accepts an optional `resolvedBundle` and emits + the bundle's entrypoint content when a `libraryRef` is set. +- `src/nodes/skill/node.tsx` shows a pack badge when `libraryRef` is present. + +### Tests +- `src/lib/__tests__/library-store.test.ts` — full store test (AC-1..AC-8 spine). +- `src/lib/__tests__/library-merge.test.ts` — three-way merge edge cases. +- `src/lib/__tests__/library-validation.test.ts` — every Validation Requirements rule. +- `src/lib/__tests__/library-export.test.ts` — `.nexus` archive contents, + hash round-trip, artifact-only resolution (AC-10, AC-11). +- `src/lib/__tests__/library-import.test.ts` — round-trip + hash mismatch. +- `src/lib/__tests__/library-resolver.test.ts` — draft vs pinned semantics. +- `src/store/__tests__/library-docs.test.ts` — store smoke test. +- `src/nodes/skill/__tests__/generator.test.ts` — inline + library-ref paths. + +### Configuration +- `.env.example` — adds `NEXUS_LIBRARY_DATA_DIR`. +- `.gitignore` — ignores `.nexus-library/`. +- `scripts/start.sh` — provisions the library data dir alongside Brain/collab. +- `docker-compose.yml` — adds `nexus_library_data` volume mounted at + `/data/library` with `NEXUS_LIBRARY_DATA_DIR` env. diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/assets/01_main_editor.png b/docs/tasks/feature-documents-skill-library-60d267bf/assets/01_main_editor.png new file mode 100644 index 0000000..3ee02d7 Binary files /dev/null and b/docs/tasks/feature-documents-skill-library-60d267bf/assets/01_main_editor.png differ diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/assets/02_documents_panel_new_pack.png b/docs/tasks/feature-documents-skill-library-60d267bf/assets/02_documents_panel_new_pack.png new file mode 100644 index 0000000..e5572f5 Binary files /dev/null and b/docs/tasks/feature-documents-skill-library-60d267bf/assets/02_documents_panel_new_pack.png differ diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/assets/03_canvas_with_skill_node.png b/docs/tasks/feature-documents-skill-library-60d267bf/assets/03_canvas_with_skill_node.png new file mode 100644 index 0000000..db69805 Binary files /dev/null and b/docs/tasks/feature-documents-skill-library-60d267bf/assets/03_canvas_with_skill_node.png differ diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/assets/04_skill_node_properties.png b/docs/tasks/feature-documents-skill-library-60d267bf/assets/04_skill_node_properties.png new file mode 100644 index 0000000..f2c0113 Binary files /dev/null and b/docs/tasks/feature-documents-skill-library-60d267bf/assets/04_skill_node_properties.png differ diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/assets/05_skill_picker_dialog.png b/docs/tasks/feature-documents-skill-library-60d267bf/assets/05_skill_picker_dialog.png new file mode 100644 index 0000000..8532493 Binary files /dev/null and b/docs/tasks/feature-documents-skill-library-60d267bf/assets/05_skill_picker_dialog.png differ diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/doc-feature-documents-skill-library-60d267bf.md b/docs/tasks/feature-documents-skill-library-60d267bf/doc-feature-documents-skill-library-60d267bf.md new file mode 100644 index 0000000..f979cfa --- /dev/null +++ b/docs/tasks/feature-documents-skill-library-60d267bf/doc-feature-documents-skill-library-60d267bf.md @@ -0,0 +1,184 @@ +# Documents Skill Library + +**ADW ID:** 60d267bf +**Date:** 2026-04-25 +**Plan:** docs/tasks/feature-documents-skill-library-60d267bf/plan-feature-documents-skill-library-60d267bf.md + +## Overview + +Adds a versioned, sharable home for Markdown skills with workspace and user-local +scopes, branchable packs, real-time collaborative editing, publish flows, and a +self-contained `.nexus` archive export. Workflow Skill nodes can now reference a +library skill by `scope + packId + packVersion + skillId`, and exported workflows +bundle every reachable pack so they resolve offline. + +## Screenshots + +![Main editor](assets/01_main_editor.png) + +![Documents panel — new pack dialog](assets/02_documents_panel_new_pack.png) + +![Canvas with Skill node](assets/03_canvas_with_skill_node.png) + +![Skill node properties — library reference section](assets/04_skill_node_properties.png) + +![Skill picker dialog — pick scope, pack, skill, and version](assets/05_skill_picker_dialog.png) + +## What Was Built + +- Filesystem-backed library metadata store with RustFS-shaped object keys + (`src/lib/library-store/`) +- API surface under `src/app/api/library/**` covering session, packs, documents, + versions, skills, fork, merge-base, conflict resolve, publish, resolve, import, + and export +- Three-way Markdown merge with conflict records and an inline resolver dialog +- Pack/skill/document validation (entrypoint, frontmatter, duplicate ids, + broken references, manifest mismatches, deleted-but-referenced docs) +- Documents panel UI: scope tabs, pack browser, four-column pack detail with + file tree, Markdown editor, preview, skill detail/validation, and publish/branch + panels +- Per-document Y.js collab binding reusing the Hocuspocus server (room name + `lib:{workspaceId}:{scope}:{packId}:{docId}`) +- Skill node `libraryRef` data field plus a `SkillPickerDialog` for linking a + workflow Skill node to a library skill at a specific version (or `draft`) +- Skill generator routes content through the resolved library bundle when a ref + is set, falling back to inline behavior otherwise +- `.nexus` archive export with `manifest.json`, `workflow.json`, snapshotted pack + contents, `runtime/resolver-metadata.json`, and `hashes.json` +- Import for Nexus-native archives and best-effort Agent Skills folders/zips +- Test coverage for storage, merge, validation, export, import, resolver, and + the library Zustand store + +## Technical Implementation + +### Files Modified + +- `src/types/workflow.ts`: extends node-data union for the new `libraryRef` +- `src/nodes/skill/types.ts`: adds `SkillLibraryRef` interface to `SkillNodeData` +- `src/nodes/skill/constants.ts`: defaults `libraryRef: null`; Zod schema entry +- `src/nodes/skill/generator.ts`: accepts a resolved `SkillBundle` and emits + pack-derived `SKILL.md` content when a library ref is set +- `src/nodes/skill/fields.tsx`, `src/nodes/skill/node.tsx`: render library + reference badge + "Link to library skill" entry point +- `src/components/workflow/properties/skill-picker-dialog.tsx`: new dialog for + picking scope/pack/skill/version +- `src/components/workflow/generated-export-dialog.tsx`: adds the `.nexus` + archive option to the export flow +- `src/components/workflow/header/session-actions.tsx`, + `src/components/workflow/workflow-editor.tsx`: surfaces the Documents panel + toggle in the header +- `src/lib/collaboration/collab-doc.ts`: small adjustment compatible with the + new per-document collab binding +- `.env.example`, `.gitignore`, `docker-compose.yml`, `next.config.ts`, + `scripts/start.sh`, `README.md`: env, ignore, deploy, and docs wiring for + `NEXUS_LIBRARY_DATA_DIR` + +### New Files (selected) + +- `src/lib/library-store/store.ts`: `LibraryStore` class — packs, skills, + documents, versions, branches, merges, publish, resolve. Singleton via + `getLibraryStore()` mirroring `BrainStore` +- `src/lib/library-store/object-store.ts`: `ObjectStorage` interface + + filesystem driver with immutable version keys +- `src/lib/library-store/merge.ts`: line-based diff3 with structured conflicts +- `src/lib/library-store/manifest.ts`, `validation.ts`: normalized manifest + + full validation rule set +- `src/lib/library-store/resolver.ts`: live and artifact-mode skill resolution +- `src/lib/library-store/export.ts`, `import.ts`: `.nexus` archive build / + read with hash verification +- `src/lib/library-store/schemas.ts`: Zod-v4 schemas for manifest, frontmatter, + and every API payload +- `src/lib/library-client.ts`: typed fetch wrapper using the Brain token +- `src/lib/collaboration/lib-doc-collab.ts`: per-document Y.Text room opener +- `src/store/library-docs/`: Zustand slice for packs/skills/documents and + pending merges +- `src/types/library.ts`: shared types (`LibraryScope`, `PackRef`, `SkillRef`, + `SkillBundle`, `MergeState`, `ValidationWarning`) +- `src/components/workflow/documents-panel/`: panel, pack-browser, pack-detail, + file-tree, doc-editor, markdown-preview, skill-detail-panel, publish-panel, + branch-status-panel, conflict-resolve-dialog, import-dialog, controller hook +- `src/app/api/library/**/route.ts`: 18 routes covering session, packs, fork, + merge-base, documents, versions, skills, merges, resolve, import, export + +### Key Changes + +- Library metadata persists in a single `manifest.json` plus per-version files + under `NEXUS_LIBRARY_DATA_DIR` (default `./.nexus-library`); the layout matches + S3/RustFS keys so a future driver swap is single-file +- Document version writes use optimistic concurrency on `previousVersionId`; + stale heads are rejected with `StaleVersionError` +- Publishing a pack snapshots every current document head into + `pack_version_documents` and writes a normalized manifest at + `packs/{packId}/versions/{versionId}/manifest.json`; published versions are + immutable +- Skill node generation calls `resolveLive()` (or reads from the artifact at + export time) and feeds a `SkillBundle` into `generator.getSkillFile()`. With + no `libraryRef`, the inline path is preserved for back-compat +- Forking a workspace pack creates a user-local copy with `base_version_id` set + per document; "Merge latest base" runs three-way merge, producing clean + versions or `document_merges` + `document_conflicts` for conflicting edits +- The `.nexus` export traverses every Skill node's `libraryRef`, gathers the + pack manifest + closure of referenced docs/rules/assets, and writes + SHA-256 hashes for every file alongside resolver metadata so an importer can + resolve skills without the live store + +## How to Use + +1. Open the editor and click **Library** in the header toolbar to open the + Documents Skill Library panel. +2. In the **Workspace** tab, click **+ New** and create a pack (e.g. + `customer-support`). +3. Inside the pack, create a skill — this generates a `SKILL.md` entrypoint. + Edit the Markdown in the doc editor; saves create new immutable versions. +4. Add supporting documents under appropriate roles (references, docs, rules, + templates, examples, assets). +5. Click **Publish pack version**, enter a semver string, and confirm — the + panel will block publish if validation warnings exist. +6. To consume a skill in a workflow: drop a **Skill** node, open its properties, + click **Link to library skill**, choose scope → pack → skill → version, and + confirm. The node displays a pack badge and the generator pulls content from + the pack on export. +7. To share a workflow self-contained: open **Generate / Export** → choose + **Nexus archive** → download. Re-importing the `.nexus` file reproduces the + linked packs and skills byte-for-byte. +8. To branch: open a workspace pack, click **Fork to user-local**. Edit your + fork independently; when the workspace base advances, click **Merge latest + base** in the branch status panel to pull updates (conflicts open the + resolver dialog). + +## Configuration + +- `NEXUS_LIBRARY_DATA_DIR` — directory for the library manifest and version + objects (default `./.nexus-library`) +- `NEXUS_BRAIN_TOKEN_SECRET` — reused for library session HMAC tokens so the + library shares the Brain workspace identity +- `.nexus-library/` is gitignored +- `docker-compose.yml` mounts a `.nexus-library` volume mirroring the Brain dir + +## Testing + +- `bun run test:lib` — covers `library-store`, `library-merge`, + `library-validation`, `library-export`, `library-import`, `library-resolver` +- `bun run test:store` — covers the `library-docs` Zustand slice +- `bun run test:nodes` — Skill node generator (with and without `libraryRef`) +- `bun run typecheck` and `bun run lint` for type/lint regressions +- `bun run build` for export/route/wiring regressions +- Manual smoke (also captured in + `e2e-feature-documents-skill-library-60d267bf.md`): create a pack, add a + skill, save, publish `1.0.0`, fork to user-local, edit and republish base + as `1.1.0`, merge into the fork, link the skill in a workflow Skill node, + export `.nexus`, re-import, confirm the skill resolves with the same content + +## Notes + +- The codebase has no relational database; the same semantic schema is held in + a JSON manifest plus per-record files. Swapping in Postgres/SpacetimeDB later + is a storage-driver change. +- `.nexus` is provisional — exposed via a single helper for easy renaming. +- Scripts inside packs are stored as documents only; nothing in this feature + executes user-supplied content. +- Existing Brain documents stay under `/api/brain`; the library is a parallel + system. A `brain-migration.ts` helper exists for a one-click "import Brain + docs into user library" flow. +- Workflows that pre-date this feature continue to work — Skill nodes default + `libraryRef: null` and use the existing inline content path. diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/e2e-feature-documents-skill-library-60d267bf.md b/docs/tasks/feature-documents-skill-library-60d267bf/e2e-feature-documents-skill-library-60d267bf.md new file mode 100644 index 0000000..ec6baa0 --- /dev/null +++ b/docs/tasks/feature-documents-skill-library-60d267bf/e2e-feature-documents-skill-library-60d267bf.md @@ -0,0 +1,53 @@ +# E2E: Documents Skill Library + +## User Story + +As a workspace user, I can create a pack with two skills, fork it into my user-local +library, edit a skill in real-time with a collaborator, publish a pack version, +reference that skill in a workflow, and export a self-contained `.nexus` archive +that resolves skill content offline. + +## Test Steps (playwright-cli) + +1. Open app at `http://localhost:3000` (screenshot: `01-app-initial.png`). +2. Open Documents panel from header toolbar by clicking the **Library** button (screenshot: `02-empty-workspace-library.png`). +3. Click **+ New** in the workspace pack list, enter `customer-support` as the pack key and `Customer Support` as the name, click **Create** (screenshot: `03-pack-detail-empty.png`). +4. In the file tree, click **+ add** under `SKILL.md` and create `support-triage/SKILL.md` (screenshot: `04-skill-doc-created.png`). +5. Select the new SKILL.md, edit it to contain: + ```markdown + --- + name: support-triage + description: Classifies support requests. + --- + # Support Triage + + Initial instructions. + ``` + Click **Save snapshot** (screenshot: `05-version-history.png`). +6. Add `references/escalation-policy.md` via the file tree's References section (screenshot: `06-file-tree-two-docs.png`). +7. Click **+ New skill**, fill in `support-triage` for the key, `Support Triage` for the name, `Classifies support requests.` for description, select the SKILL.md as the entrypoint, click **Create skill**. +8. In the right column, enter `1.0.0` as the version and click **Publish version** (screenshot: `07-publish-success.png`). +9. Hover over the pack in the Workspace tab and click the **fork** icon to fork into the user-local library. Switch to the **User-local** tab — the pack appears with a "forked" badge (screenshot: `08-fork-badge.png`). +10. Switch back to the **Workspace** tab, select `customer-support`, edit `SKILL.md` to append `\n\nAdded v1.1 guidance.`, click **Save snapshot**, then publish version `1.1.0`. +11. Switch to the **User-local** tab, select the forked pack, click **Merge latest base** in the branch status panel — expect a clean merge toast and the appended text in the SKILL.md preview (screenshot: `09-merge-clean.png`). +12. Open a workflow, place a Skill node, in its properties open **Library Reference → Link to library skill**. Select `workspace / customer-support / support-triage @ 1.1.0` in the picker. The Skill node now displays a pack badge (screenshot: `10-skill-node-badge.png`). +13. Open **Generate / Export** dialog, click **Download .nexus archive**, capture the download (screenshot: `11-nexus-archive-download.png`). +14. Open the **Import** dialog in a fresh workspace (or via clearing the data dir), upload the `.nexus`, and confirm the skill resolves with the saved content (screenshot: `12-resolve-after-import.png`). + +## Success Criteria + +- Pack `customer-support` and skill `support-triage` appear with the exact names above. +- Published versions list contains both `1.0.0` and `1.1.0`. +- Forked pack shows `forked` and `behind base` before merge, becomes in-sync after merge. +- Workflow Skill node displays the pack badge `workspace/customer-support@1.1.0`. +- Exported `.nexus` archive contains: + - `manifest.json` + - `workflow.json` + - `libraries/workspace/packs/customer-support/skills/support-triage/SKILL.md` + - `hashes.json` + - `runtime/resolver-metadata.json` +- Re-importing the archive reproduces the SKILL.md content byte-for-byte (every entry in `hashes.json` matches the imported file's SHA-256). + +## Screenshot Capture Points + +Capture screenshots at each numbered step above. Save under `screenshots/feature-documents-skill-library-60d267bf/`. diff --git a/docs/tasks/feature-documents-skill-library-60d267bf/plan-feature-documents-skill-library-60d267bf.md b/docs/tasks/feature-documents-skill-library-60d267bf/plan-feature-documents-skill-library-60d267bf.md new file mode 100644 index 0000000..fc757ee --- /dev/null +++ b/docs/tasks/feature-documents-skill-library-60d267bf/plan-feature-documents-skill-library-60d267bf.md @@ -0,0 +1,536 @@ +# feature: documents-skill-library + +## Metadata +adw_id: `60d267bf` +issue_description: `docs/spec/spec-documents-skill-library.md` — Documents Skill Library (workspace + user-local library, packs/plugins, skills, real-time Markdown collaboration, branch/merge, publish, self-contained workflow export). See `agents/60d267bf/start-task.json`. + +## Description + +Nexus Workflow Studio currently has: + +- a Brain document store (filesystem, signed token, version snapshots, soft-delete) at `src/lib/brain/server.ts` + `src/app/api/brain/*` +- a local library (browser localStorage) storing saved workflows and reusable nodes at `src/lib/library.ts` +- node types `Skill`, `Document`, `Prompt`, `Script` with per-node generators that emit `SKILL.md`, docs, scripts under `.opencode|.pi|.claude` +- Hocuspocus-backed real-time collaboration (`src/lib/collaboration/collab-doc.ts`) syncing both workflow canvas and Brain docs via Y.js +- a Markdown editor via `@uiw/react-md-editor` used in the Brain panel +- no database (Postgres/SQLite/Drizzle are absent from `package.json`) + +The spec asks for a document-centered **library of Markdown skills** grouped into **packs/plugins**, with **workspace** and **user-local** scopes, **branch/fork** flows, **publish at pack and skill level**, **self-contained workflow export**, and **Agent Skills compatibility**. Workflows must reference skills by stable `scope + packId + packVersion + skillId`. + +Because this codebase has **no relational database**, we implement metadata in the same filesystem-backed pattern used by Brain: a JSON manifest + per-record files. RustFS-style immutable object-key conventions are followed inside the filesystem so a future swap to S3/RustFS is a storage-driver change. "Workspace" here aligns with the existing Brain workspace (one per signed token). + +## Objective + +Deliver the MVP slice of the Documents Skill Library spec: + +- workspace + user-local library packs +- file tree + Markdown document editor (real-time collab reusing Hocuspocus) +- skill folders with `SKILL.md` entrypoints +- normalized pack manifest +- validation (missing entrypoint, duplicate IDs, broken references, invalid frontmatter) +- branch/fork of a workspace pack into a user-local fork with three-way Markdown merge + conflict records +- publish pack version and publish skill version +- immutable pack-version snapshots bound to immutable document versions +- workflow Skill node references `scope + packId + packVersion + skillId` with a skill picker +- self-contained `.nexus` workflow export (zip) containing workflow definition, referenced packs/skills/documents, normalized manifests, and content hashes +- import of Nexus-native `.nexus` archives and best-effort Agent Skills folders/zips +- tests for storage, manifest building, three-way merge, publish, export/import integrity +- E2E coverage of the golden path + +## Problem Statement + +The editor has no way to author, version, organize, publish, or export a reusable **library of Markdown skills** that multiple workflows can reference by stable identity. Users currently duplicate skill content in every workflow, lose customizations when a base skill is updated, cannot share packs of related skills, and cannot produce a workflow export that is self-contained (the generated `SKILL.md` files live next to each workflow rather than inside a versioned, sharable pack). + +## Solution Statement + +Layer a **pack/skill metadata service** and a **library UI** on top of the existing Brain-style filesystem document store. Extend `src/lib/brain` into `src/lib/library-store` (backend) and a matching client. Use Hocuspocus + Y.js for live Markdown editing (one Y.Doc per library document, room name `lib:{workspaceId}:{libraryDocId}`). Add publish flows that snapshot documents into immutable version records. Update the Skill node data model to carry `scope + packId + packVersion + skillId` and resolve content through a runtime resolver that works both live and inside an exported artifact. Add a `.nexus` zip export pipeline that bundles the workflow + reachable packs. + +## Code Patterns to Follow + +Reference implementations in this repo: + +- **Brain filesystem store** (`src/lib/brain/server.ts`) — manifest.json + per-doc + per-version files, HMAC-signed tokens, soft-delete via `deletedAt`, `createVersion()` pattern, singleton accessor. Model the new library store after this. +- **Brain API routes** (`src/app/api/brain/session/route.ts`, `src/app/api/brain/documents/**`) — token auth via `requireWorkspace()` / `getBrainTokenFromHeaders()`, JSON responses, `[id]` and nested dynamic segments. Mirror this shape under `src/app/api/library/**`. +- **Knowledge store** (`src/store/knowledge/store.ts`) — async Zustand slice that wraps the Brain API; replicate for library state. +- **Collab wiring** (`src/lib/collaboration/collab-doc.ts`) — Y.Doc + Hocuspocus provider, awareness for presence, subscribe-observe dedupe pattern. Reuse for per-document Markdown editing: add a second `CollabDoc`-style binding for `Y.Text` per library doc. +- **Node generator module** (`src/nodes/skill/generator.ts`, `src/lib/workflow-generator.ts`) — `NodeGeneratorModule.getSkillFile()` signature and `GeneratedFile[]` aggregation. Extend to route skill content through the library resolver. +- **Export paths** (`src/lib/generation-targets.ts`, `buildGeneratedSkillFilePath()`) — keep sanitization helpers; add a new target for `.nexus` archives. +- **Marketplace** (`src/lib/marketplace/index.ts`) — plugin discovery pattern; reuse `.claude-plugin/marketplace.json` parsing when importing Agent Skills folders. +- **Workflow JSON validation** (`src/lib/workflow-validation.ts`, `src/lib/workflow-schema.ts`) — Zod-v4 schema style (note: import `"zod/v4"` per project rule). +- **Library panel UI** (`src/components/workflow/library-panel/panel.tsx` + `use-library-panel-controller.ts`) — controller/view split with shadcn primitives. + +## Relevant Files + +Use these files to complete the task: + +### Spec & guidance +- `/media/falfaddaghi/extradrive2/repo/NexusWorkflowStudio/trees/rustFS/docs/spec/spec-documents-skill-library.md` — full spec (the authoritative source). +- `/media/falfaddaghi/extradrive2/repo/NexusWorkflowStudio/trees/rustFS/rustfs-branchable-document-system.md` — branchable document substrate this feature layers on. +- `CLAUDE.md` — project rules: `@/*` alias, `zod/v4`, dark-theme, client-only storage caveats, update multiple touchpoints when changing nodes. +- `docs/tasks/persistent-brain/doc-persistent-brain.md` — prior patterns for filesystem doc store, signed tokens, version snapshots, share links. (Read because this task persists documents and extends `src/lib/brain/*`.) +- `docs/tasks/conditional_docs.md` — confirms the above doc is relevant. + +### Server / storage layer (to extend) +- `src/lib/brain/server.ts` — `BrainStore`, `createVersion()`, token helpers (`requireWorkspace`, `getBrainTokenFromHeaders`, `createShareToken`). Import helpers and follow pattern. +- `src/lib/brain/types.ts`, `src/lib/brain/client.ts`, `src/lib/brain/config.ts`, `src/lib/brain/schemas.ts` — reference types and schemas. +- `src/lib/brain/__tests__/*.test.ts` — test patterns for filesystem stores. +- `src/app/api/brain/**/route.ts` — route shape (token → workspaceId → action). + +### Node system (to modify) +- `src/types/node-types.ts` — node-type enum, library-saveable set. +- `src/types/workflow.ts` — node-data union (will extend `SkillNodeData`). +- `src/lib/node-registry.ts` — central registry. +- `src/nodes/skill/types.ts`, `src/nodes/skill/constants.ts`, `src/nodes/skill/fields.tsx`, `src/nodes/skill/generator.ts`, `src/nodes/skill/node.tsx`, `src/nodes/skill/index.ts`, `src/nodes/skill/script-utils.ts`. +- `src/nodes/document/types.ts`, `src/nodes/document/fields.tsx`, `src/nodes/document/generator.ts`, `src/nodes/document/utils.ts`. +- `src/nodes/prompt/types.ts` (already has `brainDocId`; model for pack reference). +- `src/components/nodes/skill-node.tsx`, `src/components/nodes/document-node.tsx` — renderers. +- `src/components/workflow/properties/type-specific-fields.tsx`, `.../skill-fields.tsx`, `.../document-fields.tsx`. +- `src/nodes/shared/registry-types.ts`. + +### Generation / export (to modify) +- `src/lib/workflow-generator.ts` — add pack/skill resolution path. +- `src/lib/generation-targets.ts` — add `nexus` archive target + helpers. +- `src/lib/generated-workflow-export.ts` — folder export integration. +- `src/lib/persistence.ts` — `getWorkflowExportContent()` / `getWorkflowExportFileName()`. +- `src/lib/workflow-generation/shared.ts`, `src/lib/workflow-generation/detail-sections.ts` — shared utilities. +- `src/lib/run-script-generator.ts` — run-script emission. + +### Store layer (to extend) +- `src/store/knowledge/store.ts` — async Zustand pattern. +- `src/store/workflow/store.ts`, `src/store/workflow/index.ts` — canvas store. +- `src/store/library/store.ts`, `src/store/library-store.ts` — library items (local). +- `src/store/collaboration/collab-store.ts`, `src/store/collaboration/awareness-store.ts`. + +### Collaboration (to extend) +- `src/lib/collaboration/collab-doc.ts` — Y.Doc singleton and Hocuspocus wiring. +- `src/lib/collaboration/object-store.ts` — per-room persistence. +- `src/lib/collaboration/config.ts`, `src/lib/collaboration/awareness-names.ts`. +- `scripts/collab-server.ts` — Hocuspocus server. + +### UI (to extend) +- `src/components/workflow/library-panel/panel.tsx`, `.../cards.tsx`, `.../constants.ts`, `.../types.ts`, `.../previews.tsx`, `.../use-library-panel-controller.ts`. +- `src/components/workflow/brain-panel/panel.tsx`, `.../doc-editor.tsx` — Markdown editor + version restore UI to mirror. +- `src/components/workflow/properties-panel.tsx` — properties host. +- `src/components/workflow/header.tsx`, `src/components/workflow/workflow-editor.tsx` — top-level wiring. +- `src/components/workflow/generated-export-dialog.tsx`, `src/components/workflow/import-dialog.tsx` — export/import UI to reuse. +- `src/components/ui/*` — shadcn primitives; do **not** hand-edit. + +### Tests +- `src/lib/__tests__/brain-server.test.ts`, `src/lib/__tests__/library.test.ts`, `src/lib/__tests__/collaboration-object-store.test.ts`, `src/lib/__tests__/generation-targets.test.ts`, `src/lib/__tests__/run-script-generator.test.ts`, `src/lib/__tests__/workflow-connections.test.ts`, `src/lib/__tests__/subworkflow-transfer.test.ts`. +- `src/store/__tests__/*.test.ts`. +- `src/nodes/document/__tests__/generator.test.ts`, `.../utils.test.ts`. + +### New Files + +**Server / storage layer:** +- `src/lib/library-store/config.ts` — reads `NEXUS_LIBRARY_DATA_DIR` (default `./.nexus-library`), reuses `NEXUS_BRAIN_TOKEN_SECRET`. +- `src/lib/library-store/types.ts` — `LibraryScope` (`"workspace" | "user"`), `LibraryRecord`, `PackRecord`, `SkillRecord`, `LibraryDocumentRecord`, `LibraryDocumentVersionRecord`, `PackVersionRecord`, `PackVersionDocumentRecord`, `SkillVersionRecord`, `MergeRecord`, `ConflictRecord`, `LibraryManifest`. +- `src/lib/library-store/object-store.ts` — S3/RustFS-shaped key layout on the filesystem: `documents/{docId}/versions/{versionId}/content.md`, `documents/{docId}/versions/{versionId}/metadata.json`, `packs/{packId}/versions/{versionId}/manifest.json`, `exports/{exportId}/workflow-export.nexus`. Abstract `ObjectStorage` interface matching the spec. +- `src/lib/library-store/store.ts` — `LibraryStore` class with `createLibrary`, `createPack`, `forkPack`, `renamePack`, `softDeletePack`, `listPacks`, `createDocument`, `saveDocumentVersion` (with optimistic concurrency via `previousVersionId`), `listDocuments`, `softDeleteDocument`, `renameDocument`, `moveDocument`, `createSkill`, `listSkills`, `mergeBaseIntoBranch`, `resolveMergeConflict`, `publishPackVersion`, `publishSkillVersion`, `resolveLiveSkill`, `listPackVersions`, `listSkillVersions`. Mirrors `BrainStore` patterns. +- `src/lib/library-store/manifest.ts` — normalizes frontmatter+file-tree into manifest JSON (FR-38, FR-39, FR-41). Schema version `1`. +- `src/lib/library-store/merge.ts` — three-way Markdown/plain-text merge (use `diff3` style; implement inline — no new dep). Returns `{ content, conflicts[] }` matching spec conflict record shape. +- `src/lib/library-store/hashing.ts` — SHA-256 helpers; `computeContentHash`, `buildHashManifest` (for FR-65). +- `src/lib/library-store/validation.ts` — validates missing entrypoint, missing description, duplicate IDs, invalid frontmatter, broken references (FR-36, validation requirements section). Returns typed warnings. +- `src/lib/library-store/resolver.ts` — runtime resolver: `resolveLive(ref)` and `resolveFromArtifact(ref, artifact)` (see spec "Runtime Resolution"). +- `src/lib/library-store/import.ts` — Nexus-native archive import + best-effort Agent Skills folder/zip import (FR-70, FR-71, FR-72). +- `src/lib/library-store/export.ts` — `.nexus` archive builder (JSZip). Gathers reachable packs, snapshots draft+published docs, writes `hashes.json`, `manifest.json`, `runtime/resolver-metadata.json`, `workflow.json`, `libraries/{scope}/packs/{packKey}/...`. +- `src/lib/library-store/schemas.ts` — Zod-v4 schemas for manifest, frontmatter, and API payloads. Use `import { z } from "zod/v4"`. +- `src/lib/library-store/index.ts` — public barrel. + +**API routes (Next.js App Router) under `src/app/api/library/`:** +- `session/route.ts` — bootstrap/resume library session (reuses Brain token). +- `packs/route.ts` — GET (list by scope) / POST (create). +- `packs/[packId]/route.ts` — GET / PATCH (rename/move scope) / DELETE (soft-delete). +- `packs/[packId]/fork/route.ts` — POST (fork workspace pack into user scope). +- `packs/[packId]/merge-base/route.ts` — POST (merge base changes into fork). +- `packs/[packId]/versions/route.ts` — GET (list) / POST (publish pack version). +- `packs/[packId]/versions/[versionId]/route.ts` — GET (resolve immutable pack). +- `packs/[packId]/documents/route.ts` — GET (list) / POST (create doc). +- `packs/[packId]/documents/[docId]/route.ts` — GET / PATCH (rename/move/role) / DELETE (soft-delete). +- `packs/[packId]/documents/[docId]/versions/route.ts` — GET (list) / POST (save version; optimistic concurrency). +- `packs/[packId]/documents/[docId]/versions/[versionId]/content/route.ts` — GET (raw content). +- `packs/[packId]/skills/route.ts` — GET / POST. +- `packs/[packId]/skills/[skillId]/route.ts` — GET / PATCH / DELETE. +- `packs/[packId]/skills/[skillId]/versions/route.ts` — GET (list) / POST (publish skill version). +- `packs/[packId]/merges/[mergeId]/resolve/route.ts` — POST (submit resolution). +- `resolve/route.ts` — POST `{ scope, packId, packVersion, skillId }` → resolved skill bundle (FR-54 Live Library Mode). +- `import/route.ts` — POST multipart/form-data (accepts `.nexus` archive or Agent Skills zip). +- `export/route.ts` — POST `{ workflowJson }` → `.nexus` archive stream. + +**Client / store:** +- `src/lib/library-client.ts` — typed fetch wrapper using the Brain token. +- `src/store/library-docs/store.ts` — Zustand slice for packs, skills, documents, current selection, pending merges. +- `src/store/library-docs/index.ts` — barrel. + +**Collaboration:** +- `src/lib/collaboration/lib-doc-collab.ts` — per-document `Y.Text` binding to the Markdown editor. Room name `lib:{workspaceId}:{scope}:{packId}:{docId}`. On save / publish / export the server-side `LibraryStore` writes a snapshot from the current Y.js text. + +**UI:** +- `src/components/workflow/documents-panel/panel.tsx` — library home: scope tabs (workspace / user-local), pack grid + search. +- `src/components/workflow/documents-panel/pack-browser.tsx` — pack list, fork, rename, archive, soft-delete, restore. +- `src/components/workflow/documents-panel/pack-detail.tsx` — four-column layout: file tree | editor | preview | skill details/validation/publish. +- `src/components/workflow/documents-panel/file-tree.tsx` — renders docs grouped by role (SKILL.md, references, docs, rules, templates, examples, assets). +- `src/components/workflow/documents-panel/doc-editor.tsx` — Markdown editor with Y.Text binding, presence, branch/base/head status badge. +- `src/components/workflow/documents-panel/markdown-preview.tsx` — rendered preview (reuse `@uiw/react-md-editor` preview component). +- `src/components/workflow/documents-panel/skill-detail-panel.tsx` — resolved skill bundle preview + validation warnings. +- `src/components/workflow/documents-panel/publish-panel.tsx` — publish pack / publish skill dialogs with diff against latest published. +- `src/components/workflow/documents-panel/branch-status-panel.tsx` — fork/branch state: clean / behind / conflict. +- `src/components/workflow/documents-panel/conflict-resolve-dialog.tsx` — per-conflict manual resolver. +- `src/components/workflow/documents-panel/import-dialog.tsx` — upload `.nexus` / Agent Skills zip. +- `src/components/workflow/documents-panel/use-documents-panel-controller.ts` — controller hook. +- `src/components/workflow/documents-panel/constants.ts`, `types.ts`, `index.ts`. +- `src/components/workflow/properties/skill-picker-dialog.tsx` — skill picker for the workflow Skill node (FR-49, FR-50). + +**Types:** +- `src/types/library.ts` — shared types for `LibraryScope`, `PackRef`, `SkillRef`, `SkillBundle`, `MergeState`, `ValidationWarning`. Import from `@/types/library`. + +### E2E test file (task below describes it; do NOT write it): +- `docs/tasks/feature-documents-skill-library-60d267bf/e2e-feature-documents-skill-library-60d267bf.md` + +## Implementation Plan + +### Phase 1: Foundation (data model + storage) + +Build the metadata store, object-storage abstraction, and Zod schemas **without** UI. Provide enough API to create a library, create a pack, create a skill + doc, save document versions, publish pack + skill versions, compute hashes, list versions. Add tests for every storage primitive. + +### Phase 2: Editor and collaboration + +Wire the documents panel, pack browser, file tree, Markdown editor, preview, and a per-document Y.Text collab binding that shares a Hocuspocus room. Show branch/head/base status. On save → snapshot document version through the store. + +### Phase 3: Skills and validation + +Add skill creation (SKILL.md entrypoint), normalized manifest generation, resolved skill preview, validation panel, skill picker dialog. Wire the Skill node to reference `scope + packId + packVersion + skillId`. Add live resolver that returns a `SkillBundle`. + +### Phase 4: Publishing and branch merges + +Publish pack version (snapshots every document version id into `pack_version_documents`). Publish skill version (snapshots skill doc closure). Fork a workspace pack into user-local (creates branch with `base_version_id` per document). Merge-base pulls latest pack-version base content into the fork and runs three-way merge per document; conflicts get `document_merges` records and a conflict-resolution UI. + +### Phase 5: Export and import + +Self-contained `.nexus` zip export including `manifest.json`, `workflow.json`, `libraries/{scope}/packs/...`, `runtime/resolver-metadata.json`, `hashes.json`. Integrate with existing Generate/Export flow as a new target alongside OpenCode / PI / Claude Code, plus a new archive option. Add import for `.nexus` archives and best-effort Agent Skills folders/zips. + +## Step by Step Tasks + +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Create library-store storage foundation (Phase 1, FR-13, FR-14, FR-21, FR-44, FR-45, FR-65) + +- Create `src/lib/library-store/config.ts`. Read `NEXUS_LIBRARY_DATA_DIR` (default `./.nexus-library`) and reuse `NEXUS_BRAIN_TOKEN_SECRET` via `getBrainConfig()` so tokens interoperate. +- Create `src/lib/library-store/object-store.ts`. Implement the `ObjectStorage` interface from the spec (`putObject`, `getObject`, `deleteObject`, `objectExists`) with a filesystem driver anchored at the data dir. Keys follow the spec: `documents/{docId}/versions/{versionId}/content.md`, `documents/{docId}/versions/{versionId}/metadata.json`, `packs/{packId}/versions/{versionId}/manifest.json`, `exports/{exportId}/workflow-export.nexus`. Keep keys immutable — no overwrite for version objects. +- Create `src/lib/library-store/hashing.ts` with `sha256(content: string|Buffer)` (use `node:crypto`). +- Create `src/lib/library-store/types.ts` with the record types listed above. Use `"workspace" | "user"` for `LibraryScope` and allow future extension. Every record includes `deletedAt: string | null`, `createdAt`, `updatedAt`, optional `metadata`. + +### 2. Create Zod schemas for manifest + API payloads (FR-37, FR-38, FR-39, FR-41) + +- Create `src/lib/library-store/schemas.ts`. `import { z } from "zod/v4"` — **not** `"zod"`. +- Define `ManifestSchemaV1` matching the spec "Manifest Shape" section: `schemaVersion: 1`, `packId`, `name`, `description`, `version`, `skills` (map of `skillId → { name, description, entrypoint, documents[], rules[] }`), `docs`, `rules`, `assets`. +- Define request/response schemas for every API route (create pack, create skill, save document version, publish pack, merge-base, resolve-conflict, resolve, import, export). +- Define `SkillFrontmatterSchema` for parsing `SKILL.md` YAML frontmatter (`name`, `description`, optional `compatibility`, optional `metadata`). + +### 3. Implement `LibraryStore` class (FR-1..FR-8, FR-9..FR-15, FR-30..FR-36, FR-42..FR-48) + +- Create `src/lib/library-store/store.ts` modelled on `BrainStore`. Singleton via `getLibraryStore()`; `resetLibraryStoreForTests()` export. +- Manifest at `{dataDir}/manifest.json` with fields: `version: 1`, `libraries[]`, `packs[]`, `skills[]`, `documents[]`, `versions[]`, `packVersions[]`, `packVersionDocuments[]`, `skillVersions[]`, `skillVersionDocuments[]`, `branches[]`, `merges[]`, `conflicts[]`. +- Methods: + - `createLibrary(workspaceId, scope, ownerUserId?)` — idempotent; returns existing workspace+user libraries if already present. + - `createPack(libraryId, input)` — unique `(libraryId, packKey)`; also creates an initial base branch. + - `forkPack(sourcePackId, targetLibraryId)` — copies pack record, copies skill + document rows, sets `base_pack_id`, `branch.base_version_id = source.current_version_id` per document. + - `softDeletePack` / `restorePack` / `renamePack` / `movePack`. + - `listPacks(libraryId, { includeDeleted })`. + - `searchPacks(libraryId, query)` — FR-5; matches name/description/tags/skill metadata/document content (simple linear scan for MVP). + - `createDocument(packId, { role, path, content, createdBy })` — stores object, creates `document`, `document_version` with `parentVersionId=null`. + - `saveDocumentVersion(docId, { content, previousVersionId, message, createdBy })` — FR-14 optimistic concurrency: reject if `previousVersionId` does not equal current head. + - `renameDocument` / `moveDocument` / `softDeleteDocument` / `restoreDocument`. + - `listDocuments(packId, { includeDeleted })`. + - `createSkill(packId, { skillKey, name, description, entrypointDocId })`. + - `listSkills(packId)` / `softDeleteSkill` / `renameSkill`. + - `publishPackVersion(packId, { version, createdBy })` — snapshots every current doc head into `pack_version_documents`, stores normalized `manifest.json` in RustFS-style key (`packs/{packId}/versions/{versionId}/manifest.json`), validates before committing (FR-48). + - `publishSkillVersion(skillId, { version, createdBy, linkToLatestPackVersion })` — snapshots entrypoint doc version + closure. + - `mergeBaseIntoBranch(packId, branchId, userId)` — runs merge per document (calls `merge.ts`). Clean merges create `document_versions`, update branch heads, write `document_merges` with `merged_cleanly`. Conflicts write `document_merges` `conflict` + `document_conflicts[]`. FR-22..FR-29. + - `resolveMergeConflict(mergeId, { resolvedContentByDocId, resolvedBy })` — completes the merge, updates heads. + - `resolveLive({ scope, packId, packVersion, skillId })` → `SkillBundle` (FR-54). + - `compareDraftToPublished(packId, publishedVersionId)` — diff counts per document (FR-46). +- Every version write computes SHA-256 content hash and stores it in metadata JSON alongside the object. +- Reuse `requireWorkspace(token)` from `src/lib/brain/server.ts` to bind libraries to Brain workspace ids. + +### 4. Implement three-way Markdown merge (FR-25, FR-26, FR-27, FR-29) + +- Create `src/lib/library-store/merge.ts` exporting `threeWayTextMerge(ancestor, theirs, yours)` returning `{ content, conflicts }`. Implement a simple line-based diff3 algorithm (no new dep) — common lines, identical changes are auto-kept; divergent edits produce a conflict block `<<<<<<< yours ... ======= ... >>>>>>> theirs` and a structured conflict entry with `{ path, conflictType: "text_conflict", ancestor, base, branch }`. +- Unit tests (see step 15) verify clean merge, same-line conflict, identical concurrent edit, deleted-vs-edited, add-vs-add. + +### 5. Implement manifest building + validation (FR-36, FR-37, FR-41, Validation Requirements section) + +- Create `src/lib/library-store/manifest.ts`. `buildManifest(pack, skills, documents)` returns a `ManifestSchemaV1`-compatible object. Auto-map entrypoints, references from skill records, role-tagged documents → `docs` / `rules` / `assets`. +- Create `src/lib/library-store/validation.ts`. Exports `validatePack(pack, skills, documents)` returning `ValidationWarning[]`. Cover every bullet in the spec "Validation Requirements" section: missing skill entrypoint, invalid `SKILL.md` frontmatter, missing description, duplicate pack/skill IDs, broken relative links (scan Markdown `[text](./path.md)` and image references), missing referenced documents, manifest path mismatch, deleted docs referenced by active skills, unresolved merge conflicts, missing export metadata, hash mismatch during import/open. +- Expose frontmatter parser (`parseSkillFrontmatter(content)`) used by `createSkill` and validation. + +### 6. Build API routes (FR-1..FR-48, FR-49..FR-54 subset) + +- For each route under `src/app/api/library/**/route.ts`, follow the Brain route pattern: read `x-brain-token` via `getBrainTokenFromHeaders()`, call `requireWorkspace()`, validate body against the Zod schema, call `getLibraryStore().()`, return JSON. +- Cover: session, packs (CRUD + fork + merge-base), documents (CRUD + versions with optimistic concurrency header `If-Match: ` **or** JSON body field), skills (CRUD + versions), publish flows, resolve, import, export. +- For `POST /api/library/import` accept multipart or JSON and dispatch to `src/lib/library-store/import.ts`. +- For `POST /api/library/export` accept `{ workflowJson }`, call `src/lib/library-store/export.ts`, stream the zip back with `Content-Type: application/zip` and `Content-Disposition: attachment; filename=".nexus"`. + +### 7. Update Skill node data model (FR-31, FR-49, FR-50, FR-51, FR-52) + +- In `src/nodes/skill/types.ts`, extend `SkillNodeData` with optional fields: + - `libraryRef?: { scope: "workspace" | "user"; packId: string; packVersion: string | "draft"; skillId: string } | null;` + - Keep existing `skillName`, `description`, `promptText`, etc. as fallback for inline skills (back-compat for existing workflows). +- Update `src/nodes/skill/constants.ts` default data to `libraryRef: null`. +- Update `src/components/workflow/properties/skill-fields.tsx` to render a "Link to library skill" section that opens the new `SkillPickerDialog` (see step 12). When a library ref is set, display pack name, pack version, skill name, deprecation/soft-delete warnings, and allow "Detach" to revert to inline mode. +- Update `src/nodes/skill/generator.ts` `getSkillFile()` — when `libraryRef` is set and non-null, resolve via `resolveLive()` (live mode) or embed from the export artifact. For MVP the live resolution happens at export time; the live canvas preview still reads `libraryRef` to fetch content from the store (async) and falls back to `promptText` for offline editing. +- Update `src/nodes/skill/node.tsx` renderer to show a pack badge when `libraryRef` is present. +- Update `src/types/workflow.ts` to keep the union consistent. +- Update `src/lib/workflow-generator.ts` → `collectAgentFiles()` to accept resolved skill bundles (pass them through the generator call). For export-target generation of `.opencode|.pi|.claude`, skill content must come from the resolved skill bundle (live or pinned) when a ref is set. + +### 8. Build library-docs Zustand store (FR-3, FR-7, FR-8, FR-52, FR-53) + +- Create `src/store/library-docs/store.ts` with actions mirroring the API: `bootstrap()`, `listPacks(scope)`, `createPack`, `forkPack`, `loadPackDetail`, `createDocument`, `saveDocument`, `renameDocument`, `softDeleteDocument`, `createSkill`, `publishPack`, `publishSkill`, `mergeBase`, `resolveConflict`, `resolveLiveSkill`. +- Track `pendingMerges` and `fork behind base` flags per pack. +- Subscribe to `useKnowledgeStore` session token to authenticate requests. + +### 9. Wire per-document Y.js collab binding (FR-10, FR-16..FR-21) + +- Create `src/lib/collaboration/lib-doc-collab.ts` exporting `openLibraryDocRoom(workspaceId, scope, packId, docId)` which returns `{ provider, yText, destroy }`. Room name: `lib:{workspaceId}:{scope}:{packId}:{docId}`. Reuse `getCollabServerUrl()` and `HocuspocusProvider`. +- Hocuspocus already persists arbitrary room state through `src/lib/collaboration/object-store.ts`; no server changes required unless debounce tuning is needed for Markdown editing (keep current 1000 ms default). +- When the document editor mounts, open the room; bind the MD editor's controlled value to `yText`. On "Save snapshot" (explicit button or 5-second idle), take the current `yText.toString()` and POST to `/api/library/packs/.../documents/.../versions` with `previousVersionId` from the last-known head. On success update the head in the store and show the new version in the version history list. +- Broadcast editing presence via awareness (reuse `getOrCreateUserName`, color generator). FR-19. + +### 10. Build Documents panel UI (FR-9, FR-11, FR-12, FR-15, FR-34, FR-35) + +- Create `src/components/workflow/documents-panel/` per the New Files list. Follow the pattern of `library-panel/` (controller hook + view components, shadcn primitives, dark-theme tokens from `src/lib/theme.ts`). +- Panel layout per spec: `[ Library / Packs ] [ File Tree ] [ Editor / Preview ] [ Skill Details / Validation ]`. +- Editor reuses `@uiw/react-md-editor` (already in deps) with Y.Text binding. +- File tree groups by document role (skill entrypoint, references, docs, rules, examples, templates, manifests, assets) — FR-12. +- Show per-document status: branch name, base version id (short), head version id (short), "clean / behind / conflict" badge — FR-15. +- Add/Rename/Move/Soft-delete/Restore document actions — FR-11. +- Markdown preview and resolved skill preview (use `resolver.resolveLive()`). + +### 11. Build library home + pack browser (FR-1..FR-7) + +- `documents-panel/panel.tsx`: scope tabs (Workspace / User-local), pack grid (reuse `library-panel/cards.tsx` visual patterns). "New pack" button opens a dialog. +- `pack-browser.tsx`: search input (FR-5), fork button on workspace packs (FR-6), "behind base" badge on forked packs (FR-7), soft-delete + restore affordances. +- `pack-detail.tsx`: opens `pack-detail` view hosting the four-column layout. + +### 12. Build skill picker + workflow Skill node wiring (FR-49, FR-50) + +- Create `src/components/workflow/properties/skill-picker-dialog.tsx` — a Radix `` listing packs (grouped by scope), skills per pack, version dropdown (`draft` + published semver list). Selecting a skill writes `libraryRef` onto the Skill node and closes the dialog. +- Emit warnings on the node when the referenced pack/skill is soft-deleted/deprecated — FR-52. +- Update `src/components/workflow/properties/skill-fields.tsx` to add a "Library reference" section above the inline fields. + +### 13. Build validation panel (FR-36, FR-52) + +- `skill-detail-panel.tsx` shows `ValidationWarning[]` from `validatePack()`. Warnings re-run on every document save. + +### 14. Build publish UI (FR-42, FR-43, FR-46, FR-47, FR-48) + +- `publish-panel.tsx`: + - "Publish pack version" dialog: version string input (enforce semver regex via Zod), diff summary against latest published version (FR-46), validation must be clean before submit (FR-48). + - "Publish skill version" dialog: similar. + - List of published versions with badges for deprecated/soft-deleted; allow deprecate / undeprecate / soft-delete (FR-47). + +### 15. Build branch / fork / merge UI (FR-6, FR-7, FR-8, FR-22..FR-29) + +- `branch-status-panel.tsx` surfaces base pack version, branch head, and the "behind / clean / conflict" state computed by `store.getForkState(packId)`. +- "Merge latest base" button calls `mergeBase`. +- `conflict-resolve-dialog.tsx` renders each `document_conflicts` row with three side-by-side columns (ancestor, base, branch) and an editable resolution textarea; on submit call `resolveMergeConflict` with `{ resolvedContentByDocId }`. + +### 16. Build export pipeline (.nexus archive) (FR-55..FR-67) + +- Create `src/lib/library-store/export.ts`. Build a JSZip archive: + - `manifest.json` — top-level archive manifest: `{ schemaVersion: 1, workflowName, createdAt, createdBy, packs[], skills[], resolverMode }`. + - `workflow.json` — normalized workflow JSON (FR-56). + - `libraries/{scope}/packs/{packKey}/manifest.json` — normalized pack manifest (FR-58, FR-59, FR-61). + - `libraries/{scope}/packs/{packKey}/skills/{skillKey}/SKILL.md` — entrypoint doc content at export time. + - `libraries/{scope}/packs/{packKey}/docs/**` and `rules/**`, `assets/**` — referenced content (FR-57, FR-60). + - `runtime/resolver-metadata.json` — map `{ scope, packId, packVersion, skillId } → artifact path`, including content-hash references (FR-61). + - `hashes.json` — map path → sha256 (FR-65). +- Traversal: for each Skill node with `libraryRef`, walk pack manifest to include every referenced document + pack-level docs/rules/assets needed by the skill. Snapshot drafts at current head; snapshot published versions at their version ID (FR-63, FR-64). +- Integrity validation step before returning the archive (FR-66). +- Add `buildGeneratedArchiveFilePath(workflowName)` helper to `src/lib/generation-targets.ts`. +- Hook into the existing export dialog (`generated-export-dialog.tsx`) as a new archive option beside OpenCode / PI / Claude Code. + +### 17. Build import pipeline (FR-68..FR-72) + +- `src/lib/library-store/import.ts`: + - `importNexusArchive(buffer)` — validates `manifest.json` schema, re-hashes every file against `hashes.json` (FR-67), imports packs into the current workspace library, preserving `packKey`/`skillKey`. Collisions prompt for rename or merge (MVP: rename with `-imported-{n}`). + - `importAgentSkillsFolder(buffer)` — best-effort: for each `SKILL.md` found, create a skill + document; parse frontmatter; include sibling `references/**` etc. Sets pack provenance flag `external: true` (Security Requirements section). +- `POST /api/library/import` route accepts multipart file uploads and dispatches. +- Add `import-dialog.tsx` hooking the UI up. + +### 18. Add Brain / Hocuspocus integration to collab-doc.ts (FR-17, FR-18) + +- Extend `src/lib/collaboration/collab-doc.ts` OR create a sibling for library docs (prefer sibling to keep single-responsibility). Rooms are per-document (many small rooms) vs. the single workflow room. +- No server change required — Hocuspocus server is generic (`scripts/collab-server.ts`). + +### 19. Deprecate/migrate overlap with Brain "documents" (optional, but clarify) + +- Existing Brain documents stay under `/api/brain`. The library is a new, parallel system. The `Prompt` and `Document` nodes retain their `brainDocId` field for existing users. +- Add a migration helper `src/lib/library-store/brain-migration.ts` that offers a one-click "Import Brain docs into user library" button in the Documents panel (optional MVP polish; leave a TODO note if time-boxed out). + +### 20. Add workspace env + startup wiring + +- Update `.env.example` with `NEXUS_LIBRARY_DATA_DIR=.nexus-library`. +- Update `scripts/start.sh` if it predefines Brain/collab dirs — mirror for library dir. Otherwise rely on defaults. +- Update `.gitignore` to add `.nexus-library/`. +- Update `Dockerfile` / `docker-compose.yml` if they mount Brain/collab dirs — add `.nexus-library` volume. + +### 21. Write storage tests (Phase 1 deliverable; repeat extending through Phase 4) + +- `src/lib/__tests__/library-store.test.ts`: + - Create workspace + user libraries (FR-1, FR-2). + - Create/list/soft-delete/restore pack (FR-3, FR-4). + - Create two skills + shared doc (AC-1). + - Save doc version rejects on stale `previousVersionId` (FR-14). + - Fork pack copies rows and sets `base_version_id` (FR-6, AC-2). + - Merge base into fork with no conflicts (AC-5). + - Merge base with same-line conflict produces `document_merges` + `document_conflicts` (AC-6). + - Resolve conflict updates branch head (FR-27). + - Publish pack version snapshots current doc heads (FR-42, AC-7). + - Publish skill version snapshots entrypoint closure (FR-43, AC-8). + - Soft-delete published version remains resolvable (FR-47, AC-12 precondition). +- `src/lib/__tests__/library-merge.test.ts` covers diff3 edge cases. +- `src/lib/__tests__/library-validation.test.ts` covers every rule in the Validation Requirements list (FR-36). +- `src/lib/__tests__/library-export.test.ts`: + - Builds a `.nexus` archive containing workflow + packs + hashes. + - Hash validation round-trip (FR-65, FR-66, AC-10). + - Resolver works against artifact without live library (AC-11, FR-62). +- `src/lib/__tests__/library-import.test.ts`: + - Round-trip Nexus-native export + import (FR-68, FR-70). + - Best-effort Agent Skills zip with single `SKILL.md` (FR-71, FR-72). +- `src/lib/__tests__/library-resolver.test.ts`: + - Live resolution of a draft pack returns current head content. + - Live resolution of a pinned published version ignores subsequent draft edits. + +### 22. Write store tests + +- `src/store/__tests__/library-docs.test.ts`: + - `listPacks` populates state. + - `saveDocument` marks version history. + - `mergeBase` updates pending conflicts. + - Skill picker selection updates workflow node data. + +### 23. Write node generator test updates + +- Update `src/nodes/skill/__tests__/generator.test.ts` (create if missing under `src/nodes/skill/`): + - With no `libraryRef`, output matches existing inline behavior. + - With `libraryRef` and a resolved bundle, output uses pack-content Markdown and frontmatter. + - Deprecated/soft-deleted ref emits a warning path in generation log (non-fatal). + +### 24. Describe E2E test file (do NOT create) + +Describe `docs/tasks/feature-documents-skill-library-60d267bf/e2e-feature-documents-skill-library-60d267bf.md` with: + +- **User Story** — "As a workspace user, I can create a pack with two skills, fork it into my user-local library, edit a skill in real-time with a collaborator, publish a pack version, reference that skill in a workflow, and export a self-contained `.nexus` archive that resolves skill content offline." +- **Test Steps** (playwright-cli): + 1. Open app at `http://localhost:3000` (screenshot). + 2. Open Documents panel from header toolbar (screenshot: empty workspace library). + 3. Click "New pack", enter `customer-support`, create (screenshot: pack detail view). + 4. Create skill `support-triage` with description "Classifies support requests." (screenshot: SKILL.md editor). + 5. Edit `SKILL.md` to contain `# Support Triage\nInitial instructions.`, save (screenshot: version history row appears). + 6. Add supporting document `references/escalation-policy.md` (screenshot: file tree shows two docs). + 7. Publish pack version `1.0.0` (screenshot: publish success toast; published list entry). + 8. Fork pack to user-local library (screenshot: user-local tab shows forked pack with "cleanly derived from workspace 1.0.0" badge). + 9. Back in workspace pack: edit `SKILL.md` to append `\nAdded v1.1 guidance.`, save, publish `1.1.0`. + 10. Switch to user-local fork, click "Merge latest base", expect clean merge (screenshot: merged doc contains appended text). + 11. Open a workflow, place a Skill node, open skill picker, select `workspace / customer-support / support-triage @ 1.1.0` (screenshot: Skill node shows pack badge). + 12. Open "Generate / Export" dialog, choose "Nexus archive", click export, capture download (screenshot: dialog showing archive summary). + 13. Open the resulting `.nexus` via the import dialog in a fresh workspace (or a simulated one), confirm the skill resolves with the saved content (screenshot: resolved skill preview). +- **Success Criteria**: + - Pack and skill appear with the exact names above. + - Published versions list contains `1.0.0` and `1.1.0`. + - Forked pack shows "behind base" before merge and "in sync" after. + - Workflow Skill node displays pack `customer-support @ 1.1.0`. + - Exported archive contains `workflow.json`, `libraries/workspace/packs/customer-support/skills/support-triage/SKILL.md`, `hashes.json`, `runtime/resolver-metadata.json`. + - Re-importing the archive reproduces the skill content byte-for-byte (hash match). +- **Screenshot capture points** — as listed at every numbered step. + +### 25. Update README and CLAUDE-style quickstart + +- Extend `README.md` with a new "Documents Skill Library" section summarizing workspace + user-local packs, publish, fork/merge, and `.nexus` export. +- Update `CLAUDE.md` if the node count changes (it currently says "more than 11 nodes"; new node types are not added, just new node data fields — double-check wording). +- Update `docs/tasks/conditional_docs.md` to add a condition pointing to a new doc `docs/tasks/documents-skill-library/doc-documents-skill-library.md` (write the doc as a short "what was built" summary mirroring the Brain doc). + +### 26. Validation pass + +- Run the `Validation Commands` below; fix any failure before closing. + +## Testing Strategy + +### Unit Tests + +- **Storage**: `library-store.test.ts` — all pack / skill / doc / version CRUD, soft-delete, optimistic concurrency rejection, publish semantics, branch/merge, conflict resolution. +- **Merge**: `library-merge.test.ts` — diff3 clean/same-line conflict/add-add/delete-edit/trailing-newline cases. +- **Validation**: `library-validation.test.ts` — all Validation Requirements entries. +- **Export/Import**: `library-export.test.ts`, `library-import.test.ts` — round-trip Nexus-native, hash mismatches rejected, Agent Skills best-effort import. +- **Resolver**: `library-resolver.test.ts` — live (draft head) vs. pinned published behavior; artifact-mode resolution. +- **Schemas**: manifest + API payload Zod schemas round-trip. +- **Skill node generator**: library-ref path writes SKILL.md from resolved bundle; inline path unchanged. + +### Edge Cases + +- Optimistic concurrency: two clients saving to the same document — second must receive a stale-head rejection. +- Pack-level merge where half the documents merge cleanly and half conflict — merge record must aggregate (FR-28). +- Soft-deleted published version: already-exported artifact still resolves (AC-12). +- Draft pack reference in workflow export: content snapshots at export time (FR-63), live edits after export do not affect the artifact. +- Forking a pack whose base is also forked (two-step derivation). +- Importing a pack with duplicate `packKey` — rename with suffix. +- Agent Skills folder with missing frontmatter — creates skill with placeholder description and emits a validation warning. +- Large document (> 1 MB) — streaming save (no explicit size cap per PD-8). +- Content hash mismatch during import — reject with clear error (FR-67). +- Skill node references a pack version that no longer exists — generator emits inline placeholder + warning, not a crash. +- Y.js save when Hocuspocus is offline: queue locally via `localStorage`, flush on reconnect. + +## Acceptance Criteria + +Every spec AC must be covered. Each bullet below is a pass/fail criterion. + +- **AC-1**: A workspace user can create a pack with two skills and shared docs. Verified by: Documents panel manual flow + `library-store.test.ts::createPackWithTwoSkills`. +- **AC-2**: A user can fork that pack into their user-local library. Verified by: fork button + `library-store.test.ts::forkPack`. +- **AC-3**: Two users can edit the same Markdown document in real time. Verified by: E2E + manual browser test with two tabs using Hocuspocus-backed `Y.Text`. +- **AC-4**: Saving creates immutable document versions backed by the filesystem object store (RustFS-compatible key layout). Verified by: `library-store.test.ts::versionSnapshot` asserts file presence under `documents/{id}/versions/{v}/content.md`. +- **AC-5**: A workspace pack update can be merged into a user-local fork. Verified by: `library-store.test.ts::mergeBaseClean`. +- **AC-6**: A conflicting Markdown edit creates a conflict record instead of overwriting. Verified by: `library-merge.test.ts::sameLineConflict` + `library-store.test.ts::mergeBaseConflict`. +- **AC-7**: A pack version can be published and later resolved by workflow nodes. Verified by: `library-store.test.ts::publishPackThenResolve`. +- **AC-8**: An individual skill version can be published and resolved. Verified by: `library-store.test.ts::publishSkillThenResolve`. +- **AC-9**: A workflow node can reference `scope + packVersion + skillId`. Verified by: Skill node type + generator tests + skill picker UI. +- **AC-10**: A workflow export includes all required documents, skills, packs, metadata, assets, and hashes. Verified by: `library-export.test.ts::fullArchiveContents`. +- **AC-11**: The exported artifact can resolve skill references without access to the live library. Verified by: `library-export.test.ts::resolveFromArtifactWithoutStore` (no network / store calls). +- **AC-12**: Soft-deleting a live document or version does not break an already-exported workflow artifact. Verified by: `library-export.test.ts::softDeleteAfterExport`. + +Additional acceptance: + +- `bun run typecheck`, `bun run lint`, `bun run test`, and `bun run build` all pass. +- No new `any` usage outside of documented cast patterns. +- All Zod imports use `"zod/v4"`. +- Dark-theme shadcn primitives reused; no hand-edits to `src/components/ui/`. +- `.nexus-library/` added to `.gitignore`. +- Documents panel opens without an active OpenCode connection (FR editor must work offline, per CLAUDE.md "offline/editor-only flows"). + +## Validation Commands + +Execute every command to validate the work is complete with zero regressions. + +- `bun run typecheck` — TypeScript strict check. +- `bun run lint` — ESLint (`--max-warnings=0`). +- `bun run test` — full Bun test suite. +- `bun run test:lib` — focused lib tests (fast signal while iterating). +- `bun run test:store` — focused store tests. +- `bun run test:nodes` — node generator + utility tests. +- `bun run build` — Next.js production build (wiring / route / export regressions surface here). +- Manual smoke (in browser at `http://localhost:3000`): + - Start both servers: `bun run collab:server` and `bun run dev`. + - Open Documents panel → create workspace pack → create skill → edit `SKILL.md` → save → see new version row. + - Fork pack → edit base → merge clean into fork. + - Publish pack version `1.0.0` → reference in Skill node → export `.nexus` archive → re-import → confirm resolved skill matches. + +## Notes + +- **No database in this repo.** The spec suggests Postgres tables; we persist the same semantic schema in a single JSON manifest plus per-record files under `.nexus-library/`. Downstream swap to Postgres is a storage-driver change. +- **RustFS substitution.** The repo name suggests a future swap from filesystem to RustFS/S3. Keep `object-store.ts` behind an `ObjectStorage` interface so a replacement driver is a one-file change. +- **Auth parity with Brain.** Library sessions reuse the Brain workspace id + HMAC token so share links and presence work with existing token plumbing. +- **`.nexus` extension is provisional** — spec open question 7. Expose a helper so the extension can be changed in one place. +- **Agent Skills style.** `SKILL.md` frontmatter remains compatible with existing generated output (`name`, `description`, `compatibility`, `metadata`) — keep the same frontmatter keys the current skill generator produces. +- **Draft reference in live workflow** (FR-51, PD-6) requires the resolver to read current heads; leave a comment in the resolver noting that exports always snapshot (FR-63) so production runs are deterministic. +- **No executable scripts at runtime** (spec Security Requirements; Non-Goals). Scripts are stored as documents with role `asset` or `script` but are not executed by this feature. +- **Complexity classification: complex.** All phased sections above are included. If scope pressure arises, the hard-to-defer set is: data model, minimal UI, publish, and export — these are the AC spine (AC-1, 4, 7, 9, 10, 11). Branch/merge (AC-2, 5, 6) and user-local library are required by the MVP scope section and should not be deferred. diff --git a/scripts/start.sh b/scripts/start.sh index f190979..d926e61 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -5,6 +5,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$ROOT_DIR/.env.local" DEFAULT_BRAIN_DIR="$ROOT_DIR/.nexus-brain" DEFAULT_COLLAB_DIR="$ROOT_DIR/.nexus-collab" +DEFAULT_LIBRARY_DIR="$ROOT_DIR/.nexus-library" log() { printf '[start] %s\n' "$*" @@ -56,11 +57,14 @@ start_local() { local collab_dir="${NEXUS_COLLAB_DATA_DIR:-$DEFAULT_COLLAB_DIR}" local collab_port="${NEXUS_COLLAB_SERVER_PORT:-1234}" local collab_url="${NEXT_PUBLIC_COLLAB_SERVER_URL:-ws://localhost:${collab_port}}" + local library_dir="${NEXUS_LIBRARY_DATA_DIR:-$DEFAULT_LIBRARY_DIR}" mkdir -p "$brain_dir" mkdir -p "$collab_dir" + mkdir -p "$library_dir" log "Using Brain data directory: $brain_dir" log "Using collaboration data directory: $collab_dir" + log "Using Library data directory: $library_dir" if [[ -z "$brain_secret" ]]; then brain_secret="$(random_hex)" @@ -71,12 +75,14 @@ start_local() { ensure_env_value "NEXUS_COLLAB_DATA_DIR" "$collab_dir" ensure_env_value "NEXUS_COLLAB_SERVER_PORT" "$collab_port" ensure_env_value "NEXT_PUBLIC_COLLAB_SERVER_URL" "$collab_url" + ensure_env_value "NEXUS_LIBRARY_DATA_DIR" "$library_dir" export NEXUS_BRAIN_DATA_DIR="$brain_dir" export NEXUS_BRAIN_TOKEN_SECRET="$brain_secret" export NEXUS_COLLAB_DATA_DIR="$collab_dir" export NEXUS_COLLAB_SERVER_PORT="$collab_port" export NEXT_PUBLIC_COLLAB_SERVER_URL="$collab_url" + export NEXUS_LIBRARY_DATA_DIR="$library_dir" cd "$ROOT_DIR" diff --git a/src/app/api/library/export/route.ts b/src/app/api/library/export/route.ts new file mode 100644 index 0000000..557272f --- /dev/null +++ b/src/app/api/library/export/route.ts @@ -0,0 +1,36 @@ +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { buildNexusArchive } from "@/lib/library-store/export"; +import { exportRequestSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const body = await request.json(); + const parsed = exportRequestSchema.safeParse(body); + if (!parsed.success) { + return new Response(JSON.stringify({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + const { buffer, archiveName } = await buildNexusArchive({ + workflowJson: parsed.data.workflowJson, + workflowName: parsed.data.workflowName, + createdBy: parsed.data.createdBy, + }); + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + "content-type": "application/zip", + "content-disposition": `attachment; filename="${archiveName}"`, + }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: (error as Error).message }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/library/import/route.ts b/src/app/api/library/import/route.ts new file mode 100644 index 0000000..e0d1052 --- /dev/null +++ b/src/app/api/library/import/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { importNexusArchive, importAgentSkillsFolder } from "@/lib/library-store/import"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + const workspaceId = await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const formData = await request.formData(); + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return NextResponse.json({ error: "Missing file upload" }, { status: 400 }); + } + const format = (formData.get("format") as string | null) ?? "nexus"; + const scope = (formData.get("scope") as "workspace" | "user" | null) ?? "workspace"; + const buffer = Buffer.from(await file.arrayBuffer()); + + if (format === "agent-skills") { + const packKey = (formData.get("packKey") as string | null) ?? "imported-skills"; + const result = await importAgentSkillsFolder({ + buffer, + workspaceId, + ownerUserId: scope === "user" ? "default-user" : null, + scope, + packKey, + }); + return NextResponse.json({ packs: result.packs }); + } + + const result = await importNexusArchive({ + buffer, + workspaceId, + ownerUserId: scope === "user" ? "default-user" : null, + scope, + }); + return NextResponse.json({ packs: result.packs }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/documents/[docId]/route.ts b/src/app/api/library/packs/[packId]/documents/[docId]/route.ts new file mode 100644 index 0000000..c7aa3be --- /dev/null +++ b/src/app/api/library/packs/[packId]/documents/[docId]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { updateDocumentSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; docId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId, docId } = await context.params; + const documents = await getLibraryStore().listDocuments(packId, { includeDeleted: true }); + const document = documents.find((d) => d.id === docId); + if (!document) return NextResponse.json({ error: "Document not found" }, { status: 404 }); + const content = await getLibraryStore().readDocumentContent(docId, document.currentVersionId); + return NextResponse.json({ document, content }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function PATCH(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { docId } = await context.params; + const body = await request.json(); + const parsed = updateDocumentSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const store = getLibraryStore(); + let document = await store.listDocuments((await store.readManifest()).documents.find((d) => d.id === docId)?.packId ?? ""); + document = document.filter((d) => d.id === docId); + let updated = document[0]; + if (parsed.data.path) { + updated = await store.renameDocument(docId, parsed.data.path); + } + return NextResponse.json({ document: updated }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { docId } = await context.params; + await getLibraryStore().softDeleteDocument(docId); + return NextResponse.json({ deleted: true }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/documents/[docId]/versions/[versionId]/content/route.ts b/src/app/api/library/packs/[packId]/documents/[docId]/versions/[versionId]/content/route.ts new file mode 100644 index 0000000..13982b5 --- /dev/null +++ b/src/app/api/library/packs/[packId]/documents/[docId]/versions/[versionId]/content/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; docId: string; versionId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { docId, versionId } = await context.params; + const content = await getLibraryStore().readDocumentContent(docId, versionId); + if (content === null) return NextResponse.json({ error: "Version not found" }, { status: 404 }); + return NextResponse.json({ content }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} diff --git a/src/app/api/library/packs/[packId]/documents/[docId]/versions/route.ts b/src/app/api/library/packs/[packId]/documents/[docId]/versions/route.ts new file mode 100644 index 0000000..4935869 --- /dev/null +++ b/src/app/api/library/packs/[packId]/documents/[docId]/versions/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore, StaleVersionError } from "@/lib/library-store/store"; +import { saveDocumentVersionSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; docId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { docId } = await context.params; + const versions = await getLibraryStore().listVersions(docId); + return NextResponse.json({ versions }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { docId } = await context.params; + const body = await request.json(); + const parsed = saveDocumentVersionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const ifMatch = request.headers.get("If-Match"); + const previousVersionId = ifMatch ?? parsed.data.previousVersionId; + const version = await getLibraryStore().saveDocumentVersion(docId, { + content: parsed.data.content, + previousVersionId, + message: parsed.data.message, + createdBy: parsed.data.createdBy, + metadata: parsed.data.metadata, + }); + return NextResponse.json({ version }); + } catch (error) { + if (error instanceof StaleVersionError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/documents/route.ts b/src/app/api/library/packs/[packId]/documents/route.ts new file mode 100644 index 0000000..327cf58 --- /dev/null +++ b/src/app/api/library/packs/[packId]/documents/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { createDocumentSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const documents = await getLibraryStore().listDocuments(packId); + return NextResponse.json({ documents }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const body = await request.json(); + const parsed = createDocumentSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const result = await getLibraryStore().createDocument(packId, parsed.data); + return NextResponse.json(result); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/fork/route.ts b/src/app/api/library/packs/[packId]/fork/route.ts new file mode 100644 index 0000000..64da9e5 --- /dev/null +++ b/src/app/api/library/packs/[packId]/fork/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { forkPackSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string }>; +} + +export async function POST(request: Request, context: RouteContext) { + try { + const workspaceId = await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const body = await request.json().catch(() => ({})); + const parsed = forkPackSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const store = getLibraryStore(); + const { workspace, user } = await store.ensureLibraries(workspaceId, parsed.data.targetScope === "user" ? "default-user" : null); + const lib = parsed.data.targetScope === "user" ? user : workspace; + if (!lib) return NextResponse.json({ error: "Target library unavailable" }, { status: 400 }); + const pack = await store.forkPack(packId, lib.id, { packKey: parsed.data.packKey }); + return NextResponse.json({ pack }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/merge-base/route.ts b/src/app/api/library/packs/[packId]/merge-base/route.ts new file mode 100644 index 0000000..730bc7b --- /dev/null +++ b/src/app/api/library/packs/[packId]/merge-base/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { mergeBaseSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string }>; +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const body = await request.json().catch(() => ({})); + const parsed = mergeBaseSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const merge = await getLibraryStore().mergeBaseIntoBranch(packId, { initiatedBy: parsed.data.initiatedBy }); + return NextResponse.json({ merge }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/merges/[mergeId]/resolve/route.ts b/src/app/api/library/packs/[packId]/merges/[mergeId]/resolve/route.ts new file mode 100644 index 0000000..ad3fca5 --- /dev/null +++ b/src/app/api/library/packs/[packId]/merges/[mergeId]/resolve/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { resolveConflictSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; mergeId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { mergeId } = await context.params; + const conflicts = await getLibraryStore().listConflicts(mergeId); + return NextResponse.json({ conflicts }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { mergeId } = await context.params; + const body = await request.json(); + const parsed = resolveConflictSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const merge = await getLibraryStore().resolveMergeConflict(mergeId, parsed.data); + return NextResponse.json({ merge }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/route.ts b/src/app/api/library/packs/[packId]/route.ts new file mode 100644 index 0000000..87aa804 --- /dev/null +++ b/src/app/api/library/packs/[packId]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { updatePackSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const url = new URL(request.url); + const validate = url.searchParams.get("validate"); + const store = getLibraryStore(); + const pack = await store.getPack(packId); + if (validate === "1" && pack) { + const warnings = await store.validatePackById(packId); + return NextResponse.json({ pack, warnings }); + } + return NextResponse.json({ pack }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function PATCH(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const body = await request.json(); + const parsed = updatePackSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const store = getLibraryStore(); + const pack = await store.renamePack(packId, parsed.data); + return NextResponse.json({ pack }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + await getLibraryStore().softDeletePack(packId); + return NextResponse.json({ deleted: true }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/skills/[skillId]/route.ts b/src/app/api/library/packs/[packId]/skills/[skillId]/route.ts new file mode 100644 index 0000000..b90a2ad --- /dev/null +++ b/src/app/api/library/packs/[packId]/skills/[skillId]/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { updateSkillSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; skillId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId, skillId } = await context.params; + const skills = await getLibraryStore().listSkills(packId); + const skill = skills.find((s) => s.id === skillId); + if (!skill) return NextResponse.json({ error: "Skill not found" }, { status: 404 }); + return NextResponse.json({ skill }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function PATCH(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { skillId } = await context.params; + const body = await request.json(); + const parsed = updateSkillSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const skill = await getLibraryStore().updateSkill(skillId, parsed.data); + return NextResponse.json({ skill }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { skillId } = await context.params; + await getLibraryStore().softDeleteSkill(skillId); + return NextResponse.json({ deleted: true }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/skills/[skillId]/versions/route.ts b/src/app/api/library/packs/[packId]/skills/[skillId]/versions/route.ts new file mode 100644 index 0000000..3e8310f --- /dev/null +++ b/src/app/api/library/packs/[packId]/skills/[skillId]/versions/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { publishSkillSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; skillId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { skillId } = await context.params; + const versions = await getLibraryStore().listSkillVersions(skillId); + return NextResponse.json({ versions }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { skillId } = await context.params; + const body = await request.json(); + const parsed = publishSkillSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const skillVersion = await getLibraryStore().publishSkillVersion(skillId, parsed.data); + return NextResponse.json({ skillVersion }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/skills/route.ts b/src/app/api/library/packs/[packId]/skills/route.ts new file mode 100644 index 0000000..06c73d2 --- /dev/null +++ b/src/app/api/library/packs/[packId]/skills/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { createSkillSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const skills = await getLibraryStore().listSkills(packId); + return NextResponse.json({ skills }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const body = await request.json(); + const parsed = createSkillSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const skill = await getLibraryStore().createSkill(packId, parsed.data); + return NextResponse.json({ skill }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/[packId]/versions/[versionId]/route.ts b/src/app/api/library/packs/[packId]/versions/[versionId]/route.ts new file mode 100644 index 0000000..765c511 --- /dev/null +++ b/src/app/api/library/packs/[packId]/versions/[versionId]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string; versionId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId, versionId } = await context.params; + const versions = await getLibraryStore().listPackVersions(packId); + const packVersion = versions.find((v) => v.id === versionId); + if (!packVersion) return NextResponse.json({ error: "Pack version not found" }, { status: 404 }); + const manifest = await getLibraryStore().getObjectStorage().getObjectAsString(packVersion.manifestKey); + return NextResponse.json({ packVersion, manifest: manifest ? JSON.parse(manifest) : null }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} diff --git a/src/app/api/library/packs/[packId]/versions/route.ts b/src/app/api/library/packs/[packId]/versions/route.ts new file mode 100644 index 0000000..9b94080 --- /dev/null +++ b/src/app/api/library/packs/[packId]/versions/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore, ValidationError } from "@/lib/library-store/store"; +import { publishPackSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ packId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const versions = await getLibraryStore().listPackVersions(packId); + return NextResponse.json({ versions }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request, context: RouteContext) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const { packId } = await context.params; + const body = await request.json(); + const parsed = publishPackSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const packVersion = await getLibraryStore().publishPackVersion(packId, parsed.data); + return NextResponse.json({ packVersion }); + } catch (error) { + if (error instanceof ValidationError) { + return NextResponse.json({ error: error.message, warnings: error.warnings }, { status: 400 }); + } + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/packs/route.ts b/src/app/api/library/packs/route.ts new file mode 100644 index 0000000..515bb65 --- /dev/null +++ b/src/app/api/library/packs/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { createPackSchema, libraryScopeSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const workspaceId = await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const url = new URL(request.url); + const scopeParam = url.searchParams.get("scope"); + const scope = scopeParam ? libraryScopeSchema.parse(scopeParam) : "workspace"; + const store = getLibraryStore(); + const { workspace, user } = await store.ensureLibraries(workspaceId, scope === "user" ? "default-user" : null); + const lib = scope === "user" ? user : workspace; + if (!lib) return NextResponse.json({ packs: [] }); + const packs = await store.listPacks(lib.id); + return NextResponse.json({ packs }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 401 }); + } +} + +export async function POST(request: Request) { + try { + const workspaceId = await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const body = await request.json(); + const parsed = createPackSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const store = getLibraryStore(); + const { workspace, user } = await store.ensureLibraries(workspaceId, parsed.data.scope === "user" ? "default-user" : null); + const lib = parsed.data.scope === "user" ? user : workspace; + if (!lib) return NextResponse.json({ error: "Library not available" }, { status: 400 }); + const pack = await store.createPack(lib.id, { + packKey: parsed.data.packKey, + name: parsed.data.name, + description: parsed.data.description, + tags: parsed.data.tags, + createdBy: parsed.data.createdBy, + metadata: parsed.data.metadata, + }); + return NextResponse.json({ pack }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/resolve/route.ts b/src/app/api/library/resolve/route.ts new file mode 100644 index 0000000..4215e72 --- /dev/null +++ b/src/app/api/library/resolve/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; +import { resolveLiveSchema } from "@/lib/library-store/schemas"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + await requireWorkspace(getBrainTokenFromHeaders(request.headers)); + const body = await request.json(); + const parsed = resolveLiveSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + const bundle = await getLibraryStore().resolveLive(parsed.data); + return NextResponse.json({ bundle }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }); + } +} diff --git a/src/app/api/library/session/route.ts b/src/app/api/library/session/route.ts new file mode 100644 index 0000000..00986a2 --- /dev/null +++ b/src/app/api/library/session/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { sessionRequestSchema } from "@/lib/library-store/schemas"; +import { getBrainStore, getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; +import { getLibraryStore } from "@/lib/library-store/store"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const body = await request.json().catch(() => ({})); + const parsed = sessionRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid payload" }, { status: 400 }); + } + + const headerToken = getBrainTokenFromHeaders(request.headers); + const token = parsed.data.token ?? headerToken ?? null; + let workspaceId: string; + try { + workspaceId = await requireWorkspace(token); + } catch { + const session = await getBrainStore().createOrResumeSession(null, null); + workspaceId = session.workspaceId; + } + + const store = getLibraryStore(); + await store.ensureLibraries(workspaceId, parsed.data.ownerUserId ?? null); + const libraries = await store.listLibraries(workspaceId); + return NextResponse.json({ + workspaceId, + ownerUserId: parsed.data.ownerUserId ?? null, + libraries, + }); +} diff --git a/src/components/workflow/documents-panel/branch-status-panel.tsx b/src/components/workflow/documents-panel/branch-status-panel.tsx new file mode 100644 index 0000000..b95d5cf --- /dev/null +++ b/src/components/workflow/documents-panel/branch-status-panel.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { PackRecord } from "@/lib/library-store/types"; + +interface BranchStatusPanelProps { + pack: PackRecord; + hasPendingMerge: boolean; + onMergeBase: () => void; + onResolveConflicts?: () => void; +} + +export function BranchStatusPanel({ pack, hasPendingMerge, onMergeBase, onResolveConflicts }: BranchStatusPanelProps) { + const isFork = pack.basePackId !== null; + if (!isFork) return null; + return ( +
+
+ forked + {pack.basePackId && ( + base: {pack.basePackId.slice(0, 8)} + )} +
+
+ + {hasPendingMerge && onResolveConflicts && ( + + )} +
+
+ ); +} diff --git a/src/components/workflow/documents-panel/conflict-resolve-dialog.tsx b/src/components/workflow/documents-panel/conflict-resolve-dialog.tsx new file mode 100644 index 0000000..a1e3545 --- /dev/null +++ b/src/components/workflow/documents-panel/conflict-resolve-dialog.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import type { ConflictRecord } from "@/lib/library-store/types"; + +interface ConflictResolveDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conflicts: ConflictRecord[]; + onResolve: (resolved: Record) => void; +} + +export function ConflictResolveDialog({ open, onOpenChange, conflicts, onResolve }: ConflictResolveDialogProps) { + const initial = useMemo(() => { + const init: Record = {}; + for (const c of conflicts) init[c.docId] = c.branchContent ?? ""; + return init; + }, [conflicts]); + const [overrides, setOverrides] = useState>({}); + const resolutions = { ...initial, ...overrides }; + const setResolutions = (next: Record) => setOverrides(next); + + return ( + + + + Resolve Merge Conflicts + +
+ {conflicts.map((conflict) => ( +
+
doc: {conflict.docId}
+
+
+
Ancestor
+
{conflict.ancestorContent}
+
+
+
Base
+
{conflict.baseContent}
+
+
+
Branch
+
{conflict.branchContent}
+
+
+