mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ed2a203f | ||
|
|
fbfef42145 | ||
|
|
59c8a690da | ||
|
|
b115b75d83 | ||
|
|
3d1393e8fc | ||
|
|
99e6d4f1ad | ||
|
|
f91ea6efc4 | ||
|
|
32d313b0c8 | ||
|
|
fb6ece7a7e | ||
|
|
18e641d79a | ||
|
|
af5b767933 | ||
|
|
937b3fc65f | ||
|
|
5f1e151e84 | ||
|
|
ff63668c1e | ||
|
|
7b135b80b2 | ||
|
|
acc078064b | ||
|
|
77bf3ea419 | ||
|
|
5f6169e1d3 |
No files matched your search
@@ -0,0 +1,90 @@
|
||||
# Agent skills
|
||||
|
||||
This folder is the single home for the skills our coding agents use.
|
||||
Each skill is a folder with a `SKILL.md` inside — a short instruction
|
||||
manual that an agent loads only when it needs it.
|
||||
|
||||
One copy serves every tool:
|
||||
|
||||
- **opencode** reads this folder directly.
|
||||
- **Claude Code** reads it through the `.claude/skills` symlink.
|
||||
- **Codex** reads it directly.
|
||||
|
||||
To change how the agents behave, edit the `SKILL.md` here. There is no
|
||||
second copy to keep in sync.
|
||||
|
||||
## How the skills are organized
|
||||
|
||||
**Flows** are the six skills you invoke by name. Each one covers one step
|
||||
in the life of a change: plan it, review the plan, implement it, review
|
||||
the code, open the pull request.
|
||||
|
||||
**References** hold the quality standards. A flow's reviewer loads them;
|
||||
you rarely touch them directly.
|
||||
|
||||
**Procedures** define how one concrete step is done — a plan document, an
|
||||
issue, a commit. Flows call them, but they also work on their own.
|
||||
|
||||
**Utilities** are small helpers for everyday work: search, file lookup,
|
||||
JSON, REPL access, and so on.
|
||||
|
||||
## Flows
|
||||
|
||||
| Skill | What it does | When you would say |
|
||||
|---|---|---|
|
||||
| [`make-a-plan`](skills/make-a-plan/SKILL.md) | Researches the task, writes an implementation plan, asks you the open questions in plain language, and saves the plan to `.agents/plans/`. | "make a plan for the token refresh bug" |
|
||||
| [`review-plan`](skills/review-plan/SKILL.md) | Evaluates a plan before anyone writes code: completeness, ordering, risks. Approves it or asks for changes. | "review this plan before we start" |
|
||||
| [`implement-plan`](skills/implement-plan/SKILL.md) | Shows you the full flow first — the issue and branch it will create (or the branch it continues on), the execution style, and the task checklist — and, after your go-ahead, executes a ready plan. Default: every task, one commit. On request ("step by step"): one task, one commit, your confirmation between tasks. | "implement the plan" · "step by step, one commit per task" |
|
||||
| [`review-code`](skills/review-code/SKILL.md) | Reviews a diff, branch, or PR and returns findings ranked by impact. | "review my changes before I push" |
|
||||
| [`create-pr`](skills/create-pr/SKILL.md) | Opens a pull request for the current branch — with checks on base branch, commits, issue, and push state — or updates an existing PR's title and description. | "open a PR for this branch" |
|
||||
| [`resolve-git-conflicts`](skills/resolve-git-conflicts/SKILL.md) | Untangles merge or rebase conflicts: explains both sides, proposes a resolution, applies it after you approve. Never runs `git rebase --continue`. | "resolve these conflicts" |
|
||||
|
||||
## References
|
||||
|
||||
| Skill | What it holds |
|
||||
|---|---|
|
||||
| [`plan-review-criteria`](skills/plan-review-criteria/SKILL.md) | The plan review rubric: six axes, severity levels, approval standard, output format. The `review-plan` reviewer loads it. |
|
||||
| [`code-review-criteria`](skills/code-review-criteria/SKILL.md) | The code review rubric: five axes, core principles (DRY, KISS, YAGNI), severity format, verdict. The `review-code` reviewer loads it. |
|
||||
|
||||
## Procedures
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| [`planner`](skills/planner/SKILL.md) | The spec of a good plan: context, architecture decisions, tasks with acceptance criteria, checkpoints. Used by `make-a-plan`. |
|
||||
| [`create-issue`](skills/create-issue/SKILL.md) | Creates a GitHub issue that follows Penpot conventions. Used by `implement-plan`; also works on its own. |
|
||||
| [`create-commit`](skills/create-commit/SKILL.md) | Makes a commit the Penpot way: emoji subject, clear body, `AI-assisted-by` trailer. Used by `implement-plan`; also works alone when you say "commit this". |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| [`bat-cat`](skills/bat-cat/SKILL.md) | Read files in the terminal with syntax highlighting and line numbers. |
|
||||
| [`fd-find`](skills/fd-find/SKILL.md) | Find files by name or pattern, respecting `.gitignore`. |
|
||||
| [`ripgrep`](skills/ripgrep/SKILL.md) | Fast content search with regular expressions. |
|
||||
| [`jq-json-processor`](skills/jq-json-processor/SKILL.md) | Slice, filter, and reshape JSON output. |
|
||||
| [`nrepl-eval`](skills/nrepl-eval/SKILL.md) | Run Clojure or ClojureScript code in the live REPL sessions (backend and frontend). |
|
||||
| [`taiga`](skills/taiga/SKILL.md) | Look up Penpot issues, user stories, and tasks in Taiga. |
|
||||
| [`testing`](skills/testing/SKILL.md) | The repo's testing rules and TDD workflow, loaded before writing tests. |
|
||||
| [`security-and-hardening`](skills/security-and-hardening/SKILL.md) | Security checks for code that handles user input, auth, or external services. |
|
||||
| [`ste`](skills/ste/SKILL.md) | Rewrites prose in Simplified Technical English. Loads only when you name it. |
|
||||
| [`refine-prompt`](skills/refine-prompt/SKILL.md) | Rewrites a rough prompt into a clearer one. Never runs the prompt. |
|
||||
| [`update-changelog`](skills/update-changelog/SKILL.md) | Regenerates `CHANGES.md` from a GitHub milestone. |
|
||||
|
||||
## A typical round
|
||||
|
||||
1. `/make-a-plan` — you get a plan and a saved file in `.agents/plans/`.
|
||||
2. `/review-plan` — a second opinion; approve or request changes.
|
||||
3. `/implement-plan` — the code gets written and committed. Starting from a base branch, it also opens the GitHub issue and the `issue-NNNN` branch; the plans that follow continue on that same branch.
|
||||
4. `/review-code` — a reviewer checks the commit.
|
||||
5. `/create-pr` — the branch goes up as a pull request.
|
||||
|
||||
Every step also works on its own, and you can always say what you want
|
||||
in plain words — the agents pick the right skill from what you say.
|
||||
|
||||
## Adding or changing a skill
|
||||
|
||||
Create a folder here with a `SKILL.md` inside. The file needs `name` and
|
||||
`description` in its frontmatter, and a clear "When to use" section so
|
||||
agents know when to reach for it. Keep one job per skill, and keep the
|
||||
two families apart: flows are named with a verb first; reference skills
|
||||
end in `-criteria`.
|
||||
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"i
|
||||
|
||||
`cat` with syntax highlighting, line numbers, and Git integration.
|
||||
|
||||
## When to use
|
||||
|
||||
- Reading or displaying a file in the terminal — prefer it over plain
|
||||
`cat`: syntax highlighting, line numbers, git-side indicators.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic usage
|
||||
+7
-8
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
||||
name: code-review-criteria
|
||||
description: Code review criteria — the five review axes, core principles, severity format, and verdict for reviewing code changes. Loaded by the reviewer subagent of the review-code flow. Not a user-facing flow — to review code, use the review-code flow.
|
||||
---
|
||||
|
||||
# Code Review and Quality
|
||||
# Code Review Criteria and Quality
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,11 +13,10 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before merging any PR or change
|
||||
- After completing a feature implementation
|
||||
- When another agent or model produced code you need to evaluate
|
||||
- When refactoring existing code
|
||||
- After any bug fix (review both the fix and the regression test)
|
||||
- The reviewer subagent of the `review-code` flow loads this skill to perform
|
||||
the review of a code change.
|
||||
- To review code, always go through the `review-code` flow — never load this
|
||||
skill directly for that. This is the criteria reference, not the flow.
|
||||
|
||||
## Core Principles
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: PR flow — open a new PR for the current task branch (validates base branch, commits, issue and push state) or update an existing PR's title or description to match Penpot conventions. Use it when the user asks to open or create a PR, in any phrasing.
|
||||
---
|
||||
|
||||
# Create PR
|
||||
|
||||
Two modes. **Open mode** takes the current task branch to a new, validated
|
||||
PR. **Update mode** rewrites an existing PR's title or description. Gather
|
||||
information, validate, and act in one pass. If validation fails, STOP with a
|
||||
single coherent message that lists every problem and states exactly what
|
||||
information is missing — never fix or work around problems silently.
|
||||
|
||||
Both modes require an authenticated `gh` CLI (`gh auth status`) and never
|
||||
push — the user pushes from their own shell.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to open or create a NEW PR for the current task branch, in
|
||||
any phrasing ("open a PR", "create the pull request", "put this up for
|
||||
review") — or runs `/create-pr`. → **Open mode**.
|
||||
- The user asks to fix or update an EXISTING PR's title or description to
|
||||
match conventions. → **Update mode**.
|
||||
|
||||
If the running agent cannot write (for example, the plan agent), say so and
|
||||
stop — this skill needs the build agent.
|
||||
|
||||
## Open mode
|
||||
|
||||
### 1. Gather context (read-only)
|
||||
|
||||
- Current branch: `git rev-parse --abbrev-ref HEAD`.
|
||||
- Target base branch: run `./scripts/detect-target-branch` from the repo root.
|
||||
It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1).
|
||||
- Commits: `git log --oneline <base>..HEAD`.
|
||||
- Push state (local): `git rev-parse --verify origin/<branch>` and compare
|
||||
with HEAD. It reads the local remote-tracking ref — no network, no SSH. It
|
||||
reflects the last push or fetch this clone knows about.
|
||||
- Issue: from the session context, or from the branch name — `issue-NNNN`
|
||||
maps to issue NNNN; recover its title and body with `gh issue view NNNN`.
|
||||
|
||||
### 2. Validate — stop with one message if anything fails
|
||||
|
||||
Run all checks before reporting, then report every failure together:
|
||||
|
||||
1. **Base branch not usable.** If the script fails (exit 1), or its output —
|
||||
after stripping an optional `remotes/origin/` prefix — is not one of the
|
||||
canonical branches (`develop`, `staging`, `main`), stop and ask the user
|
||||
to re-run with more context — for example, passing the base branch
|
||||
explicitly in their invocation. An explicit base given by the user
|
||||
overrides the script's output.
|
||||
2. **On a base branch.** There is no task branch to merge — say so and stop.
|
||||
3. **No commits.** The branch has no commits ahead of the base — say so and
|
||||
stop.
|
||||
4. **No clear issue.** There is no issue in the session context, and the
|
||||
branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing)
|
||||
— say so and stop. Exception: the user's invocation says `no issue` /
|
||||
`without issue` — then continue without an issue reference.
|
||||
5. **Branch not pushed.** The remote-tracking ref `origin/<branch>` is
|
||||
missing, or `git rev-parse origin/<branch>` differs from HEAD — the
|
||||
branch was never pushed, or has commits the remote does not have. Never
|
||||
push yourself; ask the user to push and to run `/create-pr` again
|
||||
afterwards, then stop.
|
||||
|
||||
### 3. Already-open PR
|
||||
|
||||
Check whether a PR already exists for this branch (`gh pr list --head
|
||||
<branch>`). If one exists, report its URL and stop — do not create a second
|
||||
one. Title or description fixes belong to Update mode.
|
||||
|
||||
### 4. Write and create the PR
|
||||
|
||||
Write the title and body following `mem:workflow/creating-prs` (title format,
|
||||
description structure, writing principles) and `mem:workflow/creating-commits`
|
||||
(commit type emojis). Derive the title and body from the commits and, when
|
||||
there is one, from the issue body. Reference the issue with `Closes #NNNN`.
|
||||
|
||||
```bash
|
||||
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
### 5. Report
|
||||
|
||||
Report the PR URL and stop.
|
||||
|
||||
## Update mode
|
||||
|
||||
1. Identify the PR: the number given by the user, or `gh pr list --head
|
||||
<branch>`.
|
||||
2. Write the new title and/or body following `mem:workflow/creating-prs`.
|
||||
3. Apply and verify:
|
||||
|
||||
```bash
|
||||
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||
```
|
||||
|
||||
4. Report and stop.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: overrides such as `no issue` /
|
||||
`without issue`, an explicit base branch (`from origin/staging`), a PR number
|
||||
for Update mode, and so on.
|
||||
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id
|
||||
|
||||
User-friendly alternative to `find` with smart defaults.
|
||||
|
||||
## When to use
|
||||
|
||||
- Locating files or directories by name or pattern — prefer it over
|
||||
plain `find`: simpler syntax, smart defaults, respects `.gitignore`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic search
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: implement-plan
|
||||
description: Implementation flow — execute a ready plan from the session context: read the plan, detect the flow, then present the full picture (issue and branch to create or the branch to continue on, execution style, task checklist) and wait for confirmation. Default is every task with one final commit; on request ("step by step"), one task and one commit at a time with a pause after each. Use it when the user asks to implement or execute a plan, in any phrasing.
|
||||
---
|
||||
|
||||
# Implement Plan
|
||||
|
||||
This flow is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. It never pushes —
|
||||
the user pushes.
|
||||
|
||||
By default it ends with exactly one commit. When the user asks for it
|
||||
("step by step"), it commits once per task instead and waits for the
|
||||
user's confirmation after each one (see *Execution modes*).
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to implement or execute a plan, in any phrasing:
|
||||
"implement the plan", "execute it", "go build it" — or runs
|
||||
`/implement-plan`.
|
||||
- A ready, reviewed plan is in the session context or a plan file path
|
||||
was given (typically after `/make-a-plan` or `/review-plan`).
|
||||
|
||||
Do not use it to produce plans — that is the `make-a-plan` flow.
|
||||
|
||||
## 1. Read the plan first
|
||||
|
||||
Identify the plan to execute — from the file path the user gave, the
|
||||
arguments, or the session context. Read it completely. Read the required
|
||||
memories before writing any code: `mem:critical-info` and the core memory
|
||||
of every module the plan touches, plus the deeper memories they reference
|
||||
(AGENTS.md governs this).
|
||||
|
||||
## 2. Detect the flow (no questions)
|
||||
|
||||
Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the
|
||||
mode, and announce it in one line before presenting anything. Detection is
|
||||
read-only: nothing is created until the user confirms (step 3).
|
||||
|
||||
- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**:
|
||||
a new GitHub issue and a branch `issue-NNNN` will be created after the
|
||||
user's confirmation.
|
||||
- **On any other branch** (a feature branch, typically `issue-NNNN`) →
|
||||
**continue mode**: the implementation continues on the current branch.
|
||||
No issue or branch is created. The branch name provides the issue
|
||||
reference when it follows the `issue-NNNN` pattern.
|
||||
|
||||
Arguments override detection: `standalone`, `continue`,
|
||||
`no issue` / `without issue`, or an explicit base such as
|
||||
`from origin/develop`.
|
||||
|
||||
**Standalone while already on a feature branch:** stop and explain that this
|
||||
would stack branches. Ask the user to re-run with an explicit base, for
|
||||
example `from origin/develop` — then branch from that base instead of HEAD.
|
||||
|
||||
## 3. Present the checklist and wait
|
||||
|
||||
Before touching the repository, show the user the full picture:
|
||||
|
||||
- **The flow**: whether the GitHub issue and the branch will be created
|
||||
(standalone mode — give the planned branch name, `issue-NNNN` or
|
||||
`plan-<slug>`), or whether you continue on the current branch
|
||||
(continue mode — name it).
|
||||
- **The execution style**: batch or step-by-step (see *Execution modes*).
|
||||
- A checklist (todolist) of the plan's tasks, in order.
|
||||
|
||||
Then WAIT for the user's explicit confirmation. Do not start until you
|
||||
have it. If the plan has no discrete tasks, ask the user how to split
|
||||
it, or propose running it as a single change.
|
||||
|
||||
## 4. Execute the plan
|
||||
|
||||
**Standalone setup, after the confirmation:** create the issue with the
|
||||
**`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body
|
||||
from the plan, capture the new issue's number — call it **NNNN** — and
|
||||
create the branch from the current HEAD:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
If the arguments say `no issue` / `without issue`, skip the issue and
|
||||
create a branch named `plan-<slug>` instead, where `<slug>` is the plan
|
||||
title, lowercase and hyphen-separated.
|
||||
|
||||
### Batch mode (default)
|
||||
|
||||
Implement every task in one go. Work methodically, keeping changes
|
||||
focused on what the issue requires. Respect the plan's proposed
|
||||
parallelization when it applies.
|
||||
|
||||
When the implementation is complete, load the **`create-commit`** skill
|
||||
and follow its workflow to commit the changes. Provide a brief summary
|
||||
of what was implemented and why, the issue reference (`issue-NNNN`) when
|
||||
there is one, and the model name you are running as so the
|
||||
`AI-assisted-by` trailer is set correctly.
|
||||
|
||||
### Step-by-step mode (on request)
|
||||
|
||||
When the user asks for it — "step by step", "task by task", "one commit
|
||||
per task" — loop one task at a time:
|
||||
|
||||
- Execute exactly ONE task.
|
||||
- Commit it now: load the **`create-commit`** skill and follow it —
|
||||
one commit per task, never two tasks in one commit. Same inputs as
|
||||
always: what and why, the issue reference, your model name.
|
||||
- Show the user the result (what changed, files touched, how it was
|
||||
verified).
|
||||
- WAIT for the user's confirmation before starting the next task.
|
||||
|
||||
Never batch in this mode: no two tasks in one commit, and no new task
|
||||
before the user confirms. If a task turns out much bigger than planned,
|
||||
stop and ask the user before splitting it.
|
||||
|
||||
## When you are done
|
||||
|
||||
End by suggesting the next steps (suggestions, not a required pipeline — any
|
||||
instruction from me overrides them):
|
||||
|
||||
- `/review-code` — to review the changes just committed; it routes to
|
||||
`/make-a-plan` by itself if the findings need one.
|
||||
- `/create-pr` — when the task is done and the branch is ready to merge.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this
|
||||
skill) plays the role command arguments play elsewhere: `standalone`,
|
||||
`continue`, `no issue` / `without issue`, an explicit base such as
|
||||
`from origin/develop`, or `step by step` / `one commit per task` for the
|
||||
step-by-step execution mode. Modes combine freely, for example
|
||||
"standalone step by step".
|
||||
+5
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id
|
||||
|
||||
Process, filter, and transform JSON data with jq.
|
||||
|
||||
## When to use
|
||||
|
||||
- Parsing, filtering, or transforming JSON from commands, files, or API
|
||||
responses — slicing, reshaping, or validating JSON output.
|
||||
|
||||
## Quick Examples
|
||||
|
||||
### Basic filtering
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: make-a-plan
|
||||
description: Planning flow — research the subject of this session, produce an implementation plan with the planner skill, resolve open questions with the user in plain language, and save the final plan to .agents/plans/. Use it when the user asks to plan, design, or break down a task, in any phrasing.
|
||||
---
|
||||
|
||||
# Make a Plan
|
||||
|
||||
Act as a senior software engineer: research the subject of this session in depth and
|
||||
produce a well-grounded, actionable implementation plan.
|
||||
|
||||
If the running agent cannot write (for example, the plan agent), say so and
|
||||
stop — this skill needs the build agent to save the plan.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to plan, design, or break down a task, in any phrasing:
|
||||
"make a plan", "how would we build X", "design an approach for Y" —
|
||||
or runs `/make-a-plan`.
|
||||
- The user asks to rework or extend an existing plan (for example, after
|
||||
review findings) — revise the saved plan file in place.
|
||||
|
||||
Do not use it to execute a plan — that is the `implement-plan` flow.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Produce the plan** with the `planner` skill. By default, research the
|
||||
subject of this session and draft the plan yourself. If I ask for it (for
|
||||
example, `delegated` in the user context), delegate to the `general` subagent
|
||||
instead — the delegate must also follow the `planner` skill and receive all
|
||||
the relevant session context (a review, user feedback, and so on).
|
||||
2. Before asking me to decide anything, explain the plan and every open question in
|
||||
plain language. Assume I know only the high-level project goal, not the codebase,
|
||||
architecture, implementation terms, or the problem this task solves.
|
||||
3. Once all decisions are answered and the plan is final, save it verbatim to the
|
||||
announced path under `.agents/plans/` (create the directory if it does not
|
||||
exist). This step is the flow's explicit authorization to write the plan
|
||||
file — the only write allowed here. If I later ask for changes, update the
|
||||
saved file directly.
|
||||
4. Present me with a clear, self-contained summary of the plan's most relevant points
|
||||
only after all required decisions have been answered. Write it for someone who knows
|
||||
only the project's high-level goal and may not know the plan's low-level context.
|
||||
Explain necessary technical language in plain terms, include the problem being
|
||||
solved and the proposed outcome, and do not assume that listing technical task names
|
||||
is enough.
|
||||
|
||||
### Hard rule — read-only while planning
|
||||
|
||||
While this flow runs, act read-only: research with read-only tools only.
|
||||
Never edit source files, never run builds, tests, linters, or any command that
|
||||
modifies state, and never commit. The single allowed write is the plan file in
|
||||
step 3. This rule expires when I approve the plan or move on to another task;
|
||||
then you act as a normal build agent again.
|
||||
|
||||
When the plan contains open questions, do not show them as bare technical questions or
|
||||
assume that I understand the technical language or technical words used in the plan.
|
||||
For each question, first explain:
|
||||
|
||||
- What part of the user problem the decision affects.
|
||||
- The relevant concept from the beginning, with a small concrete example.
|
||||
- What each available option would make the system do.
|
||||
- The practical benefits, costs, risks, and user-visible consequences of each option.
|
||||
- Which option the planner recommends and why.
|
||||
|
||||
Only after that explanation, use the `question` tool to ask the decision with clear,
|
||||
non-technical option labels. Put the recommended option first and mark it as
|
||||
`(Recommended)`. Group related questions when their context is shared, but do not ask a
|
||||
question whose meaning has not already been explained.
|
||||
|
||||
If I say that I do not understand a question or its choices, do not treat my previous
|
||||
answer as valid. Explain the concepts again from the high-level project goal, use a more
|
||||
concrete example, explain the implications, and ask the question again with the
|
||||
`question` tool. Repeat this until I can make an informed choice. If one answer creates
|
||||
new design consequences or additional decisions, explain those consequences before
|
||||
asking any new question.
|
||||
|
||||
Distinguish clearly between requirements already fixed by the roadmap or existing
|
||||
architecture and choices that actually require my input. Do not ask me to choose an
|
||||
implementation detail when the plan can resolve it safely without changing the public
|
||||
behavior. If there are no decisions that require my input, say so and present the
|
||||
summary.
|
||||
|
||||
IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it
|
||||
after all possible questions have been answered.** The final summary must explain the
|
||||
problem being solved, the proposed behavior, the main user-visible workflow, important
|
||||
constraints and risks, what is deliberately out of scope, and the path where the plan
|
||||
is saved. Never assume that a short list of task names is enough context. End
|
||||
the final response by suggesting the next steps, in this order:
|
||||
|
||||
1. `/review-plan` — to get a second opinion on the plan before executing it.
|
||||
2. `/implement-plan` — to execute the plan from the current session context.
|
||||
|
||||
These are suggestions, not a required pipeline — any instruction from me
|
||||
overrides them (for example, asking you to implement the plan directly).
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, `delegated` to
|
||||
hand the research and drafting to the `general` subagent, or corrections and
|
||||
feedback about a previous plan.
|
||||
@@ -10,6 +10,12 @@ Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
|
||||
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
|
||||
|
||||
## When to use
|
||||
|
||||
- Evaluating Clojure or ClojureScript code against the running nREPL
|
||||
sessions (backend 6064, frontend 3447) — live inspection, patching, or
|
||||
debugging.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
+10
-10
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: plan-review
|
||||
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
|
||||
name: plan-review-criteria
|
||||
description: Plan review criteria — the six review axes, severity rubric, approval standard, and output format for reviewing implementation plans. Loaded by the reviewer subagent of the review-plan flow. Not a user-facing flow — to review a plan, use the review-plan flow.
|
||||
---
|
||||
|
||||
# Plan Review
|
||||
# Plan Review Criteria
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -13,10 +13,10 @@ Multi-dimensional plan review with quality gates. Every plan gets reviewed befor
|
||||
|
||||
## When to Use
|
||||
|
||||
- After the planner skill produces a plan
|
||||
- Before starting implementation on any non-trivial task
|
||||
- When reviewing a plan written by another agent or a human
|
||||
- When a plan feels too large, vague, or risky to start
|
||||
- The reviewer subagent of the `review-plan` flow loads this skill to perform
|
||||
the review of a plan.
|
||||
- To review a plan, always go through the `review-plan` flow — never load this
|
||||
skill directly for that. This is the criteria reference, not the flow.
|
||||
|
||||
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
|
||||
|
||||
@@ -87,7 +87,7 @@ Can an implementer actually execute this?
|
||||
|
||||
### 6. Proposed Code Quality *(when the plan includes implementation details)*
|
||||
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
|
||||
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-criteria`:
|
||||
|
||||
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
|
||||
- **Readability:** Are proposed names descriptive and consistent with project conventions?
|
||||
@@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked:
|
||||
If the plan includes code snippets, types, or API designs:
|
||||
|
||||
```
|
||||
- Load code-review skill for criteria
|
||||
- Load code-review-criteria skill for criteria
|
||||
- Check proposed signatures for edge cases
|
||||
- Verify naming follows project conventions
|
||||
- Confirm abstractions follow existing patterns
|
||||
@@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs:
|
||||
## See Also
|
||||
|
||||
- For producing plans, use the `planner` skill
|
||||
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
|
||||
- For reviewing implemented code, use `code-review-criteria` — also the criteria source for axis 6
|
||||
- For security-specific concerns, see `security-and-hardening`
|
||||
- For testing strategy guidance, see `testing`
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md.
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
|
||||
---
|
||||
|
||||
# Planner
|
||||
@@ -215,9 +215,9 @@ Add explicit checkpoints with the relevant module commands:
|
||||
|
||||
## Constraints
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code.
|
||||
- The only file write you may attempt is the plan itself, saved to
|
||||
`.opencode/plans/`.
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
@@ -228,22 +228,23 @@ Add explicit checkpoints with the relevant module commands:
|
||||
## Output Format
|
||||
|
||||
The plan is always delivered in the response so the user sees it regardless
|
||||
of which agent is running the skill.
|
||||
of which agent is running the skill. By default you never write the plan file;
|
||||
announce the path instead. Write the file only when the command or user
|
||||
explicitly instructs you to save it — and then only that file.
|
||||
|
||||
Additionally, save the plan to:
|
||||
Announce the suggested save path:
|
||||
|
||||
```
|
||||
.opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
```
|
||||
|
||||
Use today's date in the user's local timezone. The `<plan-one-line-title>`
|
||||
slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
|
||||
`.opencode/plans/` directory if it does not exist.
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
|
||||
provides a target file path, announce that path instead of the default.
|
||||
|
||||
IMPORTANT: The plan agent has write permission specifically for
|
||||
`.opencode/plans/` — always attempt the write. If the user explicitly provides
|
||||
a target file path, use that path instead of the default.
|
||||
End the response by suggesting the next steps: `/review-plan` to get a second
|
||||
opinion on the plan and `/implement-plan` to execute it.
|
||||
|
||||
### Plan Document Template
|
||||
|
||||
@@ -374,4 +375,6 @@ Before delivering the plan, confirm:
|
||||
- [ ] Task dependencies are identified and ordered correctly
|
||||
- [ ] No task is XL or larger — break it down instead
|
||||
- [ ] Checkpoints exist after every 2-3 tasks
|
||||
- [ ] The response states the plan's path (saved or suggested) and suggests
|
||||
`/review-plan` and `/implement-plan`
|
||||
- [ ] The plan is ready for human review
|
||||
File renamed without changes.
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: resolve-git-conflicts
|
||||
description: Conflict resolution flow — understand the local git conflicts, present a resolution plan, and resolve them after the user approves it. Never continues the rebase. Use it when the repo has unresolved conflicts (rebase, merge, cherry-pick) or the user asks to resolve them.
|
||||
---
|
||||
|
||||
# Resolve Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## When to use
|
||||
|
||||
- The repository has unresolved conflicts — during a rebase, merge, or
|
||||
cherry-pick — whether the user asks about them or not.
|
||||
- The user asks to resolve conflicts, in any phrasing: "fix the merge
|
||||
conflicts", "resolve these", "what's conflicting here?".
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: review-code
|
||||
description: Code review flow — review a diff, PR, or code change, delegating the review to a subagent that follows the code-review-criteria skill. Use it when the user asks to review code or a PR, in any phrasing.
|
||||
---
|
||||
|
||||
# Review Code
|
||||
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to review code, in any phrasing: "review this diff",
|
||||
"review the PR", "check my changes", "code review" — or runs
|
||||
`/review-code`.
|
||||
- A commit, branch, PR, or diff is ready and the user wants it assessed
|
||||
before merge.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Determine what is being reviewed** from the user context: a working-tree
|
||||
diff, a commit range, a branch, a PR (number or URL), or specific files. If
|
||||
the target is ambiguous, ask before reviewing.
|
||||
2. Delegate the review to the `general` subagent (via the task tool), unless the
|
||||
user specifies another agent. Include in the prompt the
|
||||
**`code-review-criteria`** skill name and all user context.
|
||||
3. When the subagent returns, output the review to the user verbatim. Do not
|
||||
summarize it and do not act on its findings.
|
||||
4. Right after the review, suggest how to proceed based on the findings. These
|
||||
are suggestions — the user decides:
|
||||
- **Approve (no required changes):** say so — there is nothing to address.
|
||||
- **Minor findings (nits):** applying them directly as-is is fine once the
|
||||
review is done — no plan needed.
|
||||
- **Substantive findings:** suggest `/make-a-plan` to make a plan to address
|
||||
them.
|
||||
|
||||
### Hard rule — read-only while reviewing
|
||||
|
||||
This flow is read-only **for the duration of the review**: from the moment it
|
||||
starts until the user considers the review finished (including any feedback,
|
||||
questions, or clarifications about it). During that period, never fix,
|
||||
implement, edit files or create commits — not even "obvious" fixes derived from
|
||||
the findings. Once the user explicitly states the review is done (or moves on to
|
||||
a different task), this rule no longer applies and you act as a normal build
|
||||
agent again.
|
||||
|
||||
## Instructions for the subagent
|
||||
|
||||
1. Load the **`code-review-criteria`** skill and follow its process and output
|
||||
format.
|
||||
2. Read `AGENTS.md` (if present) and follow its instructions for finding and
|
||||
reading all related testing documentation from memories before reviewing.
|
||||
3. Return in your final message the COMPLETE review, verbatim, exactly as the
|
||||
skill instructs it to be produced. Do not summarize it — include the full
|
||||
structured review.
|
||||
|
||||
### Strong rules for the subagent
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Read-only: do not modify any file and do not create a commit — reviewing
|
||||
never writes.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain
|
||||
why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. Missing tests are an issue, not a suggestion. Report as a severity-tagged
|
||||
finding — never as a recommendation.
|
||||
6. Skip generated files, lockfile-only changes, and unrelated modifications
|
||||
unless they introduce security risks.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, a PR number or
|
||||
URL, a commit range, specific files, or a different agent to run the review.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: review-plan
|
||||
description: Plan review flow — evaluate an implementation plan before it is executed, delegating the review to a subagent that follows the plan-review-criteria skill. Use it when the user asks to review a plan, in any phrasing.
|
||||
---
|
||||
|
||||
# Review Plan
|
||||
|
||||
Act as a senior software engineer and perform a thorough review of an
|
||||
implementation plan.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks to review a plan, in any phrasing: "review this plan",
|
||||
"does this plan look right?", "second opinion on the plan" — or runs
|
||||
`/review-plan`.
|
||||
- A plan was just produced (typically by `/make-a-plan`) and the user
|
||||
wants it evaluated before executing it.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Determine the plan under review** from the session context (for example, a
|
||||
plan just produced by `/make-a-plan`) or from a plan file path given by the
|
||||
user (typically under `.agents/plans/`). If a file path is given, read the
|
||||
file first so the complete plan is in context.
|
||||
2. Delegate the review to the `general` subagent (via the task tool), unless the
|
||||
user specifies another agent. Include in the prompt the
|
||||
**`plan-review-criteria`** skill name and all user context.
|
||||
3. When the subagent returns, output the review to the user verbatim. Do not
|
||||
summarize it and do not act on its findings.
|
||||
4. Right after the review, suggest the next step based on the verdict. These
|
||||
are suggestions — the user decides, and any instruction overrides them:
|
||||
- **Approve** → suggest `/implement-plan` to execute it.
|
||||
- **Request changes** → suggest `/make-a-plan` to make a plan to address the
|
||||
findings.
|
||||
|
||||
### Hard rule — read-only while reviewing
|
||||
|
||||
This flow is read-only **for the duration of the review**: from the moment it
|
||||
starts until the user considers the review finished (including any feedback,
|
||||
questions, or clarifications about it). During that period, never fix,
|
||||
implement, edit files or create commits — not even "obvious" fixes derived from
|
||||
the findings. Once the user explicitly states the review is done (or moves on to
|
||||
a different task), this rule no longer applies and you act as a normal build
|
||||
agent again.
|
||||
|
||||
## Instructions for the subagent
|
||||
|
||||
1. Load the **`plan-review-criteria`** skill and follow its process and output
|
||||
format.
|
||||
2. Read `AGENTS.md` (if present) and follow its instructions for finding and
|
||||
reading all related documentation and testing memories before reviewing.
|
||||
3. Return in your final message the COMPLETE review, verbatim, exactly as the
|
||||
skill instructs it to be produced. Do not summarize it — include the full
|
||||
structured review.
|
||||
|
||||
### Strong rules for the subagent
|
||||
|
||||
1. Do not invent problems. Every finding must be real and actionable.
|
||||
2. Read-only: do not modify any file and do not create a commit — reviewing
|
||||
never writes.
|
||||
3. Be specific and constructive. "This could be better" is not helpful — explain
|
||||
why and how.
|
||||
4. Prioritize by impact. One structural issue outweighs ten nits.
|
||||
5. Judge the plan as the implementer would: every task executable without
|
||||
guessing, ordering follows the dependency graph, risks named.
|
||||
|
||||
## User context
|
||||
|
||||
Extra context in the user's invocation (the message that triggered this skill)
|
||||
plays the role command arguments play elsewhere: for example, a plan file path
|
||||
to review, or a different agent to run the review.
|
||||
@@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id
|
||||
|
||||
Fast, smart recursive search. Respects `.gitignore` by default.
|
||||
|
||||
## When to use
|
||||
|
||||
- Searching file contents across the repo for regex patterns — the
|
||||
default code search, respects `.gitignore`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic search
|
||||
File renamed without changes.
@@ -9,6 +9,13 @@ Apply the ASD-STE100 standard to all prose you produce in this task. Do not anno
|
||||
|
||||
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
|
||||
|
||||
## When to use
|
||||
|
||||
Only when the user explicitly invokes it: they type `/ste`, or say "use
|
||||
the ste skill" / "apply ASD-STE100". Requests like "simplify this",
|
||||
"make it clearer", or "shorter sentences" do NOT invoke it — respond
|
||||
normally unless it is named.
|
||||
|
||||
## Step 0 — Classify the text
|
||||
|
||||
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
|
||||
File renamed without changes.
File renamed without changes.
@@ -11,6 +11,12 @@ Fetch information from Taiga public API for the **Penpot** project
|
||||
|
||||
**No authentication required** — only public project data is accessed.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks about Penpot issues, user stories, or tasks tracked in
|
||||
Taiga — fetch them via the public API (project id 345963), no
|
||||
authentication needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
|
||||
File renamed without changes.
File renamed without changes.
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1,41 +0,0 @@
|
||||
def specs: [.. | objects | select(has("tests") and has("file"))];
|
||||
def dur: [.tests[].results[]?.duration // 0] | add;
|
||||
|
||||
specs as $s
|
||||
| ($s | map(select(any(.tests[]; .status == "unexpected")))) as $failed
|
||||
| ($s | map(select(any(.tests[]; .status == "flaky")))) as $flaky
|
||||
| ($s | map(select(any(.tests[]; .status == "skipped")))) as $skipped
|
||||
| ($s | length) as $total
|
||||
| ($s | map(dur) | add // 0 | . / 1000 | floor) as $cpu
|
||||
| (if ($failed | length) > 0 then "❌"
|
||||
elif ($flaky | length) > 0 then "⚠️"
|
||||
else "✅" end) as $icon
|
||||
|
||||
| "## \($icon) Integration tests\n\n"
|
||||
+ "| Total | Passed | Flaky | Failed | Skipped | Test time |\n"
|
||||
+ "|---|---|---|---|---|---|\n"
|
||||
+ "| \($total) | \($total - ($failed|length) - ($flaky|length) - ($skipped|length)) "
|
||||
+ "| \($flaky|length) | \($failed|length) | \($skipped|length) | \($cpu / 60 | floor)m |\n"
|
||||
|
||||
+ (if ($failed | length) > 0 then
|
||||
"\n### Failed\n\n"
|
||||
+ ($failed | map("- `\(.file):\(.line)` — \(.title)") | join("\n")) + "\n"
|
||||
else "" end)
|
||||
|
||||
+ (if ($flaky | length) > 0 then
|
||||
"\n### Flaky (passed on retry)\n\n"
|
||||
+ ($flaky
|
||||
| map({ t: "`\(.file):\(.line)` — \(.title)",
|
||||
r: ([.tests[].results[]? | select(.status == "failed")] | length) })
|
||||
| sort_by(-.r)
|
||||
| map("- \(.t) _(\(.r) \(if .r == 1 then "retry" else "retries" end))_")
|
||||
| join("\n")) + "\n"
|
||||
else "" end)
|
||||
|
||||
+ (if $total > 0 then
|
||||
"\n<details><summary>Slowest specs</summary>\n\n"
|
||||
+ ($s | map({ t: "`\(.file)` — \(.title)", d: (dur / 1000 | floor) })
|
||||
| sort_by(-.d) | .[0:5]
|
||||
| map("- \(.t) — \(.d)s") | join("\n"))
|
||||
+ "\n\n</details>\n"
|
||||
else "" end)
|
||||
@@ -17,19 +17,15 @@ on:
|
||||
required: true
|
||||
default: 'develop'
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
# by the same caller into a single shared group, and left a manual dispatch of
|
||||
# the same ref in a group of its own, free to race on the same artifacts.
|
||||
concurrency:
|
||||
group: build-bundle-${{ inputs.gh_ref }}
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── 1. Decide whether there is anything to build ───────────────────────
|
||||
check:
|
||||
name: Check current bundle
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-runner-01
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@@ -79,7 +75,7 @@ jobs:
|
||||
# ── 2. Build and upload, only when needed ──────────────────────────────
|
||||
build:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-runner-01
|
||||
timeout-minutes: 90
|
||||
needs: check
|
||||
if: needs.check.outputs.exists == 'false'
|
||||
@@ -120,7 +116,7 @@ jobs:
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-runner-01
|
||||
timeout-minutes: 5
|
||||
needs: [check, build]
|
||||
if: failure()
|
||||
|
||||
@@ -5,10 +5,6 @@ on:
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
@@ -23,7 +19,7 @@ jobs:
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
|
||||
build-docker-admin-console:
|
||||
build-admin-console-docker:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
|
||||
@@ -16,12 +16,8 @@ on:
|
||||
required: true
|
||||
default: 'develop'
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
# by the same caller into a single shared group, and left a manual dispatch of
|
||||
# the same ref in a group of its own, free to race on the same artifacts.
|
||||
concurrency:
|
||||
group: build-docker-${{ inputs.gh_ref }}
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -36,7 +32,7 @@ jobs:
|
||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
@@ -107,7 +103,7 @@ jobs:
|
||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 60
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
@@ -220,7 +216,7 @@ jobs:
|
||||
# the S3 marker guarantees the branch tags were already moved.
|
||||
promote:
|
||||
name: Promote image set
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 10
|
||||
needs: [prepare, build]
|
||||
|
||||
@@ -267,7 +263,7 @@ jobs:
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
timeout-minutes: 5
|
||||
needs: [prepare, build, promote]
|
||||
if: failure()
|
||||
|
||||
@@ -5,10 +5,6 @@ on:
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
@@ -23,7 +19,7 @@ jobs:
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
|
||||
build-docker-admin-console:
|
||||
build-admin-console-docker:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -6,12 +6,6 @@ on:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1
|
||||
# must not abort the release already in flight.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
@@ -26,18 +20,10 @@ jobs:
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
@@ -51,9 +37,7 @@ jobs:
|
||||
|
||||
publish-final-tag:
|
||||
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
needs: build-docker
|
||||
uses: ./.github/workflows/release.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
name: _TMP TOKENS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '46 5-20 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "hiru-tokens-in-libs"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "hiru-tokens-in-libs"
|
||||
@@ -34,7 +34,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: penpot-standar-runner
|
||||
runs-on: penpot-runner-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
test-backend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend Tests"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test-common:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common Tests"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
composable-test-suite:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Run composable test suite (mocked backend)"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
test-frontend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend Tests"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -5,37 +5,11 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch or ref'
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
|
||||
shards:
|
||||
description: 'Shard layout (JSON array)'
|
||||
type: choice
|
||||
required: true
|
||||
default: '[1, 2, 3, 4]'
|
||||
options:
|
||||
- '[1, 2, 3, 4]'
|
||||
- '[1, 2, 3, 4, 5, 6]'
|
||||
- '[1, 2]'
|
||||
- '[1]'
|
||||
|
||||
workers:
|
||||
description: 'Playwright workers per shard'
|
||||
type: string
|
||||
required: true
|
||||
default: '2'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
- '.github/workflows/tests-integration.yml'
|
||||
|
||||
types:
|
||||
- opened
|
||||
@@ -51,41 +25,25 @@ on:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
- '.github/workflows/tests-integration.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 30
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
outputs:
|
||||
bundle_key: ${{ steps.vars.outputs.bundle_key }}
|
||||
|
||||
steps:
|
||||
# An empty `ref` makes checkout fall back to its default (the PR merge
|
||||
# ref on pull_request, the pushed ref on push).
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
# The cache key must come from the SHA actually checked out: on a manual
|
||||
# run `github.sha` points at the dispatching ref, not at `gh_ref`.
|
||||
- name: Extract cache key
|
||||
id: vars
|
||||
run: |
|
||||
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Bundle
|
||||
working-directory: ./frontend
|
||||
@@ -95,142 +53,58 @@ jobs:
|
||||
- name: Store Bundle Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: ${{ steps.vars.outputs.bundle_key }}
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
|
||||
test-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests (${{ matrix.shard }})"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 40
|
||||
|
||||
needs: build-integration
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
|
||||
|
||||
name: "Integration Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
- /var/cache/github-runner/ms-playwright:/ms-playwright
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
key: ${{ needs.build-integration.outputs.bundle_key }}
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install --frozen-lockfile;
|
||||
|
||||
# No-op once the shared volume is warm; keeps the first run working.
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: ./frontend
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
# `strategy.job-total` is the matrix size, so the shard denominator
|
||||
# follows the `shards` input without being hardcoded.
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
WORKERS: ${{ inputs.workers }}
|
||||
run: |
|
||||
WORKERS=${WORKERS:-2}
|
||||
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
|
||||
pnpm exec playwright test --project default \
|
||||
--workers="$WORKERS" \
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
|
||||
--reporter=blob
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-blob-report-${{ matrix.shard }}
|
||||
path: frontend/blob-report/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-${{ matrix.shard }}
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
merge-reports:
|
||||
if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
|
||||
name: "Merge Integration Reports"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 15
|
||||
|
||||
needs: test-integration
|
||||
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install --frozen-lockfile;
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: frontend/all-blob-reports
|
||||
pattern: integration-blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge into HTML report
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
PLAYWRIGHT_REPORTER: list,json
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
|
||||
run: |
|
||||
pnpm exec playwright merge-reports \
|
||||
--reporter=html,json,list ./all-blob-reports
|
||||
./scripts/test-e2e
|
||||
|
||||
- name: Test summary
|
||||
- name: Flaky summary
|
||||
if: always()
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
if [ ! -f report.json ]; then
|
||||
echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Kept for 30 days so flakiness rates can be aggregated across runs
|
||||
# without scraping job logs.
|
||||
jq -r '
|
||||
[ .. | objects
|
||||
| select(has("tests") and has("file"))
|
||||
| select(any(.tests[]; .status == "flaky"))
|
||||
| "- `\(.file):\(.line)` — \(.title)"
|
||||
] as $f
|
||||
| "## Flaky tests: \($f | length)\n"
|
||||
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
|
||||
' report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload JSON report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
@@ -238,13 +112,13 @@ jobs:
|
||||
name: integration-json-report
|
||||
path: frontend/report.json
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload HTML report
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-html-report
|
||||
path: frontend/playwright-report/
|
||||
name: integration-tests-result
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
retention-days: 3
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
test-library:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library Tests"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
test-mcp:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Test MCP"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
api-test-suite-mocked:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
|
||||
name: "Run Plugin API Test Suite (mocked)"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
# api-test-suite-live:
|
||||
# if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# name: Run Plugin API Test Suite (live)
|
||||
# runs-on: penpot-extended-runner
|
||||
# runs-on: penpot-runner-02
|
||||
# container:
|
||||
# image: penpotapp/devenv:latest
|
||||
#
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test-plugins:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test-render-wasm:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM Tests"
|
||||
runs-on: penpot-extended-runner
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
+1
-3
@@ -59,8 +59,6 @@ opencode.json
|
||||
/docker/images/bundle*
|
||||
/exporter/target
|
||||
/exporter/.shadow-cljs
|
||||
/exporter/resources/wasm/
|
||||
/exporter/src/app/wasm/shared.js
|
||||
/frontend/.storybook/preview-body.html
|
||||
/frontend/.storybook/preview-head.html
|
||||
/frontend/playwright-report/
|
||||
@@ -103,7 +101,7 @@ opencode.json
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
/.opencode/plans
|
||||
/.agents/plans
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.ci-logs
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Create a PR for the current task branch or update an existing one — loads and follows the create-pr skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`create-pr`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,39 +1,10 @@
|
||||
---
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill
|
||||
description: Execute a ready plan — task checklist, your confirmation, then all tasks with one commit (default) or step by step with a commit and a pause per task; creates issue + branch when on a base branch — loads and follows the implement-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
This command is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context. Follow these steps in order.
|
||||
Load the **`implement-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## 1. Create the issue
|
||||
## User input, overrides and additional context
|
||||
|
||||
Use the **`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body from the
|
||||
plan. Capture the new issue's number — call it **NNNN** (needed for the branch
|
||||
name and the commit reference).
|
||||
|
||||
## 2. Create the branch
|
||||
|
||||
Create and switch to a branch named after the issue:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
(Replace NNNN with the issue number from step 1.)
|
||||
|
||||
## 3. Execute the plan
|
||||
|
||||
Implement the prepared plan from the session context. Work methodically, keeping
|
||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||
step 4.
|
||||
|
||||
## 4. Commit with the create-commit skill
|
||||
|
||||
After the implementation is complete, load the **`create-commit`** skill and
|
||||
follow its workflow to commit the changes. Provide a brief summary of what was
|
||||
implemented and why, the issue reference (`issue-NNNN`), and the model name you
|
||||
are running as so the `AI-assisted-by` trailer is set correctly.
|
||||
|
||||
Do not push. Pushing is handled separately by the user.
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Investigate the chosen task, produce an implementation plan, and save it — loads and follows the make-a-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`make-a-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,40 +1,6 @@
|
||||
---
|
||||
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
|
||||
description: Resolve local git conflicts and stage the resolved files; never continues the rebase — loads and follows the resolve-git-conflicts skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Fix Git Conflicts
|
||||
|
||||
Resolve conflicts in the local repository. The user handles finishing the
|
||||
rebase themselves — you must **never** run `git rebase --continue`,
|
||||
`git rebase --skip`, `git merge --continue`, or anything similar.
|
||||
|
||||
## Phase 1 — Understand the problem (read-only)
|
||||
|
||||
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
|
||||
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
|
||||
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
|
||||
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
|
||||
- Identify what each side changed and why, and how they should be combined.
|
||||
|
||||
## Phase 2 — Present the resolution plan
|
||||
|
||||
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
|
||||
- What each side changed and why.
|
||||
- Your proposed resolution and the reasoning behind it.
|
||||
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
|
||||
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
|
||||
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
|
||||
|
||||
## Phase 3 — Execute
|
||||
|
||||
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
|
||||
|
||||
## Phase 4 — Stage and verify
|
||||
|
||||
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
|
||||
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
|
||||
|
||||
## Phase 5 — Report
|
||||
|
||||
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
|
||||
Load the **`resolve-git-conflicts`** skill and follow it as your only instruction.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Code review — review a diff, PR, or code change — loads and follows the review-code skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`review-code`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Plan review — evaluate an implementation plan before executing it — loads and follows the review-plan skill
|
||||
agent: build
|
||||
---
|
||||
|
||||
Load the **`review-plan`** skill and follow it as your only instruction.
|
||||
|
||||
## User input, overrides and additional context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: create-pr
|
||||
description: Create or update a GitHub PR following Penpot conventions.
|
||||
---
|
||||
|
||||
# Skill: create-pr
|
||||
|
||||
Create or update a GitHub PR. Read and follow:
|
||||
- `mem:workflow/creating-prs` — title format, description structure, writing principles
|
||||
- `mem:workflow/creating-commits` — commit type emojis
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating a new PR from a feature branch
|
||||
- Updating an existing PR's title or description to match conventions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
|
||||
## Commands
|
||||
|
||||
**Create:**
|
||||
|
||||
```bash
|
||||
gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
**Update:**
|
||||
|
||||
```bash
|
||||
gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
gh pr view <NUMBER> --repo penpot/penpot --json title,body
|
||||
```
|
||||
@@ -7,7 +7,6 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
|
||||
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
|
||||
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
|
||||
- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
|
||||
- Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment`
|
||||
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
|
||||
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
|
||||
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
# Graph Experiment
|
||||
|
||||
## Scope
|
||||
|
||||
- Purpose: project Penpot file data into an embedded Ladybug graph database.
|
||||
- Purpose: keep the graph current with Penpot file changes.
|
||||
- Purpose: expose a read-only graph console for backend debugging.
|
||||
- This is an experiment, not a replacement for PostgreSQL file storage.
|
||||
- The graph subsystem is off unless `:graph` is in the backend flags.
|
||||
- The main Penpot frontend has no graph feature code for this subsystem.
|
||||
- The graph console is a backend-served HTML template with JavaScript.
|
||||
|
||||
## Memory Links
|
||||
|
||||
- Read `mem:backend/core` for backend architecture, HTTP routes, DB rules, and test commands.
|
||||
- Read `mem:backend/rpc-db-worker-subtleties` for RPC and message bus behavior.
|
||||
- Read `mem:backend/http-storage-filedata-subtleties` for file data loading and realization.
|
||||
- Read `mem:common/changes-architecture` for the change record vocabulary.
|
||||
- Read `mem:frontend/routing-app-shell-subtleties` for the existing notification WebSocket.
|
||||
- Read `mem:prod-infra/core` for Redis or Valkey message bus topology.
|
||||
|
||||
## Branch Surface
|
||||
|
||||
- The graph experiment adds about 6,336 lines and changes about 27 files.
|
||||
- The graph implementation lives under `backend/src/app/graph/`.
|
||||
- The graph console lives at `backend/resources/app/templates/graph-console.tmpl`.
|
||||
- The existing debug page gains graph links in `backend/resources/app/templates/debug.tmpl`.
|
||||
- The existing debug HTTP routes gain graph handlers in `backend/src/app/http/debug.clj`.
|
||||
- The backend system passes the message bus to the debug route component in `backend/src/app/main.clj`.
|
||||
- The backend adds Ladybug and Arrow dependencies in `backend/deps.edn`.
|
||||
- The backend adds JVM options for Ladybug and Arrow native access.
|
||||
- The common flag registry adds `:graph` in `common/src/app/common/flags.cljc`.
|
||||
- The graph experiment adds `graph_sync_parity_test.clj` and `graph_binder_gate_test.clj`.
|
||||
|
||||
## System Model
|
||||
|
||||
### Storage layers
|
||||
|
||||
- PostgreSQL remains the source of truth for Penpot files.
|
||||
- The graph database stores a projection of one file.
|
||||
- A persistent graph uses a `.lbug` path under `PENPOT_GRAPH_DIR`.
|
||||
- The default graph directory is `/tmp/penpot-graph`.
|
||||
- A debug session uses a Ladybug `:memory:` database.
|
||||
- A debug session database lives inside the backend JVM process.
|
||||
- A debug session does not survive a backend restart.
|
||||
- A debug session does not store file data back to PostgreSQL.
|
||||
|
||||
### Two graph update paths
|
||||
|
||||
- Cold projection reads the complete file and rebuilds the graph.
|
||||
- Incremental sync reads file change records and updates the open graph.
|
||||
- Both paths must produce the same graph for the same file state.
|
||||
- The parity test treats cold projection as the reference path.
|
||||
- A reload discards the session graph and uses cold projection again.
|
||||
|
||||
## Main Namespaces
|
||||
|
||||
### `app.graph.ladybug`
|
||||
|
||||
- Opens and closes Ladybug `Database` and `Connection` objects.
|
||||
- Installs and loads the Ladybug JSON extension.
|
||||
- Executes Cypher statements.
|
||||
- Executes prepared statements.
|
||||
- Binds scalar parameters.
|
||||
- Formats UUID, string, integer, number, JSON, and timestamp values.
|
||||
- Formats compound values such as arrays, maps, and structs.
|
||||
- Converts Ladybug values back to Clojure values.
|
||||
- Limits normal query results to 200 rows by default.
|
||||
- Detects result truncation with `:truncated?`.
|
||||
- Uses query timeout `0` by default.
|
||||
- Query timeout `0` disables the timeout.
|
||||
- Provides `validate-on-connection!` for parse, bind, and read-only checks.
|
||||
- `exec-prepared-on-connection!` prepares every statement before the first execution.
|
||||
- A prepare failure stops the batch before a mutation runs.
|
||||
|
||||
### `app.graph.schema`
|
||||
|
||||
- Provides the public schema facade.
|
||||
- Exposes schema version `penpot-graph-slice-4`.
|
||||
- Delegates node and relationship definitions to `app.graph.schema.nodes`.
|
||||
|
||||
### `app.graph.schema.nodes`
|
||||
|
||||
- Holds the single registry for graph node tables.
|
||||
- Generates node DDL.
|
||||
- Generates relationship DDL.
|
||||
- Maps Penpot shape types to graph tables.
|
||||
- Projects source attributes into graph attributes.
|
||||
- Formats graph column values.
|
||||
- Quotes reserved graph labels such as `Group` and `Boolean`.
|
||||
- Defines container tables and shape tables.
|
||||
- Defines `IsChildOf`, `IsInstanceOf`, `RefersTo`, and `FillsSwapSlot`.
|
||||
|
||||
### `app.graph.schema.contract`
|
||||
|
||||
- Records deliberate graph contract decisions.
|
||||
- Renames graph columns such as `:revn` to `revision`.
|
||||
- Drops attributes that do not belong in this graph slice.
|
||||
- Records attributes that the graph does not project.
|
||||
- Applies per-table dropped attributes.
|
||||
- Defines type overrides for vectors, transforms, colors, maps, and JSON arrays.
|
||||
- Maps selected map keys to the frontend JSON naming convention.
|
||||
- `:background-blur` remains a declared unprojected attribute.
|
||||
|
||||
### `app.graph.schema.projection`
|
||||
|
||||
- Derives projected schemas from canonical Malli schemas.
|
||||
- Builds the projected document schema.
|
||||
- Builds projected shape schemas.
|
||||
- Selects the schema for each shape type.
|
||||
|
||||
### `app.graph.schema.types`
|
||||
|
||||
- Maps Malli types to Ladybug types.
|
||||
- Maps matrices to `DOUBLE[6]`.
|
||||
- Maps points to `DOUBLE[2]`.
|
||||
- Maps rectangles to `DOUBLE[4]`.
|
||||
- Maps colors to `UINT32`.
|
||||
- Maps collections to Ladybug arrays.
|
||||
- Maps `:map-of` schemas to `MAP`.
|
||||
- Maps closed scalar maps to `STRUCT`.
|
||||
- Maps other complex values to `JSON`.
|
||||
|
||||
### `app.graph.schema.values`
|
||||
|
||||
- Coerces source values to graph column values.
|
||||
- Writes fixed vectors with deterministic order.
|
||||
- Packs colors into the graph color representation.
|
||||
- Sorts set values when deterministic output is needed.
|
||||
|
||||
### `app.graph.arrow`
|
||||
|
||||
- Loads projection rows with Apache Arrow.
|
||||
- Creates temporary staged node and relationship tables.
|
||||
- Uses `COPY ... FROM (MATCH ...)` for bulk loading.
|
||||
- Groups relationship loads by source and target table pair.
|
||||
- Resolves relationship endpoints with joins.
|
||||
- Does not use `createArrowRelTable` for UUID relationship endpoints.
|
||||
- Keeps the Arrow `RootAllocator` alive until Ladybug releases staged buffers.
|
||||
- Closes the allocator after the connection and database close sequence.
|
||||
|
||||
### `app.graph.ingest`
|
||||
|
||||
- Fetches a complete file with `bfc/get-file` and `:realize? true`.
|
||||
- Rejects missing files.
|
||||
- Rejects files without file data.
|
||||
- Can run file data validation before projection.
|
||||
- Creates the DDL.
|
||||
- Loads nodes and edges through Arrow.
|
||||
- Executes post-load transforms.
|
||||
- Writes graph metadata last.
|
||||
- Treats the final metadata write as the complete-build marker.
|
||||
- Supports a persistent database path and an open connection.
|
||||
|
||||
### `app.graph.projection.document`
|
||||
|
||||
- Projects `Document`, `Page`, `Component`, and supported shape nodes.
|
||||
- Skips the page root frame.
|
||||
- Creates `IsChildOf` edges from shapes to parents.
|
||||
- Creates page edges to the document.
|
||||
- Creates component edges to the document.
|
||||
- Stores page order in `Page.index` and edge `position`.
|
||||
- Reverses the stored `:shapes` list for Penpot z-order.
|
||||
- Adds `page-id` to every projected shape.
|
||||
- Propagates an instance head `component-id` to descendants.
|
||||
- Stops component inheritance at a non-Frame shape with its own component ID.
|
||||
- Skips deleted components during cold projection.
|
||||
- Logs unsupported shape types and missing shape records.
|
||||
|
||||
### `app.graph.projection.transforms`
|
||||
|
||||
- Runs after the base nodes and edges load.
|
||||
- `link-component-instances` creates `IsInstanceOf` edges.
|
||||
- A Frame needs `component-file` to qualify as an instance head.
|
||||
- `link-shape-refs` creates `RefersTo` edges from `shape-ref`.
|
||||
- Ladybug limits multi-label relationship `MERGE` statements.
|
||||
- The transform emits one statement for each shape-table pair.
|
||||
- `link-swap-slots` creates `FillsSwapSlot` edges.
|
||||
- Swap slot IDs come from `swap-slot-<uuid>` entries in `touched`.
|
||||
- The transform removes swap slot entries from `touched` after edge creation.
|
||||
- The transform order matters because it reads and then changes `touched`.
|
||||
|
||||
### `app.graph.meta`
|
||||
|
||||
- Stores graph provenance in `GraphMeta`.
|
||||
- Stores schema version, source revision, producer, and build time.
|
||||
- The source revision identifies the file revision used for cold projection.
|
||||
|
||||
### `app.graph.stats` and `app.graph.report`
|
||||
|
||||
- `app.graph.stats` counts graph nodes and relationships from the live catalog.
|
||||
- `app.graph.report` prints ingest information for REPL use.
|
||||
|
||||
## Cold Projection Flow
|
||||
|
||||
1. Get the file row and realized file data from PostgreSQL.
|
||||
2. Read the file revision from the file row.
|
||||
3. Build the node and edge projection.
|
||||
4. Create all graph tables from the graph schema.
|
||||
5. Load node rows with Arrow.
|
||||
6. Load relationship rows with Arrow.
|
||||
7. Run `CHECKPOINT;`.
|
||||
8. Run the registered derived transforms.
|
||||
9. Write `GraphMeta` as the final build step.
|
||||
10. Return file ID, file revision, database path, projection stats, and transform stats.
|
||||
|
||||
### Projection node groups
|
||||
|
||||
- `Document` contains file-level attributes without the file data blob.
|
||||
- `Document.options` receives file-level options from the data blob.
|
||||
- `Page` contains page attributes without the page object map.
|
||||
- `Component` contains component attributes without component object maps.
|
||||
- Shape tables contain the supported shape attributes.
|
||||
- The graph stores selected derived attributes such as `page-id`.
|
||||
|
||||
### Projection relationship groups
|
||||
|
||||
- Structural edges use `IsChildOf`.
|
||||
- Page and component edges point to `Document`.
|
||||
- Derived edges come from the post-load transform registry.
|
||||
|
||||
## Incremental Sync
|
||||
|
||||
### Change source
|
||||
|
||||
- `app.rpc.commands.files-update` persists the file update first.
|
||||
- The same command publishes a `:file-change` message to the file topic.
|
||||
- The topic key is the file UUID.
|
||||
- The message contains the file ID, profile ID, session ID, revision, version, and changes.
|
||||
- Library changes also publish a team-topic message.
|
||||
- The graph session only consumes the file-topic `:file-change` messages.
|
||||
|
||||
### Session subscription
|
||||
|
||||
- `app.graph.debug/start-sync-loop!` creates a channel with a dropping buffer of 64.
|
||||
- The session subscribes the channel to the file UUID topic.
|
||||
- The loop reads one message at a time.
|
||||
- The loop ignores message types other than `:file-change`.
|
||||
- The loop stops when the channel closes.
|
||||
- `destroy-session!` closes the channel and purges its message bus subscription.
|
||||
|
||||
### Session state
|
||||
|
||||
- Sessions are stored in a global `defonce` atom.
|
||||
- The map key is the string form of `profile-id`.
|
||||
- One profile has one graph session.
|
||||
- Loading another file first destroys the old session.
|
||||
- A session stores the Ladybug database and connection.
|
||||
- A session stores a shared lock for graph access.
|
||||
- A session stores file metadata.
|
||||
- A session stores the incremental sync index.
|
||||
- A session stores the message bus channel.
|
||||
- A session stores load time and profile ID.
|
||||
- The session keeps projection statistics but drops full projection rows after index creation.
|
||||
|
||||
### Sync index
|
||||
|
||||
- `build-index` starts from the complete cold projection.
|
||||
- The index stores the graph file ID and document ID.
|
||||
- The index stores the current graph revision.
|
||||
- The index stores page IDs, names, and positions.
|
||||
- The index stores component IDs, names, and deleted state.
|
||||
- The index stores shape table, parent, position, frame, page, and component context.
|
||||
- The index stores child IDs by parent ID.
|
||||
- The index supports later change application without another PostgreSQL file read.
|
||||
|
||||
### Change application
|
||||
|
||||
- `apply-changes!` processes the change list in source order.
|
||||
- Each supported change returns a new index and a list of Cypher statements.
|
||||
- Unsupported changes enter the `:skipped` result.
|
||||
- Supported changes enter the `:applied` result.
|
||||
- The function collects all statements before it executes them.
|
||||
- The function appends a document revision statement when at least one change applies.
|
||||
- The index revision advances only when at least one change applies.
|
||||
- A larger incoming revision than the index revision creates a warning.
|
||||
- A revision gap does not trigger catch-up.
|
||||
|
||||
### Shape change rules
|
||||
|
||||
- `:add-obj` reuses `projection.document/denormalized-shape`.
|
||||
- `:add-obj` creates the shape node and its parent edge.
|
||||
- `:mod-obj` applies supported `:set` operations to graph columns.
|
||||
- `:mod-obj` keeps false and zero values as values.
|
||||
- `:del-obj` deletes shapes in deep post-order.
|
||||
- `:mov-objects` detaches shapes from the old parent.
|
||||
- `:mov-objects` closes the old sibling position gap.
|
||||
- `:mov-objects` inserts shapes at the new position.
|
||||
- `:mov-objects` updates `parent_id` and `frame_id`.
|
||||
- `:mov-objects` rewrites container `shapes` values.
|
||||
- The parent columns and child lists must match a cold projection.
|
||||
|
||||
### Page and component change rules
|
||||
|
||||
- Page add creates a projected page node and a document edge.
|
||||
- Page delete removes the page subtree.
|
||||
- Page modification updates supported page attributes.
|
||||
- Component add creates a component node and document edge.
|
||||
- Component modification updates supported component attributes.
|
||||
- Component delete uses a soft-delete state.
|
||||
- Component restore removes the soft-delete state.
|
||||
- Component purge removes the component node and document edge.
|
||||
- Component sync paths need more parity coverage than the current tests provide.
|
||||
|
||||
## Session Locking
|
||||
|
||||
- The sync loop and HTTP handlers share one lock per session.
|
||||
- The lock protects one Ladybug connection from concurrent access.
|
||||
- Queries acquire the lock before binder validation and execution.
|
||||
- Graph data export acquires the lock before catalog reads.
|
||||
- Session export acquires the lock before `EXPORT DATABASE`.
|
||||
- A long query blocks sync for the same session.
|
||||
- A sync batch blocks queries for the same session.
|
||||
- Ladybug connection thread safety is not assumed.
|
||||
|
||||
## Graph Query Rules
|
||||
|
||||
- The console accepts Cypher text.
|
||||
- Blank query text raises a validation error.
|
||||
- The query first passes Ladybug prepare and bind checks.
|
||||
- The query must pass the engine read-only analysis.
|
||||
- A mutating query is rejected.
|
||||
- The graph console does not provide a write path.
|
||||
- A session graph is rebuilt from the file by Reload.
|
||||
- Normal query results have a 200-row limit.
|
||||
- Query results use string values for the HTML console representation.
|
||||
- JSON requests receive a Transit JSON response with the query and result.
|
||||
- HTML requests receive the rendered console with the result.
|
||||
|
||||
## Graph Data Export
|
||||
|
||||
### G6 data
|
||||
|
||||
- `/dbg/actions/graph-data` reads the live Ladybug database.
|
||||
- It does not read the sync index for nodes and edges.
|
||||
- It therefore shows database drift if a batch fails after index update.
|
||||
- Node export covers all registered node tables.
|
||||
- Relationship export reads the Ladybug relationship catalog.
|
||||
- Relationship export includes source, target, relationship name, and position.
|
||||
- Node and relationship export uses a 100,000-row limit.
|
||||
- The response reports `truncated` when a limit cuts the result.
|
||||
- The response reports buffer-manager memory usage.
|
||||
|
||||
### `.lbug` export
|
||||
|
||||
- `source=file` rebuilds the persistent graph from PostgreSQL file data.
|
||||
- `source=file` runs a synchronous full ingest for each request.
|
||||
- `source=session` exports the caller profile's live in-memory graph.
|
||||
- Session export uses Ladybug `EXPORT DATABASE` to Parquet files.
|
||||
- Session export creates a new `.lbug` database with `IMPORT DATABASE`.
|
||||
- The temporary Parquet staging directory is deleted after import.
|
||||
- The final session `.lbug` file remains in the system temporary directory.
|
||||
- The HTTP response streams the database file to the caller.
|
||||
|
||||
## HTTP Routes and Access
|
||||
|
||||
- The graph routes live in `backend/src/app/http/debug.clj`.
|
||||
- The graph route list is added only when `:graph` is enabled.
|
||||
- `/dbg/graph` serves the graph console page.
|
||||
- `/dbg/actions/graph-files` returns the profile file tree.
|
||||
- `/dbg/actions/graph-load` loads a file into the profile session.
|
||||
- `/dbg/actions/graph-unload` closes the profile session.
|
||||
- `/dbg/actions/graph-reload` rebuilds the loaded file graph.
|
||||
- `/dbg/actions/graph-query` runs a read-only Cypher query.
|
||||
- `/dbg/actions/graph-sync-status` returns the sync state.
|
||||
- `/dbg/actions/graph-data` returns nodes and edges for G6.
|
||||
- `/dbg/actions/graph-export` streams a `.lbug` database.
|
||||
- The `/dbg` session middleware remains active.
|
||||
- The `/dbg` admin middleware remains active.
|
||||
- A devenv host with a profile ID passes the debug authorization rule.
|
||||
- Other hosts need a profile email in the configured admin set.
|
||||
- `/dbg/actions/graph-files` lists reachable teams, projects, and files.
|
||||
- The file tree query has a 500-file limit.
|
||||
- The graph handlers resolve graph namespaces at call time.
|
||||
- The backend requires `app.graph.debug` and `app.graph.ingest` when the flag is on.
|
||||
- Ladybug native loading then fails during route initialization instead of first use.
|
||||
|
||||
## Console Frontend
|
||||
|
||||
### Page type
|
||||
|
||||
- `graph-console.tmpl` is a backend resource template.
|
||||
- It is not a Rumext component.
|
||||
- It is not part of the main frontend route table.
|
||||
- The page uses browser `fetch` calls and a browser WebSocket.
|
||||
- The page loads G6 version `5.1.1` from jsDelivr.
|
||||
|
||||
### File tree
|
||||
|
||||
- The page fetches `/dbg/actions/graph-files`.
|
||||
- The response contains team, project, and file groups.
|
||||
- The page creates the tree with DOM APIs.
|
||||
- A file click submits the graph load form.
|
||||
- The page shows a message when no file exists.
|
||||
|
||||
### Graph rendering
|
||||
|
||||
- The page fetches `/dbg/actions/graph-data`.
|
||||
- The page converts graph nodes and edges to G6 data.
|
||||
- The page skips repaint when the node and edge signature does not change.
|
||||
- The page marks added, removed, and changed graph entities.
|
||||
- The page supports tree, dagre, circular, force, and combo layouts.
|
||||
- The page supports collapsed container combos.
|
||||
- The page has render guards at 4,000 nodes and 8,000 edges.
|
||||
- The `?safe` query option bypasses the render guard.
|
||||
- The page shows graph size by node count and relationship count.
|
||||
- The page shows buffer-manager memory in MiB.
|
||||
- The page reports a CDN failure when G6 is undefined.
|
||||
|
||||
### Query result filtering
|
||||
|
||||
- A query can return `filter_*` columns with node IDs.
|
||||
- The HTML result table hides columns with the `filter_` prefix.
|
||||
- The JSON result keeps the full result.
|
||||
- The graph view uses the hidden IDs to select matching nodes.
|
||||
- The graph view re-runs the query after graph refresh.
|
||||
- This keeps the query filter aligned with the current graph.
|
||||
- A user column named `filter_*` follows the same hiding rule.
|
||||
|
||||
### Node inspector
|
||||
|
||||
- A node click creates a query for that node.
|
||||
- The inspector calls `/dbg/actions/graph-query` with JSON negotiation.
|
||||
- The inspector displays the full projected row.
|
||||
- The inspector uses table and ID values from the graph data.
|
||||
|
||||
## WebSocket Data Flow
|
||||
|
||||
1. The page opens `/ws/notifications` with a random `session-id` query value.
|
||||
2. The page sends `:subscribe-file` with a Transit UUID value.
|
||||
3. The server makes sure that the file exists and that the profile has read permission.
|
||||
4. The server subscribes the connection to the file topic.
|
||||
5. `files_update` publishes `:file-change` to the same topic.
|
||||
6. The graph session consumes the message from its message bus subscription.
|
||||
7. The WebSocket server sends the message to the browser connection.
|
||||
8. The browser adds the change to the changelog.
|
||||
9. The browser fetches sync status after 150 milliseconds.
|
||||
10. The browser fetches graph data after a 400-millisecond debounce.
|
||||
11. The browser repaints the G6 graph when the graph data changes.
|
||||
|
||||
### WebSocket reconnect behavior
|
||||
|
||||
- The page reconnects after three seconds when the socket closes.
|
||||
- The page resubscribes to the file after the socket opens.
|
||||
- The page refreshes sync status after reconnect.
|
||||
- The page refreshes graph data after reconnect.
|
||||
- Reconnect does not recover dropped message-bus changes.
|
||||
- The page shows the sync error or skipped-change state when the status reports it.
|
||||
|
||||
## Feature Flag and Runtime Dependencies
|
||||
|
||||
- `:graph` is defined in `common/src/app/common/flags.cljc`.
|
||||
- The flag is off by default.
|
||||
- `com.ladybugdb/lbug` version `0.19.1` is a backend dependency.
|
||||
- `org.apache.arrow/arrow-memory-netty` version `18.2.0` supports Arrow `RootAllocator`.
|
||||
- The JVM uses `--enable-native-access=ALL-UNNAMED`.
|
||||
- The JVM uses `--add-opens=java.base/java.nio=ALL-UNNAMED`.
|
||||
- The JVM uses `--sun-misc-unsafe-memory-access=allow`.
|
||||
- The JVM options appear in the development alias and backend launch scripts.
|
||||
- A Ladybug version change needs new binder and parity tests.
|
||||
- A JDK version change needs a startup test with the graph flag enabled.
|
||||
|
||||
## Tests
|
||||
|
||||
### `backend-tests.graph-sync-parity-test`
|
||||
|
||||
- Uses two Ladybug `:memory:` databases.
|
||||
- Does not use PostgreSQL or a live graph session.
|
||||
- Projects initial file data into database A.
|
||||
- Applies changes to database A through incremental sync.
|
||||
- Applies the same changes to file data.
|
||||
- Projects the changed file data into database B.
|
||||
- Compares every node row and relationship row.
|
||||
- Reports differences by table, row key, and column.
|
||||
- Covers shape add, shape modification, shape deletion, movement, and page changes.
|
||||
- Contains a test that injects a sync defect and expects a graph difference.
|
||||
- Does not cover all component change variants.
|
||||
- Does not cover every movement insertion mode.
|
||||
|
||||
### `backend-tests.graph-binder-gate-test`
|
||||
|
||||
- Creates the live graph DDL in a Ladybug `:memory:` database.
|
||||
- Prepares each sync statement template without executing it.
|
||||
- Detects parse errors and missing tables.
|
||||
- Detects missing columns and bad label quoting.
|
||||
- Reports the expected read-only classification.
|
||||
- Covers reserved node labels across the node registry.
|
||||
- Reports an error result for an invalid statement.
|
||||
|
||||
### Test gaps
|
||||
|
||||
- No automated HTTP handler tests cover graph routes.
|
||||
- No automated session lifecycle tests cover load and unload.
|
||||
- No automated WebSocket tests cover graph subscription.
|
||||
- No automated export tests cover persistent and session sources.
|
||||
- Component add, modify, delete, restore, and purge need parity tests.
|
||||
- Page delete needs parity coverage.
|
||||
- Movement with `:after-shape` needs parity coverage.
|
||||
- Buffer overflow and revision gap behavior need tests.
|
||||
- Partial batch failure and recovery need tests.
|
||||
- Query timeout and long-query behavior need tests.
|
||||
|
||||
## Known Risks and Limits
|
||||
|
||||
### Dropped changes
|
||||
|
||||
- The sync channel uses a dropping buffer of 64.
|
||||
- A burst can discard file-change messages.
|
||||
- The sync loop logs a revision gap when it sees a larger revision.
|
||||
- The sync loop does not fetch missing rows from `file_change`.
|
||||
- Reload is the only built-in recovery path.
|
||||
|
||||
### Partial batch state
|
||||
|
||||
- `apply-changes!` does not provide Ladybug transaction atomicity.
|
||||
- A statement failure can leave a partly changed graph.
|
||||
- The in-memory index can advance before the database state is complete.
|
||||
- `/dbg/actions/graph-data` reads the database and exposes this drift.
|
||||
- Reload rebuilds the graph from PostgreSQL file data.
|
||||
|
||||
### Query resource use
|
||||
|
||||
- The default session query timeout is zero.
|
||||
- A costly query can hold the session lock for a long time.
|
||||
- The same lock blocks incremental sync.
|
||||
- The graph export also holds the same lock during catalog reads.
|
||||
- The graph schema has a high memory floor.
|
||||
- The console reports about 115 MiB for the wide slice before file data.
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
- Sessions have no TTL.
|
||||
- Sessions remain until unload, replacement, or process shutdown.
|
||||
- Each session owns native Ladybug memory.
|
||||
- Many profiles can create many native databases.
|
||||
- A profile load replaces its previous session.
|
||||
- Two browser tabs for one profile share one graph session.
|
||||
|
||||
### Temporary files
|
||||
|
||||
- Session export leaves the final `.lbug` file in the system temporary directory.
|
||||
- Long-lived servers can accumulate exported session databases.
|
||||
- The staging directory is deleted after import.
|
||||
|
||||
### Browser dependency
|
||||
|
||||
- The graph view depends on a runtime CDN request.
|
||||
- A network restriction can remove the G6 view.
|
||||
- Queries and session status still use backend endpoints without G6.
|
||||
|
||||
### Data exposure
|
||||
|
||||
- The graph console can list many files available to the profile.
|
||||
- The console can load complete projected file data.
|
||||
- The console can export a graph database.
|
||||
- The console can inspect all projected node attributes.
|
||||
- The console is safe only when the `/dbg` access boundary is correct.
|
||||
- The graph flag must remain off for deployments that do not need this tool.
|
||||
|
||||
### Contract drift
|
||||
|
||||
- The graph schema is a deliberate slice of the Penpot file model.
|
||||
- New source attributes do not enter the graph automatically in all cases.
|
||||
- Dropped and unprojected attributes need an explicit contract decision.
|
||||
- `applied_tokens` key mapping depends on the JSON naming function.
|
||||
- `filter_*` is a frontend convention, not a graph schema guarantee.
|
||||
|
||||
### Ladybug dialect coupling
|
||||
|
||||
- Cypher strings contain Ladybug-specific syntax.
|
||||
- Label quoting handles reserved labels explicitly.
|
||||
- Relationship transforms depend on Ladybug relationship limits.
|
||||
- Arrow loading depends on Ladybug `COPY FROM (MATCH ...)` behavior.
|
||||
- A dependency upgrade needs schema, binder, Arrow, and parity checks.
|
||||
|
||||
## REPL Helpers
|
||||
|
||||
- `app.srepl.main` resolves graph functions only when a helper runs.
|
||||
- `graph-smoke-test!` runs a basic Ladybug operation.
|
||||
- `graph-query-test!` runs a graph query test.
|
||||
- `ingest-file-to-graph!` projects a file into a graph database.
|
||||
- These helpers use `requiring-resolve` to keep the graph dependency lazy.
|
||||
|
||||
## Operational Invariants
|
||||
|
||||
- PostgreSQL file data remains authoritative.
|
||||
- Cold projection and incremental sync must produce equal graph state.
|
||||
- The graph revision must identify the last applied file revision.
|
||||
- The document revision must update when a sync batch applies.
|
||||
- A missing or skipped change must remain visible in sync status.
|
||||
- A graph query from the console must be read-only.
|
||||
- A graph session must serialize connection access.
|
||||
- Graph routes must remain behind the `:graph` flag and `/dbg` access control.
|
||||
- The Arrow allocator must outlive all Ladybug operations that use its buffers.
|
||||
- `GraphMeta` must be written after the full ingest and transforms finish.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `backend/src/app/graph/ladybug.clj`: Ladybug API and query gates.
|
||||
- `backend/src/app/graph/arrow.clj`: Arrow bulk load.
|
||||
- `backend/src/app/graph/ingest.clj`: Complete file ingest.
|
||||
- `backend/src/app/graph/debug.clj`: Session lifecycle, sync loop, query, and export.
|
||||
- `backend/src/app/graph/sync.clj`: Incremental change application.
|
||||
- `backend/src/app/graph/meta.clj`: Graph provenance.
|
||||
- `backend/src/app/graph/stats.clj`: Graph counts.
|
||||
- `backend/src/app/graph/report.clj`: REPL ingest report.
|
||||
- `backend/src/app/graph/projection/document.clj`: Base document projection.
|
||||
- `backend/src/app/graph/projection/transforms.clj`: Derived relationship transforms.
|
||||
- `backend/src/app/graph/schema/nodes.clj`: Node and relationship registry.
|
||||
- `backend/src/app/graph/schema/contract.clj`: Projection contract decisions.
|
||||
- `backend/src/app/graph/schema/projection.clj`: Malli projection schemas.
|
||||
- `backend/src/app/graph/schema/types.clj`: Malli-to-Ladybug type mapping.
|
||||
- `backend/src/app/graph/schema/values.clj`: Value coercion.
|
||||
- `backend/src/app/http/debug.clj`: Graph route registration and handlers.
|
||||
- `backend/src/app/http/websocket.clj`: File WebSocket subscription handlers.
|
||||
- `backend/src/app/rpc/commands/files_update.clj`: File-change publication.
|
||||
- `backend/src/app/main.clj`: Integrant message bus wiring.
|
||||
- `backend/resources/app/templates/graph-console.tmpl`: Graph console browser code.
|
||||
- `backend/resources/app/templates/debug.tmpl`: Debug page graph links.
|
||||
- `common/src/app/common/flags.cljc`: `:graph` feature flag.
|
||||
- `backend/test/backend_tests/graph_sync_parity_test.clj`: Cold versus sync parity.
|
||||
- `backend/test/backend_tests/graph_binder_gate_test.clj`: Cypher binder gate.
|
||||
|
||||
## Development Commands
|
||||
|
||||
- Run backend commands from the `backend/` directory.
|
||||
- Run focused parity tests with `clojure -M:dev:test --focus backend-tests.graph-sync-parity-test`.
|
||||
- Run focused binder tests with `clojure -M:dev:test --focus backend-tests.graph-binder-gate-test`.
|
||||
- Run the backend test suite with `clojure -M:dev:test`.
|
||||
- Examine Clojure formatting with `pnpm run check-fmt:clj`.
|
||||
- Run backend Clojure lint with `pnpm run lint:clj`.
|
||||
- Write test output to a file before reading or filtering it.
|
||||
@@ -30,46 +30,10 @@
|
||||
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
|
||||
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
|
||||
|
||||
## Connection Reuse Details
|
||||
|
||||
### `app.storage/resolve` patterns:
|
||||
|
||||
**1. Pool mode (default)** - `(sto/resolve cfg)`
|
||||
- Returns storage abstraction from config
|
||||
- Uses whatever database pool is available
|
||||
- **Safe to call outside transaction context**
|
||||
- Used in: `rpc/commands/media.clj:363`, `rpc/commands/auth.clj:327`, `rpc/commands/profile.clj:362`
|
||||
|
||||
**2. Connection reuse mode** - `(sto/resolve cfg ::db/reuse-conn true)`
|
||||
- Internally calls `db/get-connection cfg` to obtain connectable
|
||||
- Configures storage with the specific connection from config
|
||||
- **Must be paired with transaction that owns this connection**
|
||||
- Used in: `features/fdata.clj:100`, `rpc/commands/media.clj:425`, `rpc/commands/files_thumbnails.clj:307,319`, `binfile/v3.clj:722`
|
||||
|
||||
**3. Explicit configuration** - `(sto/configure storage conn)`
|
||||
- Sets `::db/conn` on storage map directly
|
||||
- Asserts `db/conn? connection` (storage.clj:349)
|
||||
- Used inside `db/tx-run!` blocks where `conn` is already available
|
||||
- Used in: `tasks/file_gc.clj:256`, `rpc/commands/files_thumbnails.clj:347,371`
|
||||
|
||||
### Key Warning (from function notes):
|
||||
|
||||
The improved note in `import-storage-objects` and `handle-persistence` warns:
|
||||
**Do not reuse the main database connection for storage operations within a transaction.** The storage upload process can fail mid-operation, leaving orphaned objects on the backend. If the outer transaction aborts, pending storage objects become unreconciliable because the storage subsystem registers its pending state in separate transactions.
|
||||
|
||||
### Rule of Thumb for `sto/put-object!`:
|
||||
|
||||
Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `impl/put-object`) and does not directly use `::db/conn` or `::db/pool`, **all usage of `put-object!` will never run inside a common transaction** (if configured at all). The storage backend operations are independent of the database transaction boundary.
|
||||
|
||||
## Deduplication
|
||||
|
||||
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
|
||||
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
|
||||
- The lookup only considers rows with `status='valid'`; pending rows are invisible.
|
||||
- A hit whose blob is missing is repaired in place: the same row/id is kept,
|
||||
and `put-object!` rewrites the blob under that id. This heals all existing
|
||||
references to the object. If the rewrite fails, the row is left live and
|
||||
valid for a later retry.
|
||||
- The lookup does not include file ID, profile ID, team ID, or organization ID.
|
||||
- Objects can therefore share content across users and files within one bucket.
|
||||
- Deleted objects are not reused.
|
||||
|
||||
@@ -15,6 +15,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
|
||||
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
|
||||
- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request.
|
||||
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
|
||||
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
|
||||
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
|
||||
|
||||
@@ -17,32 +17,9 @@
|
||||
|
||||
## Tile/render behavior
|
||||
|
||||
- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
|
||||
Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
|
||||
- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and
|
||||
for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles
|
||||
live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans).
|
||||
`draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct.
|
||||
- Plain text fill paint reuses `TextContent.layout` paragraphs when
|
||||
`has_usable_paint_layout` (paragraphs present + version match; during
|
||||
interactive transforms rotation/move skips width check via
|
||||
`modifier_changes_text_layout`, resize falls back to `layout_width` vs
|
||||
`get_width(selrect.width())`), via `text::try_paint_from_layout_cache`.
|
||||
The walker computes `text_layout_cache_rotation_only` from `tree` and
|
||||
passes it into `render_shape`; stroke/shadow paths pass `false`.
|
||||
- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones
|
||||
(rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only.
|
||||
Decorations are skipped when no span requests underline/strike.
|
||||
- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring
|
||||
work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is
|
||||
compose+present only.
|
||||
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
|
||||
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
|
||||
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
|
||||
- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush.
|
||||
- Zoom settle wipes the tile texture cache in `set_view_end`. Mid-zoom overlays
|
||||
key tiles by scale; shape edits must `invalidate_cached_tiles_intersecting`
|
||||
the old∪new extrect so those overlays do not keep pre-edit pixels.
|
||||
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
|
||||
- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect +
|
||||
blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true.
|
||||
- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters.
|
||||
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
|
||||
@@ -351,5 +351,5 @@ gh issue view <NUMBER> --repo penpot/penpot --json title
|
||||
## See Also
|
||||
|
||||
- End-to-end orchestration entry point: the `create-issue` skill at
|
||||
`.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry
|
||||
`.agents/skills/create-issue/SKILL.md`. The skill is a thin entry
|
||||
point; this memory is the canonical home for all issue-creation rules.
|
||||
@@ -8,6 +8,9 @@
|
||||
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
|
||||
- **Never amend a commit that has been pushed** unless the user explicitly asks.
|
||||
If the user pushes, treat that commit as final from the agent's side.
|
||||
- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is
|
||||
generated from GitHub milestones during the release process; update it only
|
||||
via the `update-changelog` skill flow or on explicit user request.
|
||||
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||
This prevents hiding test failures. See `mem:testing` for details.
|
||||
|
||||
-20
@@ -1,25 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.19.0 (Unreleased)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305))
|
||||
- Fix performance issue with WebGL render [#11240](https://github.com/penpot/penpot/issues/11240) (PR: [#11259](https://github.com/penpot/penpot/pull/11259))
|
||||
- Fix comment bubbles rendering on top of workspace dropdown menus [#10283](https://github.com/penpot/penpot/issues/10283) (PR: [#11201](https://github.com/penpot/penpot/pull/11201))
|
||||
- Fix inconsistent Mixed label in blur options and numeric inputs across 24 locales (by @filipsajdak) [#11148](https://github.com/penpot/penpot/issues/11148) (PR: [#11151](https://github.com/penpot/penpot/pull/11151))
|
||||
- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454))
|
||||
- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156))
|
||||
- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Make backend storage resilient to interrupted writes, missing files and stalled cleanup [#11344](https://github.com/penpot/penpot/issues/11344) (PR: [#11345](https://github.com/penpot/penpot/pull/11345))
|
||||
- Implement RTL support in the text editor v3 [#11262](https://github.com/penpot/penpot/issues/11262)
|
||||
- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807))
|
||||
- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237))
|
||||
- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958))
|
||||
|
||||
## 2.18.0 (Unreleased)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Read and follow the instructions in `AGENTS.md`.
|
||||
|
||||
Treat `AGENTS.md` as the canonical project instruction file.
|
||||
@@ -160,6 +160,6 @@ This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
```
|
||||
Penpot is a Kaleidos’ [open source project](https://kaleidos.net/)
|
||||
+2
-8
@@ -65,19 +65,13 @@
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.54.5"}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.54.5"}
|
||||
|
||||
com.ladybugdb/lbug {:mvn/version "0.19.1"}
|
||||
;; Required by Arrow RootAllocator (lbug only pulls arrow-memory-core).
|
||||
org.apache.arrow/arrow-memory-netty {:mvn/version "18.2.0"}}
|
||||
software.amazon.awssdk/sts {:mvn/version "2.54.5"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
{:dev
|
||||
{:jvm-opts ["--sun-misc-unsafe-memory-access=allow"
|
||||
"--enable-native-access=ALL-UNNAMED"
|
||||
;; Arrow jars are on the classpath (unnamed module), not module-path.
|
||||
"--add-opens=java.base/java.nio=ALL-UNNAMED"]
|
||||
"--enable-native-access=ALL-UNNAMED"]
|
||||
:extra-deps
|
||||
{com.bhauman/rebel-readline {:mvn/version "0.1.11"}
|
||||
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
;; This is an example on how it can be executed:
|
||||
;; clojure -Scp $(cat classpath) -M dev/script-fix-sobjects.clj
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns user
|
||||
(:require
|
||||
|
||||
@@ -236,23 +236,7 @@ Debug Main Page
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
{% if graph-enabled %}
|
||||
<fieldset>
|
||||
<legend>Export graph (Ladybug):</legend>
|
||||
<desc>Given a FILE-ID, builds the graph projection and downloads
|
||||
the `.lbug` database file.</desc>
|
||||
|
||||
<form method="get" action="/dbg/actions/graph-export">
|
||||
<div class="row">
|
||||
<input type="text" style="width:300px" name="file-id" placeholder="file-id" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" value="Download .lbug" />
|
||||
<a href="/dbg/graph">Open graph console</a>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<fieldset>
|
||||
<legend>Import binfile:</legend>
|
||||
<desc>Import penpot file in binary format.</desc>
|
||||
@@ -296,60 +280,5 @@ Debug Main Page
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<main class="dashboard wide">
|
||||
<section class="widget wide">
|
||||
<fieldset>
|
||||
<legend>Export jobs:</legend>
|
||||
<desc>
|
||||
Export jobs as the exporter left them in redis. Records expire an hour
|
||||
after the export settles, so this is a live view, not a history.
|
||||
</desc>
|
||||
|
||||
<form method="get" action="/dbg">
|
||||
<div class="row">
|
||||
<input type="text" style="width:300px" name="job-id"
|
||||
placeholder="filter by job id" value="{{export-job-filter}}" />
|
||||
<input type="submit" value="Filter" />
|
||||
<a href="/dbg">clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="scroll-box">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>JOB ID</th>
|
||||
<th>STATE</th>
|
||||
<th>PROGRESS</th>
|
||||
<th>CMD</th>
|
||||
<th>BACKEND</th>
|
||||
<th>NAME</th>
|
||||
<th>CREATED</th>
|
||||
<th>ENDED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in export-jobs %}
|
||||
<tr>
|
||||
<td><tt>{{job.id}}</tt></td>
|
||||
<td>{{job.state}}{% if job.interrupted %} (interrupted){% endif %}</td>
|
||||
<td>{{job.done}} / {{job.total}}</td>
|
||||
<td>{{job.cmd}}</td>
|
||||
<td>{{job.backend}}</td>
|
||||
<td>{{job.name}}</td>
|
||||
<td>{{job.created-at}}</td>
|
||||
<td>{{job.ended-at}}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8">No export jobs.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -143,35 +143,6 @@ nav > div:not(:last-child) {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
/* A widget that holds a table rather than a form: full width, and tall
|
||||
enough to be worth scrolling inside. */
|
||||
.dashboard.wide {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.widget.wide {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.widget.wide .scroll-box {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.widget.wide table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.widget.wide th {
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.widget input[type=submit] {
|
||||
outline: none;
|
||||
border: 1px solid gray;
|
||||
|
||||
@@ -13,10 +13,6 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
|
||||
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
|
||||
# docker/devenv/defaults.env and injected via the main service's env block.
|
||||
|
||||
if [ -f /home/selfsigned.crt ]; then
|
||||
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
|
||||
fi
|
||||
|
||||
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
|
||||
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
|
||||
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
|
||||
@@ -93,8 +89,7 @@ export JAVA_OPTS="\
|
||||
-XX:-OmitStackTraceInFastThrow \
|
||||
--sun-misc-unsafe-memory-access=allow \
|
||||
--enable-preview \
|
||||
--enable-native-access=ALL-UNNAMED \
|
||||
--add-opens=java.base/java.nio=ALL-UNNAMED";
|
||||
--enable-native-access=ALL-UNNAMED";
|
||||
|
||||
function setup_minio() {
|
||||
if [ "${PENPOT_OBJECTS_STORAGE_BACKEND}" != "s3" ]; then
|
||||
@@ -106,3 +101,5 @@ function setup_minio() {
|
||||
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
|
||||
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
#
|
||||
# Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
# Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
@@ -18,7 +18,7 @@ if [ -f ./environ ]; then
|
||||
source ./environ
|
||||
fi
|
||||
|
||||
export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
|
||||
export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS"
|
||||
|
||||
ENTRYPOINT=${1:-app.main};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.auth
|
||||
(:require
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.auth.ldap
|
||||
(:require
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.auth.oidc
|
||||
"OIDC client implementation."
|
||||
@@ -1037,7 +1037,7 @@
|
||||
provider (prepare-organization-sso-provider cfg sso)
|
||||
_info (get-info cfg provider state code)
|
||||
session (session/get-session request)
|
||||
exp (ct/in-future {:minutes 15})]
|
||||
exp (ct/in-future {:hours 4})]
|
||||
(when (and session organization-id)
|
||||
(let [props (-> (or (:props session) {})
|
||||
(update :sso assoc organization-id exp))]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.auth.passwords
|
||||
"Password strength validation using Passay library."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.binfile.cleaner
|
||||
"A collection of helpers for perform cleaning of artifacts; mainly
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.binfile.common
|
||||
"A binfile related file processing common code, used for different
|
||||
@@ -27,6 +27,7 @@
|
||||
[app.features.file-migrations :as fmigr]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.storage :as sto]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.worker :as-alias wrk]
|
||||
@@ -653,6 +654,27 @@
|
||||
(db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"])
|
||||
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])))
|
||||
|
||||
(defn invalidate-thumbnails
|
||||
[cfg file-id]
|
||||
(let [storage (sto/resolve cfg)
|
||||
|
||||
sql-1
|
||||
(str "update file_tagged_object_thumbnail "
|
||||
" set deleted_at = now() "
|
||||
" where file_id=? returning media_id")
|
||||
|
||||
sql-2
|
||||
(str "update file_thumbnail "
|
||||
" set deleted_at = now() "
|
||||
" where file_id=? returning media_id")]
|
||||
|
||||
(run! #(sto/touch-object! storage %)
|
||||
(sequence
|
||||
(keep :media-id)
|
||||
(concat
|
||||
(db/exec! cfg [sql-1 file-id])
|
||||
(db/exec! cfg [sql-2 file-id]))))))
|
||||
|
||||
(defn process-file
|
||||
[cfg {:keys [id] :as file}]
|
||||
(let [libs (delay (get-resolved-file-libraries cfg file))]
|
||||
@@ -853,8 +875,8 @@
|
||||
(defn get-resolved-file-libraries
|
||||
"Get all file libraries including itself. Returns an instance of
|
||||
LoadableWeakValueMap that allows do not have strong references to
|
||||
the loaded libraries and reduce memory pressure on having
|
||||
all this libraries at the same time on processing file validation
|
||||
the loaded libraries and reduce possible memory pressure on having
|
||||
all this libraries loaded at same time on processing file validation
|
||||
or file migration.
|
||||
|
||||
This still requires at least one library at time to be loaded while
|
||||
@@ -866,47 +888,3 @@
|
||||
(cons (:id file)))
|
||||
load-fn #(get-file cfg % :migrate? false)]
|
||||
(weak/loadable-weak-value-map library-ids load-fn {id file})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; EXTERNAL LIBRARY RESOLUTION HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn slugify-name
|
||||
"Slugify a library name for cross-environment matching.
|
||||
Lowercases, replaces non-alphanumeric runs with '-', strips
|
||||
leading/trailing '-'."
|
||||
[name]
|
||||
(str/slug name))
|
||||
|
||||
(def ^:private sql:get-files-names
|
||||
"SELECT id, name FROM file WHERE id = ANY(?)")
|
||||
|
||||
(defn get-files-names
|
||||
"Return [{:id uuid :name string}] for the given file ids."
|
||||
[cfg ids]
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [ids-arr (db/create-array conn "uuid" ids)]
|
||||
(db/exec! conn [sql:get-files-names ids-arr])))))
|
||||
|
||||
(def ^:private sql:get-shared-files-for-team
|
||||
"SELECT f.id, f.name, f.project_id
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND f.is_shared = true
|
||||
AND f.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL")
|
||||
|
||||
(defn get-shared-files-for-team
|
||||
"Return [{:id uuid :name string}] for all shared files in a team."
|
||||
[cfg team-id]
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(db/exec! conn [sql:get-shared-files-for-team team-id]))))
|
||||
|
||||
(defn find-shared-files-by-slug
|
||||
"Return all shared files in `team-id` whose slugified name equals `slug`."
|
||||
[cfg team-id slug]
|
||||
(->> (get-shared-files-for-team cfg team-id)
|
||||
(filter #(= slug (slugify-name (:name %))))))
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.binfile.migrations
|
||||
"A binfile related migrations handling"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.binfile.v1
|
||||
"A custom, perfromance and efficiency focused binfile format impl"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.binfile.v2
|
||||
"A sqlite3 based binary file exportation with support for exportation
|
||||
|
||||
+36
-213
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.binfile.v3
|
||||
"A ZIP based binary file exportation"
|
||||
@@ -68,16 +68,7 @@
|
||||
|
||||
[:relations {:optional true}
|
||||
[:vector
|
||||
[:tuple ::sm/uuid ::sm/uuid]]]
|
||||
|
||||
;; TODO: rename to :links
|
||||
[:external-libraries {:optional true}
|
||||
[:vector
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:slug :string]
|
||||
[:used-by {:optional true} [:vector ::sm/uuid]]]]]])
|
||||
[:tuple ::sm/uuid ::sm/uuid]]]])
|
||||
|
||||
(def ^:private schema:storage-object
|
||||
[:map {:title "StorageObject"}
|
||||
@@ -227,12 +218,14 @@
|
||||
(.flush writer))
|
||||
(.closeEntry output))
|
||||
|
||||
|
||||
(defn- get-file
|
||||
[{:keys [::bfc/export-type] :as cfg} file-id]
|
||||
[{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id]
|
||||
|
||||
(let [detach? (= export-type :detach-libraries)
|
||||
embed? (= export-type :merge-libraries)]
|
||||
(when (and include-libraries embed-assets)
|
||||
(throw (IllegalArgumentException.
|
||||
"the `include-libraries` and `embed-assets` are mutally excluding options")))
|
||||
|
||||
(let [detach? (and (not embed-assets) (not include-libraries))]
|
||||
(db/tx-run! cfg (fn [cfg]
|
||||
(cond-> (bfc/get-file cfg file-id
|
||||
{:realize? true
|
||||
@@ -242,7 +235,7 @@
|
||||
(-> (ctf/detach-external-references file-id)
|
||||
(dissoc :libraries))
|
||||
|
||||
embed?
|
||||
embed-assets
|
||||
(update :data #(bfc/embed-assets cfg % file-id))
|
||||
|
||||
:always
|
||||
@@ -379,34 +372,12 @@
|
||||
(write-entry! output path encoded-tokens)))))
|
||||
|
||||
(defn- export-files
|
||||
[{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}]
|
||||
|
||||
(let [original-ids ids
|
||||
ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids)))
|
||||
rels (if (= export-type :include-libraries)
|
||||
[{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}]
|
||||
(let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
|
||||
rels (if include-libraries
|
||||
(->> (bfc/get-files-rels cfg ids)
|
||||
(mapv (juxt :file-id :library-file-id)))
|
||||
[])
|
||||
|
||||
;; Compute external libraries: referenced by original files but
|
||||
;; not included in the export set. Only relevant for :link-later.
|
||||
external-libs
|
||||
(when (= export-type :link-later)
|
||||
(let [original-rels (bfc/get-files-rels cfg original-ids)
|
||||
lib-ids (into #{} (map :library-file-id) original-rels)]
|
||||
(when (seq lib-ids)
|
||||
(let [lib-names (bfc/get-files-names cfg lib-ids)]
|
||||
(->> lib-names
|
||||
(mapv (fn [{:keys [id name]}]
|
||||
(let [slug (bfc/slugify-name name)]
|
||||
(when-not (str/blank? slug)
|
||||
{:id id
|
||||
:name name
|
||||
:slug slug
|
||||
:used-by (->> original-rels
|
||||
(filter #(= (:library-file-id %) id))
|
||||
(mapv :file-id))}))))
|
||||
(filterv some?))))))]
|
||||
[])]
|
||||
|
||||
(vswap! bfc/*state* assoc :files (d/ordered-map))
|
||||
|
||||
@@ -419,14 +390,12 @@
|
||||
|
||||
;; Write manifest file
|
||||
(let [files (:files @bfc/*state*)
|
||||
params (cond-> {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:referer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}
|
||||
(seq external-libs)
|
||||
(assoc :external-libraries external-libs))]
|
||||
params {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:referer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}]
|
||||
(write-entry! output "manifest.json" params))))
|
||||
|
||||
;; --- IMPORT IMPL
|
||||
@@ -895,13 +864,6 @@
|
||||
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]
|
||||
(events/tap :progress {:section :storage-objects})
|
||||
|
||||
;; IMPORTANT: we strongly do not reuse the main connection that can
|
||||
;; run inside a transaction because the storage upload process can
|
||||
;; fail in the middle of uploading and leave garbage on the underlying
|
||||
;; backend, if we participate in the main transaction and it aborts
|
||||
;; we will lose all registry of the pending to reconcile blobs
|
||||
;; what the storage subsystem registers in other parallel
|
||||
;; transaction
|
||||
(let [storage (sto/resolve cfg)
|
||||
entries (keep (match-storage-entry-fn) entries)]
|
||||
|
||||
@@ -958,104 +920,6 @@
|
||||
|
||||
(vswap! bfc/*state* update :index assoc id (:id sobject)))))))
|
||||
|
||||
(defn- add-to-file
|
||||
"Add a resolved library entry to a file in the file-grouped resolution.
|
||||
`key` is :done (auto-linked) or :pending (needs resolution)."
|
||||
[acc file-id file-name key entry]
|
||||
(update acc file-id (fn [file]
|
||||
(let [file (or file {:id file-id
|
||||
:name file-name
|
||||
:done []
|
||||
:pending []})]
|
||||
(update file key conj entry)))))
|
||||
|
||||
(defn- compute-link-decisions
|
||||
"Returns a map of {old-lib-id -> {:library-id ... :library ...}} for external
|
||||
libraries that should be auto-linked (single candidate AND importer has edit
|
||||
permission). Libraries with zero or multiple candidates, or where the importer
|
||||
lacks permission, are excluded — their refs should remain dangling."
|
||||
[{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/profile-id] :as cfg}]
|
||||
(reduce
|
||||
(fn [acc ext-lib]
|
||||
(let [slug (:slug ext-lib)]
|
||||
(if (nil? slug)
|
||||
acc
|
||||
(let [matching (into [] (bfc/find-shared-files-by-slug cfg team-id slug))]
|
||||
(if (not= 1 (count matching))
|
||||
acc
|
||||
(let [library (first matching)
|
||||
perms (bfc/get-file-permissions conn profile-id (:id library))]
|
||||
(if (:can-edit perms)
|
||||
(assoc acc (:id ext-lib) {:library-id (:id library)
|
||||
:library library})
|
||||
acc)))))))
|
||||
{}
|
||||
(:external-libraries manifest)))
|
||||
|
||||
(defn- resolve-and-link-libraries
|
||||
"For each external library in the manifest, resolve candidates by slug.
|
||||
Auto-links single matches (creating DB rows) and builds a file-grouped
|
||||
resolution map keyed by imported file-id (new UUID)."
|
||||
|
||||
[{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/timestamp] :as cfg} files-info]
|
||||
(assert (uuid? team-id) "team-id should be provided")
|
||||
|
||||
(let [file-ids (keys files-info)
|
||||
decisions (compute-link-decisions cfg)]
|
||||
|
||||
(reduce
|
||||
(fn [acc ext-lib]
|
||||
(assert (contains? ext-lib :id) "expected `:id` on ext-lib")
|
||||
(assert (contains? ext-lib :name) "expected `:name` on ext-lib")
|
||||
(assert (contains? ext-lib :used-by) "expected `:used-by` on ext-lib")
|
||||
(assert (contains? ext-lib :slug) "expected `:slug` on ext-lib")
|
||||
|
||||
(let [used-by (into #{} (map bfc/lookup-index) (:used-by ext-lib))]
|
||||
(cond
|
||||
;; No slug → skip
|
||||
(nil? (:slug ext-lib))
|
||||
acc
|
||||
|
||||
;; Has decision → auto-link (single match + can-edit)
|
||||
(contains? decisions (:id ext-lib))
|
||||
(let [{:keys [library-id]} (get decisions (:id ext-lib))
|
||||
used-by (filter used-by file-ids)]
|
||||
(doseq [file-id used-by]
|
||||
(let [rel-params {:file-id file-id :library-file-id library-id}]
|
||||
(db/insert! conn :file-library-rel rel-params
|
||||
{::db/on-conflict-do-nothing? true})
|
||||
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))
|
||||
(let [entry {:id (:id ext-lib)
|
||||
:name (:name ext-lib)
|
||||
:linked-to library-id}]
|
||||
(reduce (fn [acc file-id]
|
||||
(add-to-file acc file-id (get files-info file-id) :done entry))
|
||||
acc used-by)))
|
||||
|
||||
;; Has candidates but no decision → multi-match or no permission → pending
|
||||
:else
|
||||
(let [matching-libraries (into [] (bfc/find-shared-files-by-slug cfg team-id (:slug ext-lib)))]
|
||||
(if (empty? matching-libraries)
|
||||
acc
|
||||
(let [candidates (mapv (fn [lib]
|
||||
(let [project-id (:project-id lib)
|
||||
project (bfc/get-project cfg project-id)
|
||||
project-name (:name project)]
|
||||
{:id (:id lib)
|
||||
:name (:name lib)
|
||||
:project-id project-id
|
||||
:project-name project-name}))
|
||||
matching-libraries)
|
||||
entry {:id (:id ext-lib)
|
||||
:name (:name ext-lib)
|
||||
:candidates candidates}]
|
||||
(reduce (fn [acc file-id]
|
||||
(add-to-file acc file-id (get files-info file-id) :pending entry))
|
||||
acc used-by)))))))
|
||||
|
||||
{}
|
||||
(:external-libraries manifest))))
|
||||
|
||||
(defn- import-files*
|
||||
[{:keys [::manifest] :as cfg}]
|
||||
(bfc/disable-database-timeouts! cfg)
|
||||
@@ -1064,58 +928,18 @@
|
||||
|
||||
(import-storage-objects cfg)
|
||||
|
||||
;; Pre-resolve external libraries and add their id mappings to the index
|
||||
;; BEFORE importing files. This allows relink-refs (inside process-file)
|
||||
;; to correctly remap :component-file references to the destination library.
|
||||
;; Only remap when a link will actually be created (single match + can-edit).
|
||||
(let [decisions (compute-link-decisions cfg)]
|
||||
(doseq [[old-lib-id {:keys [library-id]}] decisions]
|
||||
(l/trc :hint "pre-resolving external library"
|
||||
:old-id (str old-lib-id)
|
||||
:new-id (str library-id))
|
||||
(vswap! bfc/*state* update :index assoc old-lib-id library-id)))
|
||||
|
||||
(let [files (get manifest :files)
|
||||
file-ids (reduce (fn [result file]
|
||||
(let [name' (get file :name)
|
||||
file (assoc file :name name')]
|
||||
(conj result (import-file cfg file))))
|
||||
[]
|
||||
files)
|
||||
;; Build map of file-id to file-name for resolution
|
||||
files-info (into {} (map (fn [file-id manifest-file]
|
||||
[file-id (:name manifest-file)])
|
||||
file-ids
|
||||
files))]
|
||||
(let [files (get manifest :files)
|
||||
result (reduce (fn [result file]
|
||||
(let [name' (get file :name)
|
||||
file (assoc file :name name')]
|
||||
(conj result (import-file cfg file))))
|
||||
[]
|
||||
files)]
|
||||
|
||||
(import-file-relations cfg)
|
||||
(bfm/apply-pending-migrations! cfg)
|
||||
|
||||
(let [resolution (resolve-and-link-libraries cfg files-info)]
|
||||
|
||||
(bfm/apply-pending-migrations! cfg)
|
||||
{:file-ids file-ids
|
||||
:resolution resolution})))
|
||||
|
||||
(defn- invalidate-thumbnails
|
||||
[cfg file-id]
|
||||
(let [storage (sto/resolve cfg ::db/reuse-conn true)
|
||||
|
||||
sql-1
|
||||
(str "update file_tagged_object_thumbnail "
|
||||
" set deleted_at = now() "
|
||||
" where file_id=? returning media_id")
|
||||
|
||||
sql-2
|
||||
(str "update file_thumbnail "
|
||||
" set deleted_at = now() "
|
||||
" where file_id=? returning media_id")]
|
||||
|
||||
(run! #(sto/touch-object! storage %)
|
||||
(sequence
|
||||
(keep :media-id)
|
||||
(concat
|
||||
(db/exec! cfg [sql-1 file-id])
|
||||
(db/exec! cfg [sql-2 file-id]))))))
|
||||
result))
|
||||
|
||||
(defn- import-file-and-overwrite*
|
||||
[{:keys [::manifest ::bfc/file-id] :as cfg}]
|
||||
@@ -1140,11 +964,10 @@
|
||||
(import-storage-objects cfg)
|
||||
(import-file cfg file)
|
||||
|
||||
(invalidate-thumbnails cfg file-id)
|
||||
(bfc/invalidate-thumbnails cfg file-id)
|
||||
(bfm/apply-pending-migrations! cfg)
|
||||
|
||||
{:file-ids [file-id]
|
||||
:resolution {}})))
|
||||
[file-id])))
|
||||
|
||||
(defn- import-files
|
||||
[{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}]
|
||||
@@ -1201,11 +1024,12 @@
|
||||
"Do the exportation of a specified file in custom penpot binary
|
||||
format. There are some options available for customize the output:
|
||||
|
||||
`::bfc/export-type`: determines how linked libraries are handled.
|
||||
Valid values: `:include-libraries` (include linked libraries),
|
||||
`:merge-libraries` (embed library assets in the file),
|
||||
`:detach-libraries` (treat assets as basic objects),
|
||||
`:link-later` (preserve component metadata for relinking on import)."
|
||||
`::bfc/include-libraries`: additionally to the specified file, all the
|
||||
linked libraries also will be included (including transitive
|
||||
dependencies).
|
||||
|
||||
`::bfc/embed-assets`: instead of including the libraries, embed in the
|
||||
same file library all assets used from external libraries."
|
||||
|
||||
[{:keys [::bfc/ids] :as cfg} output]
|
||||
|
||||
@@ -1221,7 +1045,6 @@
|
||||
tp (ct/tpoint)
|
||||
ab (volatile! false)
|
||||
cs (volatile! nil)]
|
||||
|
||||
(try
|
||||
(l/info :hint "start exportation" :export-id (str id))
|
||||
(binding [bfc/*state* (volatile! (bfc/initial-state))]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.config
|
||||
(:refer-clojure :exclude [get])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.db
|
||||
(:refer-clojure :exclude [get run!])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.db.sql
|
||||
(:refer-clojure :exclude [update])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.email
|
||||
"Main api for send emails."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.email.blacklist
|
||||
"Email blacklist provider"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.email.whitelist
|
||||
"Email whitelist provider"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.features.fdata
|
||||
"A `fdata/*` related feature migration helpers"
|
||||
@@ -151,13 +151,6 @@
|
||||
|
||||
(cond
|
||||
(= backend "storage")
|
||||
;; IMPORTANT: we strongly do not reuse the main connection that can
|
||||
;; run inside a transaction because the storage upload process can
|
||||
;; fail in the middle of uploading and leave garbage on the underlying
|
||||
;; backend, if we participate in the main transaction and it aborts
|
||||
;; we will lose all registry of the pending to reconcile blobs
|
||||
;; what the storage subsystem registers in other parallel
|
||||
;; transaction
|
||||
(let [storage (sto/resolve cfg)
|
||||
content (sto/content data)
|
||||
sobject (sto/put-object! storage
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.features.file-migrations
|
||||
"Backend specific code for file migrations. Implemented as permanent feature of files."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.features.file-snapshots
|
||||
(:require
|
||||
@@ -326,11 +326,8 @@
|
||||
(let [file (d/update-when row :metadata fdata/decode-metadata)
|
||||
vern (rand-int Integer/MAX_VALUE)
|
||||
|
||||
;; We reuse the main connection here for storage operations
|
||||
;; becaue the main operations are touching and we need them
|
||||
;; to be atomic with the current transaction
|
||||
storage
|
||||
(sto/resolve cfg ::db/reuse-conn true)
|
||||
(sto/resolve cfg {::db/reuse-conn true})
|
||||
|
||||
snapshot
|
||||
(get-snapshot cfg file-id snapshot-id)]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.features.logical-deletion
|
||||
"A code related to handle logical deletion mechanism"
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.arrow
|
||||
"Bulk Ladybug ingest through in-memory Arrow.
|
||||
|
||||
Rows are built as Arrow `VectorSchemaRoot`s in the JVM's off-heap memory,
|
||||
handed to Ladybug as a virtual table, and `COPY`d into the real one. No file
|
||||
is written and no value is rendered as text for the engine to re-parse, so
|
||||
nothing in this path needs escaping. Arrow carries MAP, STRUCT, fixed-size
|
||||
arrays and multi-line strings natively.
|
||||
|
||||
The type language is Ladybug's, read recursively by `app.graph.schema.values`;
|
||||
this namespace adds the matching Arrow `Field` and a writer for each shape.
|
||||
`values/coerce` shapes a value first — a matrix into six doubles, a colour
|
||||
into a packed integer — exactly as it does for the Cypher path, so the two
|
||||
writers cannot disagree.
|
||||
|
||||
Engine facts this file depends on, each verified against lbug 0.19.1:
|
||||
|
||||
- An Arrow table is **not** a `COPY` source identifier, but it *is* a
|
||||
MATCH-able node label: `COPY T FROM (MATCH (n:stg) RETURN n.a AS a, …)`.
|
||||
- A MAP vector's `entries` child struct must be non-nullable, and
|
||||
`MapVector/getWriter` silently promotes it to a sparse union — so map
|
||||
vectors are built from an explicit `Field` and filled child-first.
|
||||
- Ladybug quotes the column and table names it interpolates into the staged
|
||||
table's DDL, and does not quote a STRUCT member name. So a top-level field
|
||||
arrives plain and a struct member whose name is a reserved word (`column`)
|
||||
arrives backticked.
|
||||
- `createArrowRelTable` resolves a UUID-keyed endpoint only from a
|
||||
`FixedSizeBinary(16)` column carrying the `arrow.uuid` extension, so edges
|
||||
are staged as a node table and joined by the `COPY` subquery instead."
|
||||
(:require
|
||||
[app.common.json :as json]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[app.graph.schema.nodes :as nodes]
|
||||
[app.graph.schema.values :as values]
|
||||
[clojure.string :as str])
|
||||
(:import
|
||||
com.ladybugdb.Connection
|
||||
com.ladybugdb.QueryResult
|
||||
java.nio.charset.StandardCharsets
|
||||
java.util.ArrayList
|
||||
java.util.List
|
||||
org.apache.arrow.memory.BufferAllocator
|
||||
org.apache.arrow.memory.RootAllocator
|
||||
org.apache.arrow.vector.BigIntVector
|
||||
org.apache.arrow.vector.BitVector
|
||||
org.apache.arrow.vector.complex.ListVector
|
||||
org.apache.arrow.vector.complex.MapVector
|
||||
org.apache.arrow.vector.complex.StructVector
|
||||
org.apache.arrow.vector.FieldVector
|
||||
org.apache.arrow.vector.Float8Vector
|
||||
org.apache.arrow.vector.TimeStampMicroVector
|
||||
org.apache.arrow.vector.types.FloatingPointPrecision
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$Bool
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$FloatingPoint
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$Int
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$List
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$Map
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$Struct
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$Timestamp
|
||||
org.apache.arrow.vector.types.pojo.ArrowType$Utf8
|
||||
org.apache.arrow.vector.types.pojo.Field
|
||||
org.apache.arrow.vector.types.pojo.FieldType
|
||||
org.apache.arrow.vector.types.pojo.Schema
|
||||
org.apache.arrow.vector.types.TimeUnit
|
||||
org.apache.arrow.vector.UInt4Vector
|
||||
org.apache.arrow.vector.VarCharVector
|
||||
org.apache.arrow.vector.VectorSchemaRoot))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; --------------------------------------------------------------- allocator
|
||||
|
||||
(defn with-allocator!
|
||||
"Invoke `(f allocator)` with a fresh Arrow `RootAllocator`.
|
||||
|
||||
The allocator must outlive the Ladybug connection, because Ladybug releases
|
||||
its references to the staged buffers only when the Arrow tables are dropped —
|
||||
which happens on connection close at the latest. Closing it first surfaces as
|
||||
`IllegalStateException: Memory was leaked`, *thrown while unwinding*, which
|
||||
hides whatever actually failed. Any diagnostic here must catch inside this
|
||||
scope."
|
||||
[f]
|
||||
(with-open [allocator (RootAllocator.)]
|
||||
(f allocator)))
|
||||
|
||||
;; ------------------------------------------------------ Ladybug type → Field
|
||||
|
||||
(def ^:private scalar-arrow-type
|
||||
"Ladybug scalar → Arrow type. `UUID` and `JSON` ride as UTF-8: Ladybug
|
||||
accepts a string into either column and does the conversion itself, which is
|
||||
cheaper than teaching this side two more binary layouts."
|
||||
{"STRING" #(ArrowType$Utf8.)
|
||||
"UUID" #(ArrowType$Utf8.)
|
||||
"JSON" #(ArrowType$Utf8.)
|
||||
"INT64" #(ArrowType$Int. 64 true)
|
||||
"UINT32" #(ArrowType$Int. 32 false)
|
||||
"DOUBLE" #(ArrowType$FloatingPoint. FloatingPointPrecision/DOUBLE)
|
||||
"BOOLEAN" #(ArrowType$Bool.)
|
||||
"TIMESTAMP" #(ArrowType$Timestamp. TimeUnit/MICROSECOND nil)})
|
||||
|
||||
(defn column-field
|
||||
"Arrow `Field` for a column of `ladybug-type`, recursively.
|
||||
|
||||
`nullable?` is false only where Arrow's own invariants demand it — a MAP's
|
||||
`entries` struct and its key."
|
||||
(^Field [^String field-name ladybug-type]
|
||||
(column-field field-name ladybug-type true))
|
||||
(^Field [^String field-name ladybug-type nullable?]
|
||||
(cond
|
||||
;; A list first: `STRUCT(…)[]` starts with `STRUCT(` but is a list of them.
|
||||
(ladybug/list-type? ladybug-type)
|
||||
(Field. field-name (FieldType. nullable? (ArrowType$List.) nil)
|
||||
[(column-field "item" (values/list-element ladybug-type))])
|
||||
|
||||
(ladybug/map-type? ladybug-type)
|
||||
(let [[key-type value-type] (values/map-types ladybug-type)]
|
||||
(Field. field-name (FieldType. nullable? (ArrowType$Map. false) nil)
|
||||
[(Field. "entries" (FieldType. false (ArrowType$Struct.) nil)
|
||||
[(column-field "key" key-type false)
|
||||
(column-field "value" value-type)])]))
|
||||
|
||||
(ladybug/struct-type? ladybug-type)
|
||||
(Field. field-name (FieldType. nullable? (ArrowType$Struct.) nil)
|
||||
;; Backticks kept: Ladybug quotes none of these when it names the
|
||||
;; staged struct's fields, so `column` has to arrive quoted.
|
||||
(mapv (fn [[field field-type]] (column-field field field-type))
|
||||
(values/struct-fields-quoted ladybug-type)))
|
||||
|
||||
:else
|
||||
(if-let [mk (get scalar-arrow-type ladybug-type)]
|
||||
(Field. field-name (FieldType. nullable? (mk) nil) nil)
|
||||
(throw (ex-info (str "no Arrow mapping for Ladybug type: " ladybug-type)
|
||||
{:ladybug-type ladybug-type}))))))
|
||||
|
||||
;; ------------------------------------------------------------------- writer
|
||||
|
||||
(defn- utf8
|
||||
^bytes [v]
|
||||
(.getBytes (if (keyword? v) (name v) (str v)) StandardCharsets/UTF_8))
|
||||
|
||||
(defn- epoch-micros
|
||||
^long [v]
|
||||
(let [^java.time.Instant inst
|
||||
(cond
|
||||
(instance? java.time.Instant v) v
|
||||
(instance? java.util.Date v) (.toInstant ^java.util.Date v)
|
||||
:else (java.time.Instant/parse (str v)))]
|
||||
(+ (* (.getEpochSecond inst) 1000000) (long (quot (.getNano inst) 1000)))))
|
||||
|
||||
(defn- write-scalar!
|
||||
[^FieldVector fv ladybug-type ^long idx v]
|
||||
(case ladybug-type
|
||||
("STRING" "UUID") (.setSafe ^VarCharVector fv idx (utf8 v))
|
||||
;; A JSON column holds JSON, not a Clojure value's print form: `str` on a
|
||||
;; map yields `{:fill-color "#000000"}`, which is EDN and which every
|
||||
;; consumer of `fills`, `content` or `position_data` would fail to parse.
|
||||
;; Same encoder the Cypher path uses (`app.graph.ladybug/format-json`).
|
||||
"JSON" (.setSafe ^VarCharVector fv idx
|
||||
(.getBytes ^String (json/encode v)
|
||||
StandardCharsets/UTF_8))
|
||||
"INT64" (.setSafe ^BigIntVector fv idx (long v))
|
||||
"UINT32" (.setSafe ^UInt4Vector fv idx (unchecked-int (long v)))
|
||||
"DOUBLE" (.setSafe ^Float8Vector fv idx (double v))
|
||||
"BOOLEAN" (.setSafe ^BitVector fv idx (if v 1 0))
|
||||
"TIMESTAMP" (.setSafe ^TimeStampMicroVector fv idx (epoch-micros v))
|
||||
(throw (ex-info (str "no Arrow writer for Ladybug type: " ladybug-type)
|
||||
{:ladybug-type ladybug-type}))))
|
||||
|
||||
(defn write-value!
|
||||
"Write already-coerced `v` into `fv` at `idx`, per `ladybug-type`.
|
||||
|
||||
`map-key-fn` renders the keys of a `MAP(STRING, …)`, for the same reason
|
||||
`app.graph.ladybug/format-typed-value` takes one: the right spelling is a
|
||||
property of the column, not of the writer."
|
||||
;; `idx` is deliberately unhinted: Clojure only accepts primitive args on fns
|
||||
;; of four or fewer, and the map-key renderer has to travel with the value.
|
||||
[^FieldVector fv ladybug-type idx v map-key-fn]
|
||||
(if (nil? v)
|
||||
(.setNull fv (int idx))
|
||||
(cond
|
||||
(ladybug/list-type? ladybug-type)
|
||||
(let [^ListVector lv fv
|
||||
child (.getDataVector lv)
|
||||
element-type (values/list-element ladybug-type)
|
||||
elements (vec (if (or (sequential? v) (set? v)) v [v]))
|
||||
start (.startNewValue lv (int idx))]
|
||||
(dotimes [i (count elements)]
|
||||
(write-value! child element-type (+ start i) (nth elements i) map-key-fn))
|
||||
(.endValue lv (int idx) (count elements)))
|
||||
|
||||
(ladybug/map-type? ladybug-type)
|
||||
(let [^MapVector mv fv
|
||||
^StructVector entries (.getDataVector mv)
|
||||
[key-type value-type] (values/map-types ladybug-type)
|
||||
key-vec (.getChild entries "key")
|
||||
value-vec (.getChild entries "value")
|
||||
render-key (if (and map-key-fn (= "STRING" key-type)) map-key-fn identity)
|
||||
pairs (vec (seq v))
|
||||
start (.startNewValue mv (int idx))]
|
||||
(dotimes [i (count pairs)]
|
||||
(let [[k mv'] (nth pairs i)
|
||||
at (+ start i)]
|
||||
;; The entries struct is non-nullable: every slot must be defined.
|
||||
(.setIndexDefined entries (int at))
|
||||
(write-value! key-vec key-type at (render-key k) nil)
|
||||
(write-value! value-vec value-type at mv' map-key-fn)))
|
||||
(.endValue mv (int idx) (count pairs)))
|
||||
|
||||
(ladybug/struct-type? ladybug-type)
|
||||
(let [^StructVector sv fv]
|
||||
(.setIndexDefined sv (int idx))
|
||||
(doseq [[quoted-field field-type] (values/struct-fields-quoted ladybug-type)]
|
||||
;; The child is named with its backticks; the coerced value is keyed
|
||||
;; without them.
|
||||
(write-value! (.getChild sv quoted-field) field-type idx
|
||||
(get v (str/replace quoted-field "`" "")) map-key-fn)))
|
||||
|
||||
:else
|
||||
(write-scalar! fv ladybug-type (long idx) v))))
|
||||
|
||||
;; ------------------------------------------------------------------ batches
|
||||
|
||||
(defn- fill-vector!
|
||||
[^VectorSchemaRoot root ^String field-name ladybug-type rows value-fn map-key-fn]
|
||||
(let [^FieldVector fv (.getVector root field-name)]
|
||||
(.allocateNew fv)
|
||||
(dotimes [i (count rows)]
|
||||
(write-value! fv ladybug-type i
|
||||
(values/coerce ladybug-type (value-fn (nth rows i)))
|
||||
map-key-fn))
|
||||
(.setValueCount fv (count rows))))
|
||||
|
||||
(defn- node-batch
|
||||
"One `VectorSchemaRoot` holding every projected row of `table`.
|
||||
|
||||
Fields carry the plain column name. Ladybug quotes every identifier it
|
||||
interpolates into the staged table's DDL, so a name that is a reserved word
|
||||
(`Page.index`, `Document.options`) arrives unquoted and a name arriving
|
||||
pre-quoted comes out doubly backticked and fails to parse. The `COPY`
|
||||
projection below is Cypher, not DDL, so it quotes the same names itself."
|
||||
^VectorSchemaRoot [^BufferAllocator allocator table rows]
|
||||
(let [columns (nodes/column-keys table)
|
||||
fields (mapv (fn [k] (column-field (nodes/column-name table k)
|
||||
(nodes/column-ladybug-type table k)))
|
||||
columns)
|
||||
root (VectorSchemaRoot/create (Schema. ^List fields) allocator)]
|
||||
(doseq [k columns]
|
||||
(fill-vector! root (nodes/column-name table k)
|
||||
(nodes/column-ladybug-type table k)
|
||||
rows #(get % k) (nodes/column-map-key-fn table k)))
|
||||
(.setRowCount root (count rows))
|
||||
root))
|
||||
|
||||
(def ^:private edge-fields
|
||||
"Edge staging columns. `id` is the staging table's own key — Ladybug wants a
|
||||
first column to key the virtual table on — and `from`/`to` land as STRING,
|
||||
hence the cast in the join."
|
||||
[(Field. "id" (FieldType. true (ArrowType$Utf8.) nil) nil)
|
||||
(Field. "from" (FieldType. true (ArrowType$Utf8.) nil) nil)
|
||||
(Field. "to" (FieldType. true (ArrowType$Utf8.) nil) nil)
|
||||
(Field. "position" (FieldType. true (ArrowType$Int. 64 true) nil) nil)])
|
||||
|
||||
(defn- edge-batch
|
||||
^VectorSchemaRoot [^BufferAllocator allocator edges]
|
||||
(let [root (VectorSchemaRoot/create (Schema. ^List edge-fields) allocator)
|
||||
^VarCharVector iv (.getVector root "id")
|
||||
^VarCharVector fv (.getVector root "from")
|
||||
^VarCharVector tv (.getVector root "to")
|
||||
^BigIntVector pv (.getVector root "position")
|
||||
n (count edges)]
|
||||
(doseq [^FieldVector v [iv fv tv pv]] (.allocateNew v))
|
||||
(dotimes [i n]
|
||||
(let [{:keys [from-id to-id position]} (nth edges i)]
|
||||
(.setSafe iv i (utf8 i))
|
||||
(.setSafe fv i (utf8 from-id))
|
||||
(.setSafe tv i (utf8 to-id))
|
||||
(if (nil? position) (.setNull pv i) (.setSafe pv i (long position)))))
|
||||
(doseq [^FieldVector v [iv fv tv pv]] (.setValueCount v n))
|
||||
(.setRowCount root n)
|
||||
root))
|
||||
|
||||
;; ------------------------------------------------------------------ staging
|
||||
|
||||
(defn- batches
|
||||
^List [^VectorSchemaRoot root]
|
||||
(doto (ArrayList.) (.add root)))
|
||||
|
||||
(defn- check!
|
||||
[^QueryResult result hint data]
|
||||
(when-not (.isSuccess result)
|
||||
(throw (ex-info (str hint ": " (.getErrorMessage result))
|
||||
(assoc data :err (.getErrorMessage result))))))
|
||||
|
||||
(defn- with-staged-table!
|
||||
"Create Arrow table `staging-name` from `root`, run `(f)`, always drop it."
|
||||
[^Connection conn ^BufferAllocator allocator ^String staging-name
|
||||
^VectorSchemaRoot root data f]
|
||||
(try
|
||||
(with-open [^QueryResult r (.createArrowTable conn staging-name (batches root) allocator)]
|
||||
(check! r "createArrowTable failed" data))
|
||||
(f)
|
||||
(finally
|
||||
;; Dropped even on failure: the staged buffers stay referenced by Ladybug
|
||||
;; until it is, and the allocator's leak check fires on close otherwise.
|
||||
(try (.close ^QueryResult (.dropArrowTable conn staging-name))
|
||||
(catch Throwable _ nil)))))
|
||||
|
||||
(defn- copy-node-table!
|
||||
[^Connection conn table ^String staging-name]
|
||||
(let [projection (str/join ", " (for [k (nodes/column-keys table)
|
||||
:let [c (nodes/cypher-property-key table k)]]
|
||||
(str "n." c " AS " c)))
|
||||
statement (str "COPY `" table "` FROM (MATCH (n:" staging-name ") "
|
||||
"RETURN " projection ");")]
|
||||
(with-open [^QueryResult r (.query conn statement)]
|
||||
(check! r (str "COPY node table failed: " table)
|
||||
{:table table :statement statement}))))
|
||||
|
||||
(defn- copy-edge-group!
|
||||
"Load one FROM/TO pair of `IsChildOf`.
|
||||
|
||||
`createArrowRelTable` is unusable here — it cannot resolve endpoints against a
|
||||
UUID-keyed node table — so the edge list is staged as a node table and the
|
||||
endpoints are resolved by the subquery. The `WHERE` is clause-level because
|
||||
this dialect prohibits an inline pattern `WHERE`, and both sides are pinned by
|
||||
label so the join cannot reach outside the pair."
|
||||
[^Connection conn from-table to-table ^String staging-name]
|
||||
(let [statement (str "COPY `IsChildOf` FROM ("
|
||||
"MATCH (e:" staging-name "), "
|
||||
"(a:" (nodes/match-label from-table) "), "
|
||||
"(b:" (nodes/match-label to-table) ") "
|
||||
"WHERE a.id = cast(e.from AS UUID) "
|
||||
"AND b.id = cast(e.to AS UUID) "
|
||||
"RETURN a.id, b.id, e.position) "
|
||||
"(from='" from-table "', to='" to-table "');")]
|
||||
(with-open [^QueryResult r (.query conn statement)]
|
||||
(check! r (str "COPY edge group failed: " from-table " -> " to-table)
|
||||
{:from-table from-table :to-table to-table :statement statement}))))
|
||||
|
||||
(defn- staging-name
|
||||
[prefix & parts]
|
||||
(str/replace (str/join "_" (cons (str "stg_" prefix) parts)) #"[^A-Za-z0-9_]" "_"))
|
||||
|
||||
;; --------------------------------------------------------------------- load
|
||||
|
||||
(defn load-projection!
|
||||
"Load projected nodes and edges into an open Ladybug connection.
|
||||
|
||||
`allocator` must outlive `conn` — see `with-allocator!`."
|
||||
[^Connection conn {:keys [nodes edges]} ^BufferAllocator allocator]
|
||||
(doseq [[table rows] (sort-by key nodes)
|
||||
:when (seq rows)]
|
||||
(let [name (staging-name "node" table)]
|
||||
(with-open [root (node-batch allocator table rows)]
|
||||
(with-staged-table! conn allocator name root {:table table}
|
||||
#(copy-node-table! conn table name)))))
|
||||
(doseq [[[from-table to-table] group]
|
||||
(sort-by key (group-by (juxt :from-table :to-table) edges))
|
||||
:when (seq group)]
|
||||
(let [name (staging-name "edge" from-table to-table)]
|
||||
(with-open [root (edge-batch allocator group)]
|
||||
(with-staged-table! conn allocator name root
|
||||
{:from-table from-table :to-table to-table}
|
||||
#(copy-edge-group! conn from-table to-table name))))))
|
||||
@@ -1,383 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.debug
|
||||
"In-memory Ladybug sessions for the debug graph console."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.time :as ct]
|
||||
[app.graph.ingest :as graph.ingest]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[app.graph.schema.nodes :as nodes]
|
||||
[app.graph.sync :as graph.sync]
|
||||
[app.msgbus :as mbus]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[promesa.exec.csp :as sp])
|
||||
(:import
|
||||
com.ladybugdb.Connection
|
||||
com.ladybugdb.Database))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def default-query
|
||||
"Default console query, written to be self-explanatory in the textarea.
|
||||
The `filter_*` columns carry node ids for the graph-view result filter;
|
||||
the results table hides them (see `hide-filter-columns` and the
|
||||
template's `renderQueryOutput`)."
|
||||
(str "MATCH (s)-[r]->(t)\n"
|
||||
"// WHERE some condition\n"
|
||||
"RETURN label(s) AS src, s.name,\n"
|
||||
" label(r) AS rel,\n"
|
||||
" t.name, label(t) AS tgt,\n"
|
||||
"\n"
|
||||
"// filter_* columns omitted from table; these needed for graph view\n"
|
||||
"s.id AS filter_src_id, t.id AS filter_tgt_id;"))
|
||||
|
||||
(defonce ^:private sessions
|
||||
(atom {}))
|
||||
|
||||
(defn- session-key
|
||||
[profile-id]
|
||||
(str profile-id))
|
||||
|
||||
(defn- destroy-session!
|
||||
[{:keys [conn db sync-ch msgbus]}]
|
||||
(when sync-ch
|
||||
(sp/close! sync-ch)
|
||||
(when msgbus
|
||||
(mbus/purge! msgbus [sync-ch])))
|
||||
(when conn
|
||||
(ex/ignoring (.close ^Connection conn)))
|
||||
(when db
|
||||
(ex/ignoring (.close ^Database db))))
|
||||
|
||||
(defn- slim-ingest-meta
|
||||
"Drop full projection rows from session meta.
|
||||
|
||||
`build-index` needs `:nodes`/`:edges` once; keeping them in the session
|
||||
duplicates the entire graph on the JVM heap for every Load."
|
||||
[meta]
|
||||
(update meta :projection #(select-keys % [:stats])))
|
||||
|
||||
(defn- format-cell
|
||||
[value]
|
||||
(cond
|
||||
(nil? value) "NULL"
|
||||
(string? value) value
|
||||
:else (str value)))
|
||||
|
||||
(defn- format-query-result
|
||||
[{:keys [columns rows truncated?]}]
|
||||
{:columns (mapv str columns)
|
||||
:rows (mapv (fn [row]
|
||||
(mapv format-cell row))
|
||||
rows)
|
||||
:truncated? truncated?
|
||||
:row-count (count rows)})
|
||||
|
||||
(defn- apply-file-change!
|
||||
[conn profile-id {:keys [changes revn file-id]}]
|
||||
(try
|
||||
(some-> (get @sessions (session-key profile-id))
|
||||
(as-> current
|
||||
(when (= file-id (:file-id current))
|
||||
(let [lock (:lock current)
|
||||
result (locking lock
|
||||
(graph.sync/apply-changes!
|
||||
conn (:index current) changes revn))
|
||||
sync-at (ct/now)]
|
||||
(swap! sessions assoc-in [(session-key profile-id) :index]
|
||||
(:index result))
|
||||
(swap! sessions update-in [(session-key profile-id) :meta]
|
||||
(fn [meta]
|
||||
(cond-> (-> meta
|
||||
(update :sync dissoc :error)
|
||||
(assoc-in [:sync :last-at] sync-at)
|
||||
(assoc-in [:sync :last-applied] (:applied result))
|
||||
(assoc-in [:sync :last-skipped] (:skipped result)))
|
||||
(seq (:applied result))
|
||||
(assoc :revn (:revn result)))))
|
||||
(when (seq (:skipped result))
|
||||
(l/dbg :hint "graph sync skipped changes"
|
||||
:file-id (str file-id)
|
||||
:revn revn
|
||||
:skipped (:skipped result)))))))
|
||||
(catch Throwable cause
|
||||
(l/wrn :hint "graph sync failed"
|
||||
:file-id (str file-id)
|
||||
:cause cause)
|
||||
(swap! sessions assoc-in [(session-key profile-id) :meta :sync :error]
|
||||
(ex-message cause)))))
|
||||
|
||||
(defn- start-sync-loop!
|
||||
[{:keys [conn profile-id file-id] :as session}]
|
||||
(if-let [msgbus (:msgbus session)]
|
||||
(let [sync-ch (sp/chan :buf (sp/dropping-buffer 64))]
|
||||
(mbus/sub! msgbus :topic file-id :chan sync-ch)
|
||||
;; Recur ONLY while the channel is open. A bare `(recur)` after
|
||||
;; `take!` returns nil would spin forever and pin this Connection
|
||||
;; (and its Ladybug Database native memory) across every Load.
|
||||
(sp/go-loop []
|
||||
(when-let [message (sp/take! sync-ch)]
|
||||
(when (= :file-change (:type message))
|
||||
(apply-file-change! conn profile-id message))
|
||||
(recur)))
|
||||
(assoc session :sync-ch sync-ch))
|
||||
session))
|
||||
|
||||
(defn session-info
|
||||
"Return a public view of the current session for `profile-id`, if any."
|
||||
[profile-id]
|
||||
(when-let [{:keys [file-id meta loaded-at index]} (get @sessions (session-key profile-id))]
|
||||
{:file-id file-id
|
||||
:name (:name meta)
|
||||
:revn (:revn meta)
|
||||
:graph-revn (:revn index)
|
||||
:schema-version (:schema-version meta)
|
||||
:projection (:projection meta)
|
||||
:sync (:sync meta)
|
||||
:loaded-at (ct/format-inst loaded-at :iso)}))
|
||||
|
||||
(defn sync-status
|
||||
"Return incremental sync status for the active session."
|
||||
[profile-id]
|
||||
(when-let [session (get @sessions (session-key profile-id))]
|
||||
(let [{:keys [file-id meta index loaded-at]} session]
|
||||
{:file-id file-id
|
||||
:revn (:revn meta)
|
||||
:graph-revn (:revn index)
|
||||
:sync (:sync meta)
|
||||
:loaded-at (ct/format-inst loaded-at :iso)})))
|
||||
|
||||
(defn unload-session!
|
||||
"Close and discard the in-memory graph for `profile-id`."
|
||||
[profile-id]
|
||||
(when-let [session (get @sessions (session-key profile-id))]
|
||||
(destroy-session! session))
|
||||
(swap! sessions dissoc (session-key profile-id)))
|
||||
|
||||
(defn load-session!
|
||||
"Ingest `file-id` into a new in-memory Ladybug database for `profile-id`."
|
||||
[cfg profile-id file-id]
|
||||
(unload-session! profile-id)
|
||||
(let [^Database db (Database.)
|
||||
^Connection conn (Connection. db)
|
||||
msgbus (::mbus/msgbus cfg)]
|
||||
(.setQueryTimeout conn 0)
|
||||
(ladybug/ensure-extensions! conn)
|
||||
(try
|
||||
(let [meta (graph.ingest/ingest-on-connection! cfg conn file-id
|
||||
:db-path ":memory:"
|
||||
:skip-stats? true
|
||||
:skip-validation? true)
|
||||
index (graph.sync/build-index file-id (:revn meta) (:projection meta))
|
||||
;; Discard projection rows after indexing — they are only needed
|
||||
;; to seed the sync index and would otherwise leak heap on each Load.
|
||||
meta (slim-ingest-meta meta)
|
||||
session
|
||||
;; :lock serializes access to the shared Connection between the
|
||||
;; msgbus sync loop (writes) and HTTP handlers (reads); the Java
|
||||
;; binding gives no thread-safety guarantee for one Connection.
|
||||
(-> {:db db
|
||||
:conn conn
|
||||
:lock (Object.)
|
||||
:file-id file-id
|
||||
:meta meta
|
||||
:index index
|
||||
:msgbus msgbus
|
||||
:profile-id profile-id
|
||||
:loaded-at (ct/now)}
|
||||
start-sync-loop!)]
|
||||
(swap! sessions assoc (session-key profile-id) session)
|
||||
meta)
|
||||
(catch Throwable cause
|
||||
(destroy-session! {:conn conn :db db :msgbus msgbus})
|
||||
(throw cause)))))
|
||||
|
||||
(defn query-session!
|
||||
"Run a read-only `statement` against the in-memory graph for `profile-id`.
|
||||
|
||||
The statement is bound against the live schema before it runs, so a query
|
||||
naming a table or a property that does not exist reports the binder's own
|
||||
message and executes nothing. The engine's read/write analysis then decides
|
||||
whether it may run at all: the console is an inspection surface, and a
|
||||
session graph is rebuilt from the file by Reload, so a mutation from here
|
||||
would produce a graph no rebuild reproduces."
|
||||
[profile-id statement]
|
||||
(when (str/blank? statement)
|
||||
(ex/raise :type :validation
|
||||
:code :missing-query
|
||||
:hint "cypher query is required"))
|
||||
(if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))]
|
||||
(locking lock
|
||||
(let [{:keys [ok? error read-only?]} (ladybug/validate-on-connection! conn statement)]
|
||||
(when-not ok?
|
||||
(ex/raise :type :validation
|
||||
:code :graph-query-invalid
|
||||
:hint error))
|
||||
(when-not read-only?
|
||||
(ex/raise :type :validation
|
||||
:code :graph-query-not-read-only
|
||||
:hint "the graph console runs read-only queries"))
|
||||
(-> (ladybug/query-on-connection! conn statement)
|
||||
format-query-result)))
|
||||
(ex/raise :type :not-found
|
||||
:code :graph-session-not-loaded
|
||||
:hint "load a file graph before running queries")))
|
||||
|
||||
(def ^:private export-max-rows
|
||||
"Row cap for graph-view export queries; far above expected per-file node
|
||||
and edge counts. `:truncated` in the export signals when it was hit."
|
||||
100000)
|
||||
|
||||
(defn- export-nodes
|
||||
[conn]
|
||||
(reduce
|
||||
(fn [acc {:keys [table]}]
|
||||
(let [stmt (str "MATCH (n:" (nodes/match-label table)
|
||||
") RETURN n.id AS id, n.name AS name;")
|
||||
{:keys [rows truncated?]}
|
||||
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
|
||||
(-> acc
|
||||
(update :nodes into
|
||||
(map (fn [[id label]]
|
||||
{:id (str id) :label (str label) :table table}))
|
||||
rows)
|
||||
(update :truncated? #(or % truncated?)))))
|
||||
{:nodes [] :truncated? false}
|
||||
nodes/node-types))
|
||||
|
||||
(defn rel-tables
|
||||
"Every relationship table in the open database, with whether it carries a
|
||||
`position` property.
|
||||
|
||||
Read from the catalog rather than listed here, so a newly ported transform's
|
||||
rel table appears in the graph view without the console being told about it."
|
||||
[conn]
|
||||
(for [[table] (:rows (ladybug/query-on-connection!
|
||||
conn "CALL show_tables() WHERE type = 'REL' RETURN name;"
|
||||
:max-rows 1000))
|
||||
:let [props (->> (ladybug/query-on-connection!
|
||||
conn (str "CALL table_info('" table "') RETURN *;")
|
||||
:max-rows 1000)
|
||||
:rows
|
||||
(into #{} (map (comp str second))))]]
|
||||
{:table table :position? (contains? props "position")}))
|
||||
|
||||
(defn- export-edges
|
||||
[conn]
|
||||
(reduce
|
||||
(fn [acc {:keys [table position?]}]
|
||||
(let [stmt (str "MATCH (a)-[r:`" table "`]->(b) "
|
||||
"RETURN a.id AS source, b.id AS target, "
|
||||
(if position? "r.position" "NULL") " AS position, "
|
||||
"'" table "' AS rel;")
|
||||
{:keys [rows truncated?]}
|
||||
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
|
||||
(-> acc
|
||||
(update :edges into
|
||||
(map (fn [[source target position rel]]
|
||||
(cond-> {:source (str source)
|
||||
:target (str target)
|
||||
:rel (str rel)}
|
||||
(some? position) (assoc :position position))))
|
||||
rows)
|
||||
(update :truncated? #(or % truncated?)))))
|
||||
{:edges [] :truncated? false}
|
||||
(rel-tables conn)))
|
||||
|
||||
(defn- bm-usage-bytes
|
||||
"Buffer-manager memory in use by this session's in-memory database
|
||||
(`CALL bm_info()` → [mem_limit mem_usage]); nil if the call fails."
|
||||
[conn]
|
||||
(ex/ignoring
|
||||
(-> (ladybug/query-on-connection! conn "CALL bm_info() RETURN *;" :max-rows 1)
|
||||
:rows first second)))
|
||||
|
||||
(defn export-graph-data!
|
||||
"Export the node/edge inventory of the in-memory graph for `profile-id`
|
||||
as plain data for the debug graph view. Returns nil when no session is
|
||||
loaded. Queries the Ladybug database (not the sync index) so the view
|
||||
reflects actual DB state, including drift."
|
||||
[profile-id]
|
||||
(when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))]
|
||||
(locking lock
|
||||
(let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn)
|
||||
{:keys [edges] edges-truncated? :truncated?} (export-edges conn)]
|
||||
{:file-id (str file-id)
|
||||
:revn (:revn index)
|
||||
:truncated (boolean (or nodes-truncated? edges-truncated?))
|
||||
:bm-bytes (bm-usage-bytes conn)
|
||||
:nodes nodes
|
||||
:edges edges}))))
|
||||
|
||||
(defn- delete-tree!
|
||||
[^java.io.File file]
|
||||
(when (.exists file)
|
||||
(doseq [f (reverse (file-seq file))]
|
||||
(.delete ^java.io.File f))))
|
||||
|
||||
(defn export-session-database!
|
||||
"Materialize the in-memory session graph of `profile-id` as a `.lbug` file.
|
||||
|
||||
The console's graph is in-memory and live-synced, so it can differ from a
|
||||
fresh projection of the same file — which is exactly when someone wants to
|
||||
take it away and query it elsewhere. There is no \"save this database\"
|
||||
primitive, so the transfer goes through Ladybug's `EXPORT DATABASE` (Parquet
|
||||
per table) into a fresh on-disk database via `IMPORT DATABASE`.
|
||||
|
||||
Note the round trip drops table comments. Nothing in the graph is addressed
|
||||
by a table comment: every table is resolved by name, so the loss costs
|
||||
nothing.
|
||||
|
||||
Returns the path of the written database, or nil when no session is loaded.
|
||||
The caller owns the file and must delete it once streamed."
|
||||
[profile-id]
|
||||
(when-let [{:keys [conn lock file-id]} (get @sessions (session-key profile-id))]
|
||||
(let [stamp (System/nanoTime)
|
||||
staging (io/file (System/getProperty "java.io.tmpdir")
|
||||
(str "penpot-graph-session-" file-id "-" stamp))
|
||||
db-path (str (io/file (System/getProperty "java.io.tmpdir")
|
||||
(str file-id "-session-" stamp ".lbug")))]
|
||||
(try
|
||||
(locking lock
|
||||
(ladybug/exec-on-connection!
|
||||
conn [(str "EXPORT DATABASE '" (.getAbsolutePath staging)
|
||||
"' (format='parquet');")]))
|
||||
(ladybug/with-connection! db-path
|
||||
(fn [target]
|
||||
(ladybug/exec-on-connection!
|
||||
target [(str "IMPORT DATABASE '" (.getAbsolutePath staging) "';")
|
||||
"CHECKPOINT;"])))
|
||||
db-path
|
||||
(finally
|
||||
(delete-tree! staging))))))
|
||||
|
||||
(defn- hide-filter-columns
|
||||
"Drop `filter_*` columns from a query result before HTML table render;
|
||||
they exist to feed node ids to the graph-view filter, not for reading.
|
||||
The JSON response path keeps the full result."
|
||||
[{:keys [columns rows] :as result}]
|
||||
(let [idxs (vec (keep-indexed
|
||||
(fn [i c] (when-not (str/starts-with? (str c) "filter_") i))
|
||||
columns))]
|
||||
(if (or (empty? idxs) (= (count idxs) (count columns)))
|
||||
result
|
||||
(assoc result
|
||||
:columns (mapv (vec columns) idxs)
|
||||
:rows (mapv (fn [row] (mapv (vec row) idxs)) rows)))))
|
||||
|
||||
(defn console-context
|
||||
"Build template data for the graph debug console page."
|
||||
[profile-id & {:keys [query query-result error message]}]
|
||||
{:session (session-info profile-id)
|
||||
:query (or query default-query)
|
||||
:query-result (some-> query-result hide-filter-columns)
|
||||
:error error
|
||||
:message message
|
||||
:default-query default-query})
|
||||
@@ -1,106 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.ingest
|
||||
"Penpot file -> Ladybug graph projection."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.db :as db]
|
||||
[app.graph.arrow :as graph.arrow]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[app.graph.meta :as graph.meta]
|
||||
[app.graph.projection.document :as projection.document]
|
||||
[app.graph.projection.transforms :as projection.transforms]
|
||||
[app.graph.schema :as schema]
|
||||
[app.graph.stats :as stats]
|
||||
[app.srepl.helpers :as h])
|
||||
(:import
|
||||
com.ladybugdb.Connection
|
||||
org.apache.arrow.memory.BufferAllocator))
|
||||
|
||||
(defn- fetch-file!
|
||||
[system file-id]
|
||||
(let [file-id (h/parse-uuid file-id)
|
||||
file (db/run! system #(bfc/get-file % file-id :realize? true))]
|
||||
(when-not file
|
||||
(ex/raise :type :not-found
|
||||
:code :file-not-found
|
||||
:file-id (str file-id)))
|
||||
(when-not (:data file)
|
||||
(ex/raise :type :validation
|
||||
:code :file-without-data
|
||||
:hint "file has no data to project"
|
||||
:file-id (str file-id)))
|
||||
[file-id file]))
|
||||
|
||||
(defn- ingest-on-connection*!
|
||||
[system ^Connection conn file-id ^BufferAllocator allocator
|
||||
{:keys [db-path skip-stats? skip-validation?] :or {skip-stats? true}}]
|
||||
(let [[file-id file] (fetch-file! system file-id)
|
||||
db-path (or db-path (ladybug/db-path-for-file file-id))
|
||||
data (:data file)]
|
||||
(when-not skip-validation?
|
||||
(ctf/check-file-data data))
|
||||
(l/inf :hint "graph ingest"
|
||||
:file-id (str file-id)
|
||||
:revn (:revn file)
|
||||
:db-path db-path
|
||||
:schema schema/schema-version)
|
||||
(let [ddl (schema/ddl-statements)
|
||||
{:keys [nodes edges stats]}
|
||||
(projection.document/projection-data data file)]
|
||||
(ladybug/exec-on-connection! conn ddl)
|
||||
(graph.arrow/load-projection! conn {:nodes nodes :edges edges} allocator)
|
||||
(ladybug/exec-on-connection! conn ["CHECKPOINT;"])
|
||||
(let [transforms (projection.transforms/apply-transforms! system conn data file)]
|
||||
;; Written last: its presence doubles as the build-complete marker.
|
||||
(graph.meta/write! conn {:file-id file-id
|
||||
:revn (:revn file)})
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:name (or (:name data) (:name file))
|
||||
:db-path db-path
|
||||
:schema-version schema/schema-version
|
||||
:projection {:stats stats
|
||||
:nodes nodes
|
||||
:edges edges}
|
||||
:transforms transforms
|
||||
:stats (when-not skip-stats?
|
||||
(stats/summarize-connection conn))}))))
|
||||
|
||||
(defn ingest-on-connection!
|
||||
"Project `file-id` into an already open Ladybug `conn`.
|
||||
|
||||
Takes an `:arrow-alloc` when the caller already owns one; otherwise it makes
|
||||
a short-lived allocator around this call. A caller that opened the connection
|
||||
itself should pass its own, because the allocator has to be closed *after*
|
||||
the connection — see `app.graph.arrow/with-allocator!`."
|
||||
[system ^Connection conn file-id & {:keys [arrow-alloc] :as opts}]
|
||||
(if arrow-alloc
|
||||
(ingest-on-connection*! system conn file-id arrow-alloc opts)
|
||||
(graph.arrow/with-allocator!
|
||||
(fn [allocator] (ingest-on-connection*! system conn file-id allocator opts)))))
|
||||
|
||||
(defn ingest-file!
|
||||
[system file-id & {:keys [db-path reset-db? skip-stats? skip-validation?]
|
||||
:or {reset-db? true}}]
|
||||
(let [db-path (or db-path (ladybug/db-path-for-file (h/parse-uuid file-id)))]
|
||||
(when reset-db?
|
||||
(ladybug/reset-db-path! db-path))
|
||||
;; Allocator outermost: Ladybug holds the staged Arrow buffers until its
|
||||
;; tables are dropped, which is no later than connection close, so the
|
||||
;; allocator must be closed after the connection and the database.
|
||||
(graph.arrow/with-allocator!
|
||||
(fn [allocator]
|
||||
(ladybug/with-connection! db-path
|
||||
(fn [conn]
|
||||
(ingest-on-connection*! system conn file-id allocator
|
||||
{:db-path db-path
|
||||
:skip-stats? skip-stats?
|
||||
:skip-validation? skip-validation?})))))))
|
||||
@@ -1,504 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.ladybug
|
||||
"Ladybug access layer for graph-backed Penpot.
|
||||
|
||||
Uses the embedded Java API (`com.ladybugdb/lbug`)."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.json :as json]
|
||||
[app.graph.schema.values :as values]
|
||||
[clojure.string :as str]
|
||||
[datoteka.fs :as fs])
|
||||
(:import
|
||||
com.ladybugdb.Connection
|
||||
com.ladybugdb.Database
|
||||
com.ladybugdb.FlatTuple
|
||||
com.ladybugdb.PreparedStatement
|
||||
com.ladybugdb.QueryResult
|
||||
com.ladybugdb.Value))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn default-graph-dir
|
||||
[]
|
||||
(or (System/getenv "PENPOT_GRAPH_DIR") "/tmp/penpot-graph"))
|
||||
|
||||
(defn db-path-for-file
|
||||
[file-id]
|
||||
(str (fs/path (default-graph-dir) (str file-id ".lbug"))))
|
||||
|
||||
(defn- memory-db-path?
|
||||
[db-path]
|
||||
(= db-path ":memory:"))
|
||||
|
||||
(defn reset-db-path!
|
||||
[db-path]
|
||||
(when-not (memory-db-path? db-path)
|
||||
(when (fs/exists? db-path)
|
||||
(fs/delete db-path))))
|
||||
|
||||
(defn escape-cypher-string
|
||||
[s]
|
||||
(-> (str s)
|
||||
(str/replace "\\" "\\\\")
|
||||
(str/replace "'" "\\'")))
|
||||
|
||||
(defn format-uuid
|
||||
[id]
|
||||
(str "uuid('" (str id) "')"))
|
||||
|
||||
(defn format-string
|
||||
[s]
|
||||
(str "'" (escape-cypher-string s) "'"))
|
||||
|
||||
(defn format-int
|
||||
[n]
|
||||
(str (long n)))
|
||||
|
||||
(defn format-number
|
||||
[n]
|
||||
(if (== n (long n))
|
||||
(format-int n)
|
||||
(str (double n))))
|
||||
|
||||
(defn format-json
|
||||
[v]
|
||||
(str "json('" (escape-cypher-string (json/encode v)) "')"))
|
||||
|
||||
(defn format-timestamp
|
||||
"Ladybug TIMESTAMP literal of the form `timestamp('<ISO-8601 instant>')`."
|
||||
[v]
|
||||
(let [s (cond
|
||||
(instance? java.time.Instant v)
|
||||
(.toString ^java.time.Instant v)
|
||||
|
||||
(instance? java.util.Date v)
|
||||
(.toString (.toInstant ^java.util.Date v))
|
||||
|
||||
(string? v)
|
||||
v
|
||||
|
||||
:else
|
||||
(str v))]
|
||||
(str "timestamp('" (escape-cypher-string s) "')")))
|
||||
|
||||
(defn format-value
|
||||
[v]
|
||||
(cond
|
||||
(nil? v) "NULL"
|
||||
(uuid? v) (format-uuid v)
|
||||
(instance? java.time.Instant v) (format-timestamp v)
|
||||
(instance? java.util.Date v) (format-timestamp v)
|
||||
(string? v) (format-string v)
|
||||
(number? v) (format-number v)
|
||||
(boolean? v) (if v "true" "false")
|
||||
(keyword? v) (format-string (name v))
|
||||
(map? v) (format-json v)
|
||||
(coll? v) (format-json v)
|
||||
:else (format-string (str v))))
|
||||
|
||||
(defn map-type?
|
||||
"Is `ladybug-type` a MAP column?"
|
||||
[ladybug-type]
|
||||
(and (string? ladybug-type)
|
||||
(str/starts-with? ladybug-type "MAP(")
|
||||
(not (str/ends-with? ladybug-type "]"))))
|
||||
|
||||
(defn list-type?
|
||||
"Is this a list or fixed-size array type? Checked before MAP and STRUCT,
|
||||
since `STRUCT(…)[]` starts with `STRUCT(` but is a list of them."
|
||||
[ladybug-type]
|
||||
(and (string? ladybug-type)
|
||||
(some? (re-matches #".+\[\d*\]$" ladybug-type))))
|
||||
|
||||
(defn struct-type?
|
||||
[ladybug-type]
|
||||
(and (string? ladybug-type)
|
||||
(str/starts-with? ladybug-type "STRUCT(")
|
||||
(not (list-type? ladybug-type))))
|
||||
|
||||
(declare format-typed-value)
|
||||
|
||||
(defn- format-typed-list
|
||||
"Cypher LIST literal, elements formatted by the element type.
|
||||
|
||||
Handles `T[]` and the fixed-size `T[n]` alike: the size constrains the column,
|
||||
not the literal."
|
||||
[ladybug-type v]
|
||||
(let [element (second (re-matches #"(.+?)\[\d*\]$" ladybug-type))
|
||||
elems (if (or (sequential? v) (set? v)) (seq v) [v])]
|
||||
(str "[" (str/join ", " (map #(format-typed-value element %) elems)) "]")))
|
||||
|
||||
(defn- format-struct
|
||||
"Cypher STRUCT literal, `{field: value, …}`.
|
||||
|
||||
*Every* declared field is emitted, NULL where the value has none: a struct
|
||||
literal's type is its field list, so omitting a field yields a different type
|
||||
and Ladybug refuses the implicit cast (`STRUCT(m2 DOUBLE, m4 DOUBLE)` cannot
|
||||
be assigned to `STRUCT(m1 …, m2 …, m3 …, m4 …)`). Penpot's layout margins are
|
||||
exactly that case — a shape sets only the sides it overrides."
|
||||
[ladybug-type v]
|
||||
(let [fields (values/struct-fields ladybug-type)]
|
||||
(str "{"
|
||||
(str/join ", "
|
||||
(for [[field field-type] fields
|
||||
:let [fv (get v field)]]
|
||||
;; Backticked for the same reason as in the DDL: a field
|
||||
;; named `column` is a keyword and will not parse bare.
|
||||
;; A bare NULL is typed STRING, which changes the struct's
|
||||
;; type as surely as omitting the field would, so absent
|
||||
;; fields get a NULL cast to their declared type.
|
||||
(str "`" field "`: "
|
||||
(if (nil? fv)
|
||||
(str "cast(NULL, '" field-type "')")
|
||||
(format-typed-value field-type fv)))))
|
||||
"}")))
|
||||
|
||||
(defn format-typed-value
|
||||
"Cypher literal for `v` in a column of `ladybug-type`.
|
||||
|
||||
Recursive over the type language, because the types are: a
|
||||
`MAP(UUID, STRUCT(…))` needs its keys, its fields and each field's own type
|
||||
honoured. `app.graph.schema.values/coerce` shapes the value first — turning a
|
||||
matrix record into six doubles, a hex colour into a packed integer — so this
|
||||
function only has to escape plain data.
|
||||
|
||||
`map-key-fn` renders the keys of a `MAP(STRING, …)`; the caller supplies it
|
||||
because the right form is a property of the column, not of this function
|
||||
(`app.graph.schema.contract/map-key-fn`)."
|
||||
([ladybug-type v] (format-typed-value ladybug-type v nil))
|
||||
([ladybug-type v map-key-fn]
|
||||
(let [v (values/coerce ladybug-type v)]
|
||||
(cond
|
||||
(nil? v)
|
||||
"NULL"
|
||||
|
||||
(list-type? ladybug-type)
|
||||
(format-typed-list ladybug-type v)
|
||||
|
||||
(map-type? ladybug-type)
|
||||
(let [[key-type value-type] (values/map-types ladybug-type)
|
||||
entries (seq v)
|
||||
format-key (if (and map-key-fn (= "STRING" key-type))
|
||||
#(format-string (map-key-fn (key %)))
|
||||
#(format-typed-value key-type (key %)))]
|
||||
(str "map([" (str/join ", " (map format-key entries))
|
||||
"], ["
|
||||
(str/join ", " (map #(format-typed-value value-type (val %)) entries))
|
||||
"])"))
|
||||
|
||||
(struct-type? ladybug-type)
|
||||
(format-struct ladybug-type v)
|
||||
|
||||
(= ladybug-type "JSON")
|
||||
(format-json v)
|
||||
|
||||
;; Coerce string ids from transit edge-cases into UUID literals.
|
||||
(= ladybug-type "UUID")
|
||||
(format-uuid v)
|
||||
|
||||
(= ladybug-type "TIMESTAMP")
|
||||
(format-timestamp v)
|
||||
|
||||
:else
|
||||
(format-value v)))))
|
||||
|
||||
(defn- ensure-semicolon
|
||||
[statement]
|
||||
(let [s (str/trim (str statement))]
|
||||
(if (str/ends-with? s ";") s (str s ";"))))
|
||||
|
||||
(defn- value->clj
|
||||
[^Value value]
|
||||
(when-not (.isNull value)
|
||||
(let [v (try
|
||||
(.getValue value)
|
||||
(catch Exception _
|
||||
;; LIST/STRUCT values are not supported by the binding's
|
||||
;; getValue (\"value_get_value\"); fall back to the textual
|
||||
;; representation so console queries do not crash.
|
||||
(.toString value)))]
|
||||
(cond
|
||||
(instance? Long v) v
|
||||
(instance? Integer v) (long v)
|
||||
(instance? Double v) v
|
||||
:else v))))
|
||||
|
||||
(defn- check-success!
|
||||
[^QueryResult result statement]
|
||||
(when-not (.isSuccess result)
|
||||
(let [err (.getErrorMessage result)]
|
||||
(ex/raise :type :internal
|
||||
:code :ladybug-query-failed
|
||||
:hint (str "Ladybug query failed: " err)
|
||||
:statement statement
|
||||
:err err))))
|
||||
|
||||
(defn- query-columns
|
||||
[^QueryResult result]
|
||||
(let [ncols (.getNumColumns result)]
|
||||
(vec (for [i (range ncols)]
|
||||
(.getColumnName result (long i))))))
|
||||
|
||||
(defn- query-row
|
||||
[^FlatTuple tuple ncols]
|
||||
(vec (for [i (range ncols)]
|
||||
(with-open [^Value value (.getValue tuple (long i))]
|
||||
(value->clj value)))))
|
||||
|
||||
(def ^:private default-query-max-rows 200)
|
||||
|
||||
(defn- read-query-rows
|
||||
[^QueryResult result ncols max-rows]
|
||||
(loop [rows [] n 0]
|
||||
(if (and (< n max-rows) (.hasNext result))
|
||||
(let [row (with-open [^FlatTuple tuple (.getNext result)]
|
||||
(query-row tuple ncols))]
|
||||
(recur (conj rows row) (inc n)))
|
||||
rows)))
|
||||
|
||||
(defn query-on-connection!
|
||||
"Execute a Cypher query on `conn` and return tabular results.
|
||||
|
||||
Returns `{:columns [...] :rows [[...] ...] :truncated? bool}`."
|
||||
[^Connection conn statement & {:keys [max-rows]
|
||||
:or {max-rows default-query-max-rows}}]
|
||||
(let [cypher (ensure-semicolon statement)]
|
||||
(with-open [^QueryResult result (.query conn cypher)]
|
||||
(check-success! result cypher)
|
||||
(let [ncols (long (.getNumColumns result))
|
||||
columns (query-columns result)
|
||||
rows (read-query-rows result ncols max-rows)
|
||||
total (long (.getNumTuples result))]
|
||||
{:columns columns
|
||||
:rows rows
|
||||
:truncated? (and (pos? total) (> total (count rows)))}))))
|
||||
|
||||
(def ^:private default-query-timeout-ms
|
||||
"0 disables query timeout (recommended for bulk COPY ingest)."
|
||||
0)
|
||||
|
||||
(defn- scalar-value
|
||||
[^Connection conn statement]
|
||||
(let [cypher (ensure-semicolon statement)]
|
||||
(with-open [^QueryResult result (.query conn cypher)]
|
||||
(check-success! result cypher)
|
||||
(when (.hasNext result)
|
||||
(with-open [^FlatTuple tuple (.getNext result)]
|
||||
(with-open [^Value value (.getValue tuple 0)]
|
||||
(value->clj value)))))))
|
||||
|
||||
(defn- extension-statement-ok?
|
||||
[err-msg]
|
||||
(let [err (str/lower-case (or err-msg ""))]
|
||||
(or (str/includes? err "already loaded")
|
||||
(str/includes? err "already installed"))))
|
||||
|
||||
(defn- run-extension-statement!
|
||||
[^Connection conn statement]
|
||||
(let [cypher (ensure-semicolon statement)]
|
||||
(with-open [^QueryResult result (.query conn cypher)]
|
||||
(when-not (.isSuccess result)
|
||||
(let [err (.getErrorMessage result)]
|
||||
(when-not (extension-statement-ok? err)
|
||||
(check-success! result cypher)))))))
|
||||
|
||||
(defn ensure-extensions!
|
||||
"Install and load Ladybug extensions required by graph ingest and sync."
|
||||
[^Connection conn]
|
||||
(run-extension-statement! conn "INSTALL json;")
|
||||
(run-extension-statement! conn "LOAD json;"))
|
||||
|
||||
(defn- run-statements!
|
||||
[^Connection conn statements]
|
||||
(doseq [statement statements]
|
||||
(let [cypher (ensure-semicolon statement)]
|
||||
(with-open [^QueryResult result (.query conn cypher)]
|
||||
(check-success! result cypher)))))
|
||||
|
||||
(defn- ensure-db-path!
|
||||
[db-path]
|
||||
(when-not (memory-db-path? db-path)
|
||||
(fs/create-dir (fs/parent db-path))))
|
||||
|
||||
(defn with-connection!
|
||||
"Open a Ladybug connection for `db-path` and invoke `(f conn)`.
|
||||
|
||||
Options:
|
||||
- `:query-timeout-ms` query timeout in milliseconds (default 0, disabled)
|
||||
|
||||
For `:memory:`, the database only lives for the duration of this call;
|
||||
all reads and writes must happen inside `f`."
|
||||
[db-path f & {:keys [query-timeout-ms]
|
||||
:or {query-timeout-ms default-query-timeout-ms}}]
|
||||
(ensure-db-path! db-path)
|
||||
(let [^Database db (if (memory-db-path? db-path)
|
||||
(Database.)
|
||||
(Database. (str db-path)))]
|
||||
(try
|
||||
(let [^Connection conn (Connection. db)]
|
||||
(try
|
||||
(.setQueryTimeout conn (long query-timeout-ms))
|
||||
(ensure-extensions! conn)
|
||||
(f conn)
|
||||
(finally
|
||||
(.close conn))))
|
||||
(finally
|
||||
(.close db)))))
|
||||
|
||||
(defn exec-on-connection!
|
||||
"Execute Cypher statements on an open Ladybug connection."
|
||||
[^Connection conn statements]
|
||||
(assert (sequential? statements) "statements should be a sequential collection")
|
||||
(run-statements! conn statements))
|
||||
|
||||
;; --- prepared statements
|
||||
|
||||
(defn- ->param-value
|
||||
"Clojure scalar → `Value` for prepared-statement binding.
|
||||
|
||||
This is the only `Value` constructor on the write path, so every parameter
|
||||
is wrapped here. Parameters are scalars: the `Value` constructor takes no
|
||||
list or map, so `MAP`, `STRUCT` and `T[]` columns stay literal-rendered
|
||||
(`format-typed-value`) and the `:else` raise below means a caller tried to
|
||||
bind one."
|
||||
^Value [v]
|
||||
(cond
|
||||
(nil? v) (Value/createNull) ; no explicit type needed
|
||||
(uuid? v) (Value. ^Object v) ; native UUID
|
||||
(string? v) (Value. ^Object v)
|
||||
(boolean? v) (Value. ^Object v)
|
||||
(integer? v) (Value. ^Object (long v))
|
||||
(number? v) (Value. ^Object (double v))
|
||||
(keyword? v) (Value. ^Object (name v))
|
||||
|
||||
(instance? java.time.Instant v) ; native TIMESTAMP
|
||||
(Value. ^Object v)
|
||||
|
||||
(instance? java.util.Date v)
|
||||
(Value. ^Object (.toInstant ^java.util.Date v))
|
||||
|
||||
:else
|
||||
(ex/raise :type :internal
|
||||
:code :ladybug-unsupported-param
|
||||
:hint (str "cannot bind a " (type v) " as a Ladybug parameter; "
|
||||
"compound columns must be literal-rendered")
|
||||
:value v)))
|
||||
|
||||
(defn- as-statement
|
||||
"Normalize a statement to `{:cypher … :params …}`.
|
||||
|
||||
A bare string binds nothing, so the sync builders can convert to bound
|
||||
parameters one family at a time."
|
||||
[stmt]
|
||||
(if (map? stmt)
|
||||
(update stmt :params #(or % {}))
|
||||
{:cypher stmt :params {}}))
|
||||
|
||||
(defn prepare-on-connection!
|
||||
"Parse and bind `statement` on `conn` without executing it.
|
||||
|
||||
The returned `PreparedStatement` is a JNI resource: the caller closes it."
|
||||
^PreparedStatement [^Connection conn statement]
|
||||
(let [cypher (ensure-semicolon statement)
|
||||
ps (.prepare conn cypher)]
|
||||
(when-not (.isSuccess ps)
|
||||
(let [err (.getErrorMessage ps)]
|
||||
(.close ps)
|
||||
(ex/raise :type :internal
|
||||
:code :ladybug-prepare-failed
|
||||
:hint (str "Ladybug prepare failed: " err)
|
||||
:statement cypher
|
||||
:err err)))
|
||||
ps))
|
||||
|
||||
(defn execute-prepared!
|
||||
"Bind `params` into `ps` and execute it on `conn`.
|
||||
|
||||
`params` keys are parameter names without the `$` (keyword or string);
|
||||
values are scalars. Every bound `Value` is closed, including the ones built
|
||||
before a later parameter is rejected."
|
||||
[^Connection conn ^PreparedStatement ps params]
|
||||
(let [vmap (java.util.HashMap.)]
|
||||
(try
|
||||
(doseq [[k v] params]
|
||||
(.put vmap (name k) (->param-value v)))
|
||||
(with-open [^QueryResult result (.execute conn ps vmap)]
|
||||
(check-success! result "<prepared>"))
|
||||
(finally
|
||||
(run! #(.close ^Value %) (.values vmap))))))
|
||||
|
||||
(defn exec-prepared-on-connection!
|
||||
"Prepare all statements, then execute all of them.
|
||||
|
||||
A parse or bind failure in *any* statement aborts the batch before the first
|
||||
mutation runs — the bind-level batch gate. Statements are
|
||||
`{:cypher … :params {…}}` maps or bare strings."
|
||||
[^Connection conn stmts]
|
||||
(assert (sequential? stmts) "statements should be a sequential collection")
|
||||
(let [prepared (volatile! [])]
|
||||
(try
|
||||
(doseq [stmt stmts]
|
||||
(let [{:keys [cypher params]} (as-statement stmt)]
|
||||
(vswap! prepared conj {:ps (prepare-on-connection! conn cypher)
|
||||
:params params})))
|
||||
(doseq [{:keys [ps params]} @prepared]
|
||||
(execute-prepared! conn ps params))
|
||||
(finally
|
||||
(run! #(.close ^PreparedStatement (:ps %)) @prepared)))))
|
||||
|
||||
(defn validate-on-connection!
|
||||
"Binder gate: parse and semantic-check `statement` against the live schema,
|
||||
without executing it.
|
||||
|
||||
Returns `{:ok? … :error … :read-only? …}`. Unlike `prepare-on-connection!`
|
||||
a failure is a return value rather than a raise: the callers are gates (the
|
||||
CI binder gate, the console read-only gate) that report it. `:read-only?` is
|
||||
the engine's own read/write analysis."
|
||||
[^Connection conn statement]
|
||||
(with-open [^PreparedStatement ps (.prepare conn (ensure-semicolon statement))]
|
||||
(let [ok? (.isSuccess ps)]
|
||||
{:ok? ok?
|
||||
:error (when-not ok? (.getErrorMessage ps))
|
||||
:read-only? (when ok? (.isReadOnly ps))})))
|
||||
|
||||
(defn query-scalar-on-connection!
|
||||
"Execute a query expected to return a single scalar value on `conn`."
|
||||
[^Connection conn statement]
|
||||
(scalar-value conn statement))
|
||||
|
||||
(defn exec!
|
||||
"Execute Cypher statements against a Ladybug database.
|
||||
|
||||
`db-path` is either `:memory:` or a filesystem path to a `.lbug` database."
|
||||
[db-path statements]
|
||||
(with-connection! db-path
|
||||
(fn [conn]
|
||||
(exec-on-connection! conn statements))))
|
||||
|
||||
(defn query-scalar!
|
||||
"Execute a query expected to return a single scalar value."
|
||||
[db-path statement]
|
||||
(with-connection! db-path
|
||||
(fn [conn]
|
||||
(query-scalar-on-connection! conn statement))))
|
||||
|
||||
(defn smoke-test!
|
||||
"Run a minimal CREATE + count against Ladybug."
|
||||
[& {:keys [db-path] :or {db-path ":memory:"}}]
|
||||
(when-not (memory-db-path? db-path)
|
||||
(reset-db-path! db-path))
|
||||
(with-connection! db-path
|
||||
(fn [^Connection conn]
|
||||
(run-statements! conn
|
||||
["CREATE NODE TABLE Person(name STRING, age INT64, PRIMARY KEY(name));"
|
||||
"CREATE (:Person {name: 'Alice', age: 25});"
|
||||
"CREATE (:Person {name: 'Bob', age: 30});"])
|
||||
{:db-path db-path
|
||||
:person-count (scalar-value conn
|
||||
"MATCH (a:Person) RETURN count(a) AS c;")})))
|
||||
@@ -1,59 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.meta
|
||||
"`GraphMeta`: the graph's own account of who built it and from what.
|
||||
|
||||
A projected graph is a cache of a file at a revision, built by a known
|
||||
schema. The row records both, so a reader can decide whether to reuse the
|
||||
database or rebuild it: a `schema_version` that no longer matches the
|
||||
registry, or a `source_revn` behind the file's, means the cache is stale.
|
||||
|
||||
The row is written *last* in a build, so its presence also marks the build
|
||||
complete.
|
||||
|
||||
Keyed by `source_file_id` rather than holding a single row: a closure graph
|
||||
is a union of per-file builds, and each contributing file keeps its own
|
||||
provenance."
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[app.graph.schema.nodes :as nodes])
|
||||
(:import
|
||||
com.ladybugdb.Connection))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def table
|
||||
"GraphMeta")
|
||||
|
||||
(def producer
|
||||
"penpot")
|
||||
|
||||
(def ddl
|
||||
"DDL for the provenance table."
|
||||
(str "CREATE NODE TABLE `" table "` ("
|
||||
"`source_file_id` UUID, "
|
||||
"`producer` STRING, "
|
||||
"`producer_version` STRING, "
|
||||
"`schema_version` STRING, "
|
||||
"`source_revn` INT64, "
|
||||
"`built_at` TIMESTAMP, "
|
||||
"PRIMARY KEY (`source_file_id`));"))
|
||||
|
||||
(defn write!
|
||||
"Record what this build produced for `file-id`."
|
||||
[^Connection conn {:keys [file-id revn]}]
|
||||
(ladybug/exec-on-connection! conn [ddl])
|
||||
(ladybug/exec-on-connection!
|
||||
conn
|
||||
[(str "MERGE (m:`" table "` {source_file_id: " (ladybug/format-uuid file-id) "}) "
|
||||
"SET m.producer = " (ladybug/format-string producer) ", "
|
||||
"m.producer_version = " (ladybug/format-string (or (System/getenv "PENPOT_BUILD") "devenv")) ", "
|
||||
"m.schema_version = " (ladybug/format-string nodes/schema-version) ", "
|
||||
"m.source_revn = " (ladybug/format-int (or revn 0)) ", "
|
||||
"m.built_at = " (ladybug/format-timestamp (ct/now)) ";")]))
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.projection.document
|
||||
"Project a Penpot file-data map into Ladybug nodes and structural edges.
|
||||
|
||||
Projects Document, Page, Component, the full shape tree (skipping the root
|
||||
frame), and `IsChildOf` edges from shapes/pages/components to their parent.
|
||||
|
||||
Two denormalizations happen here rather than in a later pass, because the
|
||||
walk already has both answers in hand and a post-ingest statement would have
|
||||
to rediscover them:
|
||||
|
||||
- `page-id` on every shape, from the page the walk is currently in;
|
||||
- `component-id` propagated from an instance head down to its descendants,
|
||||
from the head context the walk carries."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.graph.schema.nodes :as nodes]))
|
||||
|
||||
(def root-frame-id
|
||||
uuid/zero)
|
||||
|
||||
(defn- document-attrs
|
||||
"The Document node's attrs: the file row, minus its data blob.
|
||||
|
||||
`:options` is lifted out of the blob before it goes: it is file-level
|
||||
configuration a consumer wants without opening `:data`."
|
||||
[file data]
|
||||
(-> file
|
||||
(assoc :id (or (:id data) (:id file)))
|
||||
(cond-> (:options data) (assoc :options (:options data)))
|
||||
(dissoc :data)))
|
||||
|
||||
(defn- page-attrs
|
||||
[page index]
|
||||
(-> page
|
||||
(dissoc :objects)
|
||||
(cond-> (some? index) (assoc :index (long index)))))
|
||||
|
||||
(defn- component-attrs
|
||||
[component]
|
||||
(-> component
|
||||
(dissoc :objects)
|
||||
;; schema:component requires :path; some legacy rows omit it
|
||||
(update :path #(or % ""))))
|
||||
|
||||
(defn- shape-table
|
||||
[shape]
|
||||
(nodes/table-for-type (:type shape)))
|
||||
|
||||
(defn denormalized-shape
|
||||
"`shape` with `page-id` set and an inherited `component-id` filled in.
|
||||
|
||||
A shape that carries its own `component-id` keeps it; `component-ctx` only
|
||||
fills the gap for descendants (see `descend-component-ctx`)."
|
||||
[shape page-id component-ctx]
|
||||
(cond-> (assoc shape :page-id page-id)
|
||||
(and (uuid? component-ctx) (nil? (:component-id shape)))
|
||||
(assoc :component-id component-ctx)))
|
||||
|
||||
(defn- shape-node-attrs
|
||||
[table shape page-id component-ctx]
|
||||
(nodes/project-attrs table (denormalized-shape shape page-id component-ctx)))
|
||||
|
||||
(defn descend-component-ctx
|
||||
"The component context to pass to `shape`'s children.
|
||||
|
||||
Inheritance stops at the nearest ancestor Frame carrying a `component-id`,
|
||||
and any intermediate shape that carries one is a barrier:
|
||||
|
||||
- a Frame with its own `component-id` becomes the new context (it is an
|
||||
instance head, and its descendants belong to *it*, not to an outer head);
|
||||
- any other shape carrying a `component-id` blocks inheritance below it
|
||||
without being able to supply one, since only Frames are heads;
|
||||
- otherwise the context passes through unchanged."
|
||||
[table shape ctx]
|
||||
(let [own (:component-id shape)]
|
||||
(cond
|
||||
(and (some? own) (= table "Frame")) own
|
||||
(some? own) ::blocked
|
||||
:else ctx)))
|
||||
|
||||
(defn- container-table?
|
||||
[table]
|
||||
(contains? nodes/container-tables table))
|
||||
|
||||
(defn- child-shape-ids
|
||||
"Child ids in Penpot z-order (reversed from the stored :shapes list)."
|
||||
[parent]
|
||||
(when-let [shapes (:shapes parent)]
|
||||
(vec (reverse shapes))))
|
||||
|
||||
(defn- initial-acc
|
||||
[]
|
||||
{:nodes {}
|
||||
:edges []
|
||||
:stats {:documents 0 :pages 0 :components 0 :shapes 0}})
|
||||
|
||||
(declare project-shape-ids)
|
||||
|
||||
(defn- project-shape
|
||||
[objects acc table shape parent-table parent-id position page-id component-ctx]
|
||||
(let [shape-id (:id shape)
|
||||
acc' (-> acc
|
||||
(update-in [:nodes table] (fnil conj [])
|
||||
(shape-node-attrs table shape page-id component-ctx))
|
||||
(update :edges conj {:from-table table
|
||||
:from-id shape-id
|
||||
:to-table parent-table
|
||||
:to-id parent-id
|
||||
:position position})
|
||||
(update-in [:stats :shapes] inc))]
|
||||
(if-let [child-ids (when (container-table? table)
|
||||
(child-shape-ids shape))]
|
||||
(project-shape-ids objects acc' table shape-id child-ids page-id
|
||||
(descend-component-ctx table shape component-ctx))
|
||||
acc')))
|
||||
|
||||
(defn- project-shape-ids
|
||||
[objects acc parent-table parent-id child-ids page-id component-ctx]
|
||||
(reduce
|
||||
(fn [acc [position shape-id]]
|
||||
(if-let [shape (get objects shape-id)]
|
||||
(if-let [table (shape-table shape)]
|
||||
(project-shape objects acc table shape parent-table parent-id position
|
||||
page-id component-ctx)
|
||||
(do
|
||||
(l/wrn :hint "unsupported shape type for graph slice"
|
||||
:shape-id (str shape-id)
|
||||
:type (:type shape))
|
||||
acc))
|
||||
(do
|
||||
(l/wrn :hint "missing shape in page objects"
|
||||
:shape-id (str shape-id))
|
||||
acc)))
|
||||
acc
|
||||
(map-indexed vector child-ids)))
|
||||
|
||||
(defn- project-page
|
||||
[acc doc-id page position]
|
||||
(let [page-id (:id page)
|
||||
objects (:objects page)
|
||||
root (get objects root-frame-id)
|
||||
page-node (nodes/project-attrs "Page" (page-attrs page position))
|
||||
acc' (-> acc
|
||||
(update-in [:nodes "Page"] (fnil conj []) page-node)
|
||||
(update :edges conj {:from-table "Page"
|
||||
:from-id page-id
|
||||
:to-table "Document"
|
||||
:to-id doc-id
|
||||
:position position})
|
||||
(update-in [:stats :pages] inc))]
|
||||
(if-let [top-level-ids (child-shape-ids root)]
|
||||
(project-shape-ids objects acc' "Page" page-id top-level-ids page-id nil)
|
||||
acc')))
|
||||
|
||||
(defn- project-component
|
||||
[acc doc-id component position]
|
||||
(if (:deleted component)
|
||||
acc
|
||||
(let [comp-id (:id component)
|
||||
node (nodes/project-attrs "Component" (component-attrs component))]
|
||||
(-> acc
|
||||
(update-in [:nodes "Component"] (fnil conj []) node)
|
||||
(update :edges conj {:from-table "Component"
|
||||
:from-id comp-id
|
||||
:to-table "Document"
|
||||
:to-id doc-id
|
||||
:position position})
|
||||
(update-in [:stats :components] inc)))))
|
||||
|
||||
(defn- project-components
|
||||
[acc doc-id components]
|
||||
(reduce (fn [acc [position [_id component]]]
|
||||
(project-component acc doc-id component position))
|
||||
acc
|
||||
(map-indexed vector components)))
|
||||
|
||||
(defn projection-data
|
||||
"Build node/edge rows for projecting `data` into Ladybug.
|
||||
|
||||
Returns `{:nodes {table [attrs ...]} :edges [...] :stats {...}}`."
|
||||
[data file]
|
||||
(let [doc-id (or (:id data) (:id file))
|
||||
doc-node (nodes/project-attrs "Document" (document-attrs file data))
|
||||
;; `:pages` is the tab order the user sees, and `Page.index` and the
|
||||
;; page's `IsChildOf.position` are that order. Child shapes are
|
||||
;; reversed on the way in (`child-shape-ids`) because their stored
|
||||
;; list runs bottom to top; pages have no such second ordering.
|
||||
pages (seq (:pages data))
|
||||
comps (seq (:components data))
|
||||
acc0 (-> (initial-acc)
|
||||
(update-in [:nodes "Document"] (fnil conj []) doc-node)
|
||||
(assoc-in [:stats :documents] 1))
|
||||
acc (cond-> acc0
|
||||
(seq comps)
|
||||
(project-components doc-id comps))
|
||||
acc (if (empty? pages)
|
||||
acc
|
||||
(reduce (fn [acc [position page-id]]
|
||||
(if-let [page (get-in data [:pages-index page-id])]
|
||||
(project-page acc doc-id page position)
|
||||
(do
|
||||
(l/wrn :hint "missing page in pages-index"
|
||||
:page-id (str page-id))
|
||||
acc)))
|
||||
acc
|
||||
(map-indexed vector pages)))]
|
||||
(select-keys acc [:nodes :edges :stats])))
|
||||
@@ -1,149 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.projection.transforms
|
||||
"Derived graph links: edges a reader could compute from the projected
|
||||
columns, materialized once at build time so a query does not have to.
|
||||
|
||||
Each entry in `registry` names the transform, the relationship it produces,
|
||||
and the function that produces it, so adding one is a single entry and
|
||||
nothing else has to be told about it."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[app.graph.schema.nodes :as nodes])
|
||||
(:import
|
||||
com.ladybugdb.Connection))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- run-scalar!
|
||||
[^Connection conn statement]
|
||||
(or (ladybug/query-scalar-on-connection! conn statement) 0))
|
||||
|
||||
(defn- link-component-instances!
|
||||
"`IsInstanceOf` from Frame instance heads to their Component.
|
||||
|
||||
Every head is linked, the main instance and any copy root alike.
|
||||
|
||||
`component-file` is what makes a head a head here, not `component-id` alone.
|
||||
`app.common.types.component/instance-of?` requires both, and the projection
|
||||
denormalizes `component-id` down the shape tree
|
||||
(`app.graph.projection.document`), so on its own it no longer distinguishes a
|
||||
head from a shape that merely lives inside one. `component-file` is not
|
||||
denormalized and remains the head marker Penpot itself uses."
|
||||
[^Connection conn]
|
||||
(run-scalar! conn
|
||||
(str "MATCH (f:Frame), (c:Component) "
|
||||
"WHERE f.component_id = c.id "
|
||||
"AND f.component_file IS NOT NULL "
|
||||
"AND NOT COALESCE(c.deleted, false) "
|
||||
"MERGE (f)-[:IsInstanceOf]->(c) "
|
||||
"RETURN count(*);")))
|
||||
|
||||
(defn- shape-pair-statements
|
||||
"One statement per (from, to) shape-table pair.
|
||||
|
||||
Ladybug cannot create a relationship bound by multiple node labels in a
|
||||
single `MERGE`, a constraint inherited from Kùzu, which it forks (upstream
|
||||
issue kuzudb/kuzu#5841). The loop over label pairs is that dialect
|
||||
constraint, not a modelling choice."
|
||||
[f]
|
||||
(for [from nodes/shape-tables
|
||||
to nodes/shape-tables]
|
||||
(f from to)))
|
||||
|
||||
(defn- link-shape-refs!
|
||||
"`RefersTo` from an instance shape to its homologue in the main instance,
|
||||
driven by `shape-ref`."
|
||||
[^Connection conn]
|
||||
(reduce
|
||||
(fn [total statement] (+ total (run-scalar! conn statement)))
|
||||
0
|
||||
(shape-pair-statements
|
||||
(fn [from to]
|
||||
(str "MATCH (s:" (nodes/match-label from) "), (t:" (nodes/match-label to) ") "
|
||||
"WHERE s.shape_ref = t.id "
|
||||
"MERGE (s)-[:RefersTo]->(t) "
|
||||
"RETURN count(*);")))))
|
||||
|
||||
(def ^:private swap-slot-prefix "swap-slot-")
|
||||
|
||||
(def ^:private slot-uuid-expr
|
||||
;; Ladybug `substring` is 1-indexed; 36 = RFC 4122 UUID text length.
|
||||
(str "substring(touched_key, " (inc (count swap-slot-prefix)) ", 36)"))
|
||||
|
||||
(defn- link-swap-slots!
|
||||
"`FillsSwapSlot` from a swapped-in shape to the slot it replaces.
|
||||
|
||||
Penpot records a component sub-shape swap as a `swap-slot-<uuid>` entry in
|
||||
the *replacing* shape's `touched` set, where `<uuid>` names the replaced
|
||||
slot shape in the main instance. The entries are then stripped from
|
||||
`touched`, as `app.common.types.component/normal-touched-groups` does, so a
|
||||
reader of `touched` sees design edits rather than swap bookkeeping.
|
||||
|
||||
Stripping makes this the one transform that writes a column another
|
||||
transform could read. Anything reading `touched` has to run before it."
|
||||
[^Connection conn]
|
||||
(let [linked
|
||||
(reduce
|
||||
(fn [total statement] (+ total (run-scalar! conn statement)))
|
||||
0
|
||||
(shape-pair-statements
|
||||
(fn [from to]
|
||||
(str "MATCH (s:" (nodes/match-label from) ") "
|
||||
"WHERE size(s.touched) > 0 "
|
||||
"UNWIND s.touched AS touched_key "
|
||||
"WITH s, touched_key "
|
||||
"WHERE STARTS_WITH(touched_key, '" swap-slot-prefix "') "
|
||||
"WITH s, CAST(" slot-uuid-expr ", 'UUID') AS slot_id "
|
||||
"MATCH (t:" (nodes/match-label to) ") "
|
||||
"WHERE t.id = slot_id AND s.id <> t.id "
|
||||
"MERGE (s)-[r:FillsSwapSlot {slot_id: slot_id}]->(t) "
|
||||
"RETURN count(r);"))))]
|
||||
;; Strip unconditionally: an entry may name a slot that was garbage
|
||||
;; collected, so "no edge created" does not mean "nothing to strip".
|
||||
(doseq [table nodes/shape-tables]
|
||||
(ladybug/exec-on-connection!
|
||||
conn
|
||||
[(str "MATCH (s:" (nodes/match-label table) ") "
|
||||
"WHERE size(s.touched) > 0 "
|
||||
"SET s.touched = list_filter(s.touched, x -> "
|
||||
"NOT STARTS_WITH(x, '" swap-slot-prefix "'));")]))
|
||||
linked))
|
||||
|
||||
(def registry
|
||||
"Every transform this backend applies.
|
||||
|
||||
`:id` names the transform in the ingest report and the log. `:rel` names
|
||||
the relationship it produces. The three registered here read disjoint
|
||||
columns, so the vector order is not load-bearing. The one ordering
|
||||
constraint that exists is stated on `link-swap-slots!`."
|
||||
[{:id "link-component-instances" :rel :IsInstanceOf :run link-component-instances!}
|
||||
{:id "link-shape-refs" :rel :RefersTo :run link-shape-refs!}
|
||||
{:id "link-swap-slots" :rel :FillsSwapSlot :run link-swap-slots!}])
|
||||
|
||||
(defn apply-transforms!
|
||||
"Apply every registered transform to an already loaded graph.
|
||||
|
||||
Returns `{:ids [...] :counts {...} :transforms n}`, where `:ids` names what
|
||||
ran and `:counts` gives the edges each one produced."
|
||||
[_system ^Connection conn _data _file]
|
||||
(reduce
|
||||
(fn [acc {:keys [id rel run]}]
|
||||
(let [n (run conn)]
|
||||
(l/inf :hint "graph transform" :transform id :edges n)
|
||||
(-> acc
|
||||
(update :ids conj id)
|
||||
(update :counts assoc rel n)
|
||||
(assoc rel n))))
|
||||
{:ids [] :counts {} :transforms (count registry)}
|
||||
registry))
|
||||
|
||||
(defn transform-ids
|
||||
"Ids of every transform in the registry."
|
||||
[]
|
||||
(mapv :id registry))
|
||||
@@ -1,65 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.report
|
||||
(:require
|
||||
[clojure.core :as c]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn- println!
|
||||
[& lines]
|
||||
(doseq [line lines]
|
||||
(println line)))
|
||||
|
||||
(defn- section-title
|
||||
[title]
|
||||
(println! (str "\n" title)
|
||||
(str (apply str (repeat (count title) "─")))))
|
||||
|
||||
(defn- kv-line
|
||||
[k v]
|
||||
(format " %-14s %s" (str k ":") v))
|
||||
|
||||
(defn- print-node-counts
|
||||
[nodes]
|
||||
(doseq [[table count] (sort-by first nodes)
|
||||
:when (pos? (long count))]
|
||||
(println! (kv-line table count))))
|
||||
|
||||
(defn print-ingest!
|
||||
"Pretty-print the result map returned by `app.graph.ingest/ingest-file!`."
|
||||
[{:keys [file-id revn name db-path schema-version projection transforms stats]}]
|
||||
(section-title "Graph ingest")
|
||||
(println! (kv-line "File" (str name " (" file-id ")"))
|
||||
(kv-line "Revision" revn)
|
||||
(kv-line "Schema" schema-version)
|
||||
(kv-line "Database" db-path))
|
||||
|
||||
(when-let [pstats (:stats projection)]
|
||||
(section-title "Projection")
|
||||
(doseq [[k v] (sort-by key pstats)]
|
||||
(println! (kv-line (c/name k) v))))
|
||||
|
||||
(section-title "Transforms")
|
||||
(println! (kv-line "Applied" (or (:transforms transforms) 0)))
|
||||
(doseq [[rel count] (sort-by key (:counts transforms))]
|
||||
(println! (kv-line (c/name rel) count)))
|
||||
(when-let [ids (seq (:ids transforms))]
|
||||
(println! (kv-line "Recorded" (str/join ", " ids))))
|
||||
|
||||
(when stats
|
||||
(section-title "Graph counts")
|
||||
(when-let [nodes (:nodes stats)]
|
||||
(println! " Nodes")
|
||||
(print-node-counts nodes))
|
||||
(when-let [edges (:edges stats)]
|
||||
(println! " Edges")
|
||||
(doseq [[rel count] (sort-by key edges)
|
||||
:when (pos? (long count))]
|
||||
(println! (kv-line (c/name rel) count)))))
|
||||
|
||||
(println!)
|
||||
nil)
|
||||
Loaded 100 of 1923 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user