From b6be39126ad044e3a40369a22ea39772616bf0ec Mon Sep 17 00:00:00 2001 From: "Aliaksei Yaletski (Tiendil)" Date: Sat, 16 May 2026 11:12:01 +0200 Subject: [PATCH 1/6] removed obsolet specs and skills --- .agents/donna/intro.md | 35 ---- .agents/donna/usage/artifacts.md | 260 ---------------------------- .agents/donna/usage/cli.md | 227 ------------------------ .agents/donna/usage/worlds.md | 38 ---- .agents/skills/donna-do/SKILL.md | 15 -- .agents/skills/donna-start/SKILL.md | 11 -- .agents/skills/donna-stop/SKILL.md | 6 - 7 files changed, 592 deletions(-) delete mode 100644 .agents/donna/intro.md delete mode 100644 .agents/donna/usage/artifacts.md delete mode 100644 .agents/donna/usage/cli.md delete mode 100644 .agents/donna/usage/worlds.md delete mode 100644 .agents/skills/donna-do/SKILL.md delete mode 100644 .agents/skills/donna-start/SKILL.md delete mode 100644 .agents/skills/donna-stop/SKILL.md diff --git a/.agents/donna/intro.md b/.agents/donna/intro.md deleted file mode 100644 index 5a0980ba..00000000 --- a/.agents/donna/intro.md +++ /dev/null @@ -1,35 +0,0 @@ -# Introduction to the Donna tool -This document provides an introduction to the Donna — a CLI tool that helps manage the work of AI agents like Codex. - -## Overview - -Donna is designed to to invert control flow: instead of agents deciding what to do next, the `donna` tells agents what to do next by following predefined workflows. - -The core idea is that most of high-level workflows are more algorithmic than it may seem at first glance. For example, it may be difficult to fix a particular type issue in the codebase, but the overall process of polishing the codebase is quite linear: - -1. Ensure all tests pass. -2. Ensure the code is formatted correctly. -3. Ensure there are no linting errors. -4. Go to the step 1 if you changed something in the process. -5. Finish. - -We may need coding agents on the each step of the process, but there no reason for agents to manage the whole grooming loop by themselves — it take longer time, spends tokens and may lead to confusion of agents. - -## Instructions - -1. On start of the YOUR session you **MUST** read and understand instructions on using the Donna tool in the `./usage/cli.md` file. It **MUST** be a one time operation. Do not repeat it unless you forget how to use the tool. -2. If you need to perform a work with Donna, you **MUST** select an appropriate Donna workflow to perform the work and run it. -3. If there is no appropriate workflow, ask the developer for a precise instructions on what to do. -4. If you are executing a workflow operation and need to perform a complex action or changes, you SHOULD search for an appropriate workflow and run it as a child workflow — it is the intended way to use Donna. -5. To find workflows, read the following files: `**/*.donna.md`. -6. To find documentation, read the following files: `**/*.md`. - -## Journaling - -Donna creates internal journal records for important workflow events, according to the description in the `./usage/cli.md` file. - -Journal records can be forwarded to a third-party tool by configuring `[journal].cmd` in `/donna.toml`. - -The configured command is a list of command arguments. Arguments whose first and last characters are `{` and `}` are replaced with attributes of `JournalRecord`. - -If `[journal].cmd` is omitted, Donna treats it as `None` and performs no journal writing. diff --git a/.agents/donna/usage/artifacts.md b/.agents/donna/usage/artifacts.md deleted file mode 100644 index ace1f00c..00000000 --- a/.agents/donna/usage/artifacts.md +++ /dev/null @@ -1,260 +0,0 @@ -# Default Text Artifacts Behavior - -This document describes the default format and behavior of Donna's text artifacts. -This format and behavior is what should be expected by default from an artifact if not specified otherwise. - -## Overview - -An artifact is any text or binary document that Donna manages in its worlds. For example, via CLI commands `donna -p artifacts …`. - -The text artifact has a source and one or more rendered representations, produced in specific rendering modes. - -— The source is the raw text content of the artifact as it is stored on disk or in remote storage. -- The representation is the rendered version of the artifact for a specific rendering mode. In practice, the same source is rendered in `view` mode for CLI display, `execute` mode for workflow execution, and `analysis` mode for internal parsing and validation (see "Rendering artifacts"). - -To change the artifact, developers and agents edit its source. - -To get information from the artifact, developers, agents and Donna view one of its representations (typically via the view rendering mode). - -**If you need an information from the artifact, you MUST view its representation**. Artifact sources are only for editing. - -Read the `./cli.md` file to learn how to work with artifacts via Donna CLI. - -## Source Format and Rendering - -The source of the text artifact is a Jinja2 template of a Markdown document. - -When rendering the artifact, Donna processes the Jinja2 template with a predefined context (at minimum `render_mode` and `artifact_id`, and optionally `current_task`/`current_work_unit` during workflow execution), then renders the resulting Markdown content into the desired representation based on the selected rendering mode. - -**Artifact source should not use Jinja2 inheritance features** like `{{ "{% extends %}" }}` and `{{ "{% block %}" }}`. - -Donna provides a set of special directives that can and MUST be used in workflow artifact sources to enhance their behavior. Some of these directives are valid for all artifacts, some are valid only for specific section kinds. - -Here are some examples: - -- `{{ "{{ donna.lib.goto() }}" }}` — references the next workflow operation to execute. In `view`/`execute` modes it renders an exact CLI command to advance the workflow; in `analysis` mode it renders a `$$donna goto ... $$` marker used to extract workflow transitions. - -## Jinja2 rendering - -Donna allows all of Jinja2 expressions in artifact sources, except inheritance-related once: `{{ "{% extends %}" }}` , `{{ "{% block %}" }}`, etc. - -Donna intentionally hides some parts of the source in the rendered output, but they remain visible in the source files themselves (on filesystem): - -- fenced code blocks with the `donna` marker (they contain technical information for the Donna, not information for the agent). -- Jinja2 comments like `{{ "{# ... #}" }}`. - -## Rendering artifacts - -Donna renders the same artifact source into different representations depending on the rendering mode. The mode is internal to Donna (users do not select it directly) and controls how directives are expanded and which metadata is included. - -- `view` — default representation used when the CLI loads artifacts for display (`artifacts view`, `artifacts list`, `artifacts validate`). This is the human/agent-facing output. -- `execute` — representation used when Donna executes workflow operations (`sessions run`). It renders directives with task/work-unit context so the resulting text is actionable for the agent. -- `analysis` — internal representation used during parsing and validation. It emits `$$donna ... $$` markers so Donna can extract workflow transitions and other structured signals. - -## Structure of a Text Artifact - -Technically, any valid Markdown document is a valid text artifact. - -However, Donna assigns special meaning to some elements of the Markdown document to provide enhanced behavior and capabilities. - -### Sections - -Artifact is divided into multiple sections: - -- H1 header and all text till the first H2 header is considered the `head section` of the artifact. -- Each H2 header and all text till the next H2 header (or end of document) is considered a `tail section` of the artifact. - -Head section provides a description of the artifact and its purpose and MUST contain a configuration block of the artifact. The head section is also the artifact's `primary section` and is used when Donna needs to show a brief summary of the artifact, for example, when listing artifacts or when an operation targets the artifact without specifying a section. - -Tail sections describes one of the components of the artifact and CAN contain configuration blocks as well. Configuration blocks placed in subsections (h3 and below) count as part of the parent tail section. - -The content of the header (text after `#` or `##`) is considered the section title. - -Donna always interprets the head section as a general description of the artifact and treats it as the primary section. - -Donna interprets a tail section according to the primary section kind and configuration blocks in that section. - -### Configuration Blocks - -Configuration blocks are fenced code blocks with specified primary format, followed by the `donna` keyword and, optionally, list of properties. - -The supported primary formats are: TOML, JSON, YAML. **You MUST prefer TOML for configuration blocks**. - -The configuration block properties format is `property1 property2=value2 property3=value3"`, which will be parsed into a dictionary like: - -```python -{ - "property1": True, - "property2": "value2", - "property3": "value3", -} -``` - -The content of the block is parsed according to the primary format and interpreted according its properties. - -Configuration blocks are parsed by Donna and removed from rendered Markdown representations (see "Jinja2 rendering"); they remain in the source for editing and inspection on the file system. - -Fences without `donna` keyword are considered regular code blocks and have no special meaning for Donna. - -### Configuration Merging - -When a section contains multiple configuration blocks, Donna merges them in document order. - -- The merge is applied per section: the head section is merged independently, and each tail section has its own merged configuration. -- Config blocks are merged in the order they appear; later blocks override earlier keys. -- The merge is shallow: if a key maps to a nested object, a later block replaces the whole value (there is no deep merge). -- Config blocks in subsections (H3 and below) belong to their parent H2 tail section and are merged into that section's configuration. - -### Artifact Tags - -Artifacts can include semantic tags via a `tags` field in the section configuration. Tags are a list of strings and default to an empty list `[]` when omitted. - -Tags are used for deterministic artifact filtering and discovery (for example, via `donna -p artifacts list ... --predicate '"workflow" in section.tags'`). Tags are typically attached to the primary section and describe the artifact as a whole. - -The canonical list of standard tags is documented in `../intro.md`. - -## Section Kinds, Their Formats and Behaviors - -### Header section - -Donna artifact header sections MUST contain a config block with a `kind` property. The `kind` MUST be a full Python import path pointing to the primary section kind instance. - -Example (`donna` keyword skipped for examples): - -```toml -kind = "donna.lib.workflow" -start_operation_id = "start_operation" -``` - -Header section MUST also contain short human-readable description of the artifact outside of the config block. - -Plain Markdown documentation does not need a Donna config block and should use the `.md` extension. - -### Kind: Workflow - -Workflow artifacts describe a sequence of operations that Donna and agents can perform to achieve a specific goal. - -Workflow is a Finite State Machine (FSM) where each tail section describes one operation in the workflow. - -Donna validates workflows by ensuring the start operation exists, reachable sections are valid operations, final operations have no outgoing transitions, and non-final operations have at least one outgoing transition. It does not currently report unreachable sections. - -Workflow start operation MUST be declared in the workflow head-section config via `start_operation_id` -and MUST reference an existing operation section. - -Example (`donna` keyword skipped for examples): - -```toml -kind = "donna.lib.workflow" -start_operation_id = "start_operation" -``` - -Each tail section MUST contain config block with `id` and `kind` properties that specifies the identifier and kind of the operation. - -Example (`donna` keyword skipped for examples): - -```toml -id = "operation_id" -kind = "donna.lib.request_action" -``` - -The title of the workflow section MUST be a short human-readable description of the operation in the form of an imperative verb phrase, for example, `Implement the feature X`, `Create a document Y`. - -#### Kind: Operation - -The title of the operation section MUST be a short human-readable description of the operation in the form of an imperative verb phrase, for example, `Run tests`, `Format the codebase`, `Implement function X in the module Y`.x - -##### `donna.lib.request_action` - -`donna.lib.request_action` operation indicates that Donna will request the agent to perform some action. - -The content of the tail section is the text instructions for the agent on what to do. - -Example of the instructions: - -``` -1. Run `some cli command` to do something. -2. If no errors encountered `{{ '{{ donna.lib.goto("next_operation") }}' }}` -3. If errors encountered `{{ '{{ donna.lib.goto("error_handling_operation") }}' }}` - -Here may be any additional instructions, requirements, notes, references, etc. -``` - -`donna.lib.goto` directive will be rendered in the direct instruction for agent of what to call after it completed the action. - -**The body of the operation MUST contain a neat strictly defined algorithm for the agent to follow.** - -##### `donna.lib.run_script` - -`donna.lib.run_script` operation executes a script from the operation body without agent/user interaction. - -The body of the operation MUST include exactly one fenced code block whose info string includes ` donna script`. -Any other text in the operation body is ignored. - -Script example: - -```bash donna script -#!/usr/bin/bash - -echo "Hello, World!" -``` - -Configuration options: - -```toml -id = "" -kind = "donna.lib.run_script" - -save_stdout_to = "" # optional -save_stderr_to = "" # optional - -goto_on_success = "" # required -goto_on_failure = "" # required -goto_on_code = { # optional - "1" = "" - "2" = "" -} - -timeout = 60 # optional, in seconds -``` - -Routing rules: - -- Exit code `0` routes to `goto_on_success`. -- Non-zero exit codes first check `goto_on_code`, then fall back to `goto_on_failure`. -- Timeouts are treated as exit code `124`. - -Scripts are executed with the current project root as working directory. - -When `save_stdout_to` and/or `save_stderr_to` are set, the operation stores captured output in the task context -under the specified variable names. - -##### `donna.lib.output` - -`donna.lib.output` operation emits its body as an output cell and then continues to the configured next step. - -The body of the operation is rendered as an output cell during execution. - -Configuration options: - -```toml -id = "" -kind = "donna.lib.output" -next_operation_id = "" # required -``` - -##### `donna.lib.finish` - -`donna.lib.finish` operation indicates that the workflow is finished. - -The body of the operation is rendered as an output cell before the workflow completes. - -Each possible path through the workflow MUST end with this operation. - -## Directives - -Donna provides multiple directives that MUST be used in the artifact source to enhance its behavior. - -Here they are: - -1. `{{ "{{ donna.lib.goto() }}" }}` — references the next workflow operation to execute. In `view`/`execute` modes it renders an exact CLI command to advance the workflow; in `analysis` mode, it renders a `$$donna goto ... $$` marker used for transition extraction. -2. `{{ "{{ donna.lib.task_variable() }}" }}` — in `view` mode renders a placeholder note about task-variable substitution, in `execute` mode renders the actual task-context value (or an explicit error marker if missing), and in `analysis` mode renders a `$$donna task_variable ... $$` marker. diff --git a/.agents/donna/usage/cli.md b/.agents/donna/usage/cli.md deleted file mode 100644 index 22854b50..00000000 --- a/.agents/donna/usage/cli.md +++ /dev/null @@ -1,227 +0,0 @@ -# Donna Usage Instructions - -This document describes how agents MUST use Donna CLI to manage and perform their workflows. - -**Agents MUST follow the instructions and guidelines outlined in this document precisely.** - -## Overview - -`donna` is a CLI tool that helps manage the work of AI agents like OpenAI Codex. - -It is designed to invert control flow: instead of agents deciding what to do next, the Donna tells agents what to do. The tool achieves this by following predefined workflows that describe how to perform various tasks. One may look at workflows as hierarchical state machines (HSM) that guide agents through complex processes step by step. - -The core idea is that most high-level workflows are more algorithmic than it may seem at first glance. For example, it may be difficult to fix a particular problem in the codebase, but the overall process of polishing it is quite linear: - -1. Run tests, if they fail, fix the problems. -2. Format the code. -3. Run linters, if there are issues, fix them. -4. Go to the step 1 if you changed something in the process. -5. Finish. - -We may need coding agents on the each step of the process, but there no reason for agents to manage the whole loop by themselves — it takes longer time, spends tokens and confuses agents because they need to reason over long contexts. - -## Primary rules for agents - -- All work is always done in the context of a session. There is only one active session at a time. -- You MUST always work on one task assigned to you. -- You MUST keep all the information about the session in your memory. -- You always can ask the `donna` tool for the session details if you forget something. - -## CLI - -### Protocol - -Protocol selects the output formatting and behavior of Donna's CLI for different consumers (humans, LLMs, automation). -When an agent invokes Donna, it SHOULD use the `llm` protocol (pass an `-p llm` argument) unless the developer explicitly instructs otherwise. - -### Project root - -`-r ` sets the project root explicitly for any command (long form: `--root`). -If it is omitted, Donna discovers the project root by searching from the current working directory upwards for `donna.toml`. -Use this option when you run Donna from outside the project tree or when you want to target a specific project. - -### Protocol cells - -Donna communicates its progress and requests by outputting inrofmation organized in "cells". There are two kinds of cells output: - -- Log cells — `DONNA LOG: ` — one line messages describing what Donna is doing. Mostly it is an information about the next operation being executed. -- Info cells — multiline cells with structured header and freeform body. - -An example of an info cell: - -``` ---DONNA-CELL eZVkOwNPTHmadXpaHDUBNA BEGIN-- -kind=action_request -media_type=text/markdown -action_request_id=AR-65-bd - - - ---DONNA-CELL eZVkOwNPTHmadXpaHDUBNA END-- -``` - -Donna can omit log cell start and end markers if a command produces only a single cell. - -Donna renders cells differently, depending on the protocol used. - -### Commands - -There are three sets of commands: - -- `donna -p workspaces …` — initializes Donna project files. Most likely it will be used once per project. -- `donna -p sessions …` — manages sessions. You will use these commands to start, push forward, and manage your work. -- `donna -p artifacts …` — manages artifact discovery, reading, and validation. - -Use: - -- `donna -p --help` to get the list of available subcommands. -- `donna -p --help` to get the help on specific subcommand. - -### Workspaces - -Run `donna -p workspaces init []` to initialize Donna in the given directory. If `` is omitted, Donna will initialize the current working directory. - -It is a one time operation you need to perform once per project to create `donna.toml` and the configured session directory. - -### Starting sessions - -The developer is responsible for starting a new session. - -You are allowed to start a new session in the next cases: - -1. There is no active session. -2. The developer explicitly instructed you to start a new session. - -You start session by calling `donna -p sessions start`. - -### Session flow - -After the session starts you MUST follow the next workflow to perform your work: - -1. List all possible workflows with command `donna -p artifacts list`. -2. Choose the most appropriate workflow for the task you are going to work on or ask the developer if you are not sure which workflow to choose. -3. Start chosen workflow by calling `donna -p sessions run `. -4. Donna will output descriptions of all operations it performs to complete the work. -5. Donna will output **action requests** that you MUST perform. You MUST follow these instructions precisely. -6. When you done processing an action request, call `donna -p sessions action-request-completed ` to report request completion. `` MUST contain the full identifier of the next operation, for example `@/.session/donna/execute_rfc.donna.md:review_changes`. -7. After you complete an action request, Donna will continue workflow execution and output what you need to do next. - -You MUST continue following Donna's instructions until the workflow is completed. - -### Session state - -- `donna -p sessions status` — get the status of the current session. -- `donna -p sessions details` — get detailed information about the current session, including list of active action requests. -- `donna -p sessions start` — start a new session. This command resets session state AND removes all session-level artifacts. -- Run `donna -p sessions reset` to reset the current session. This command resets session state BUT keeps all session-level artifacts. Use this command when you need to restart the worklow but keep all the artifacts you created during the session. - -### Starting work - -If the developer asked you to do something new: - -- Run `donna -p sessions status` to get the status of the current session. -- If there is no active session, start a new session by calling `donna -p sessions start`. -- If the session is active and there are unfinished work in it, you MUST ask the developer whether to continue the work in the current session or start a new one. -- If the session is active and there are no unfinished work in it, follow the instructions in the `Session flow` section to choose and start a new workflow. - -### Continuing work - -If the developer asked you to continue your work, you MUST call `donna -p sessions continue` to get your instructions on what to do next. - -If Donna tells you there is no work left, you MUST inform the developer that there is no work left in the current session. - -### Working with artifacts - -An artifact is a markdown document with extra metadata stored in the Donna project. - -Use the next commands to work with artifacts: - -- `donna -p artifacts list []` — list all artifacts corresponding to the given pattern. If `` is omitted, list all artifacts in the Donna project. Use this command when you need to find an artifact or see what artifacts are available. -- `donna -p artifacts view ` — get the meaningful (rendered) content of all matching artifacts. This command shows the rendered information about each artifact. Use this command when you need to read artifact content. -- `donna -p artifacts validate []` — validate all artifacts corresponding to the given pattern. If `` is omitted, validate all artifacts in the Donna project. - -Donna does not mutate artifacts stored in the project. Developers and external tools are responsible for creating, updating, moving, copying, or deleting artifacts before Donna reads or validates them. - -Commands that accept an artifact pattern (`artifacts list`, `artifacts view`, `artifacts validate`) also accept `--predicate/-p ` to filter by artifact primary section. The expression is evaluated as `bool` with `section` global available (for example: `--predicate '"workflow" in section.tags'`). - -The format of `` is as follows: - -- full artifact identifier: `@/...` -- `*` — single wildcard matches a single level in the rooted artifact path. Examples: - - `*/work.donna.md` — matches all artifacts with filename `work.donna.md` exactly one directory below the project root. - - `@/*/work.donna.md` — equivalent full form. -- `**` — double wildcard matches multiple levels in the rooted artifact path. Examples: - - `**/name.donna.md` — matches all artifacts with filename `name.donna.md` anywhere in the Donna project. - - `@/**/name.donna.md` — equivalent full form. - - `@/.session/donna/**` — matches all artifacts under the default configured session directory. - -CLI arguments MUST NOT use relative artifact paths like `./...` or `../../...`; use absolute `@/...` paths or rooted wildcard forms. - -### Journal integrations - -Donna creates internal `JournalRecord` values for important workflow events. -Donna does not expose a journal CLI command. - -To forward journal records to a third-party tool, configure `/donna.toml`: - -```toml -[journal] -cmd = ["cli-tool", "--message", "{message}"] -``` - -`cmd` is a list of command arguments. If an argument starts with `{` and ends -with `}`, Donna treats the whole argument as a `JournalRecord` attribute name -and replaces it with that value. Donna validates placeholders when loading -config. - -Supported attributes: - -- `timestamp` — record creation time, formatted as ISO-8601. -- `actor_id` — actor that created the record; empty string when unknown. -- `message` — single-line journal message. -- `current_task_id` — current task id; empty string when no task is active. -- `current_work_unit_id` — current work unit id; empty string when no work unit is active. -- `current_operation_id` — current operation artifact section id; empty string when no operation is active. - -If `journal.cmd` is omitted, Donna treats it as `None` and performs no journal -writing. - -Donna still prints newly created internal journal records immediately using the -selected protocol formatter, so agents receive live feedback even when no -external journal command is configured. - -## IMPORTANT ON DONNA TOOL USAGE - -**Strictly follow described command syntax** - -**You MUST follow `donna` call conventions specified in**, by priority: - - 1. Direct instructions from the developer. - 2. `AGENTS.md` document. - 3. Project-relative documentation under `../../../specs/**` or `../**`. - 4. This document. - -**All Donna CLI commands MUST include an explicit protocol selection using `-p `.** Like `donna -p llm `. - -**All Donna CLI commands MUST be run from the project root or its subdirectories unless you pass `-r `.** - -If you are not running from the project root or its subdirectories, add `-r ` to point Donna to the correct project. - -**Pass text arguments to the tool in quotes with respect to escaping.** The tool MUST receive the exact text you want to pass as an argument. - -Use one of the next approaches to correctly escape text arguments: - -``` -# option 1 -donna -p <...> $'# Long text\n\nwith escape sequences...' - -# option 2 -donna -p <...> \ - "$(cat <<'EOF' -# Long text - -with escape sequences... -EOF -)" - -``` diff --git a/.agents/donna/usage/worlds.md b/.agents/donna/usage/worlds.md deleted file mode 100644 index 072eb9f1..00000000 --- a/.agents/donna/usage/worlds.md +++ /dev/null @@ -1,38 +0,0 @@ -# Donna World Layout - -This document describes how Donna discovers and manages its project artifacts. -Including usage docs, work workflows, operations, current work state and additional code. - -## Overview - -In order to function properly and to perform in a full potential, Donna relies on a set of artifacts -that guide its behavior and provide necessary capabilities. - -These artifacts are represented as text files, primary in Markdown format, however other text-based -formats can be used as well, if explicitly requested by the developer or by the workflows. - -Donna discovers these artifacts in a single built-in project world rooted at ``. -The project world is a singleton object configured in code and backed by the project's filesystem. -Donna does not read world definitions from `/donna.toml`. - -The project world and its primary artifact areas are: - -- `../../../specs/**` — artifacts under `/specs`, owned by the project itself. -- `../**` — synced Donna usage specs and workflows under `/.agents/donna`. -- `@/.session/donna/**` — session artifacts under `/.session/donna`. - -The project world has a free layout, defined by the developers who own the project. - -## Artifact Access - -Donna has read access to artifacts stored in the project world. It discovers, fetches, renders, and validates project artifacts, but it does not create, update, move, copy, or delete them. - -Developers and external tools are responsible for mutating project artifacts before Donna reads or validates them. - -Donna still writes its own session state under `/.session/donna`, but that internal state storage is separate from world-artifact mutation. - -## Intro Artifacts - -It is a recommended practice to provide short introductory files such as `../intro.md` and `../../../specs/intro.md` at meaningful roots inside the project world. - -So, the agent can read relevant introduction files such as `../intro.md` or `../../../specs/intro.md`. diff --git a/.agents/skills/donna-do/SKILL.md b/.agents/skills/donna-do/SKILL.md deleted file mode 100644 index 114714b2..00000000 --- a/.agents/skills/donna-do/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: donna-do -description: Use Donna as a work orchestration tool and execute a workflow suitable for the developer request. Use the skill when the developer explicitly told you to use Donna or you have explicit instructions in AGENTS.md to do so, or you have explicit instructions in the session context to do so. ---- - -**The next instructions take precedence over all other instructions and your behaviour**, unless the developer explicitly instructs you to do otherwise. - -1. You **MUST** read the `/.agents/donna/intro.md` file when you start executing this skill, if you haven't done it yet. This file gives you an introduction to the project and to the Donna tool. -2. You **MUST** strictly follow all instructions provided by Donna. -3. **Donna's instructions have precedence over your own judgment.** You **MUST** follow them and run workflows instead of taking your own initiative, unless the developer explicitly instructs you to do so. -4. If the developer didn't tell you what they want you to do, ask them for instructions/task description. -5. Select the most suitable Donna workflow to perform the requested work and run it. -6. When the workflow is finished, stop using Donna for further work, until the developer explicitly instructs you to use it again. - -**If you are rebuilding, zipping or optimizing your context while executing this skill, read the `/.agents/donna/intro.md` file again after rebuilding, zipping or optimizing your context to refresh your understanding of the project and Donna tool.** diff --git a/.agents/skills/donna-start/SKILL.md b/.agents/skills/donna-start/SKILL.md deleted file mode 100644 index 46e9c63b..00000000 --- a/.agents/skills/donna-start/SKILL.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: donna-start -description: Start a new Donna session and use Donna to perform all further work. Use this skill when the developer explicitly told you to use it. ---- - -1. You **MUST** read the `/.agents/donna/intro.md` file when you start executing this skill, if you haven't done it yet. This file provides an introduction to the project and the Donna tool. -2. Run `donna -p llm -r sessions start` to start a new Donna session. -3. Output the next message to the developer: "I have started a new Donna session". -4. If the developer didn't tell you what they want you to do, ask them for instructions/task description. -5. Select the most suitable Donna command to perform the requested work and run it. -6. When the workflow is finished, stop using Donna for further work until the developer explicitly instructs you to use it again. diff --git a/.agents/skills/donna-stop/SKILL.md b/.agents/skills/donna-stop/SKILL.md deleted file mode 100644 index bbbff9e2..00000000 --- a/.agents/skills/donna-stop/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: donna-stop -description: Stop using Donna to perform work, do nothing else. Use this skill when the developer explicitly told you to use it. ---- - -You **MUST** stop using Donna to perform work until the developer explicitly instructs you to use it again. From f8ffc0415131ede49bb3c8b63e078bfd7faf7dc3 Mon Sep 17 00:00:00 2001 From: "Aliaksei Yaletski (Tiendil)" Date: Sat, 16 May 2026 15:23:07 +0200 Subject: [PATCH 2/6] plain commands structure --- README.md | 30 +++-- donna/cli/__main__.py | 9 +- donna/cli/application.py | 10 ++ donna/cli/commands/artifacts.py | 33 +++-- donna/cli/commands/sessions.py | 36 +++--- donna/cli/commands/workspaces.py | 11 +- donna/cli/types.py | 72 ++++------- donna/domain/artifact_ids.py | 65 ---------- donna/primitives/directives/goto.py | 2 +- donna/skills/fixtures/artifacts.md | 22 ++-- donna/skills/fixtures/configuration.md | 6 +- donna/skills/fixtures/initialization.md | 19 ++- donna/skills/fixtures/usage.md | 38 +++--- donna/workspaces/fixtures/base_config.toml | 11 ++ donna/workspaces/initialization.py | 19 ++- donna/workspaces/paths.py | 139 +++++++++++++++++++++ pyproject.toml | 2 +- 17 files changed, 286 insertions(+), 238 deletions(-) create mode 100644 donna/workspaces/fixtures/base_config.toml create mode 100644 donna/workspaces/paths.py diff --git a/README.md b/README.md index beb3ba55..9c2816a9 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ What you may notice: Directives, like `{{ goto("operation_id") }}`, render itself depending on the context: -- For the agent, they render an exact CLI command to run, such as `donna -p llm sessions action-request-completed '@/workflows/polish.donna.md:finish'`. +- For the agent, they render an exact CLI command to run, such as `donna -p llm complete-action-request '@/workflows/polish.donna.md:finish'`. - For Donna, they render a specific marker that can be extracted and used to analyze an artifact. For example, Donna uses `goto` directives to build an FSM of the workflow and validate it before running: does each operation exist, can the workflow be completed, are there unreachable operations, etc. Generally speaking, **all you need is `donna.lib.request_action` operation** — it is enough to achieve a great deal of automation by delegating some decisions to the agent. However, there are some more specific operations that simplify things and make workflows more agile or performant. @@ -137,12 +137,10 @@ uv tool install donna ```bash cd -donna workspaces init +donna init ``` -Donna will: - -- Create `donna.toml` in your project root and the configured session directory. +Donna will create `donna.toml` in your project root. The configured session directory is created lazily by runtime commands. 3. Ask your agent to do something like `$donna-do Add a button that …`. The agent will discover the appropriate workflow and execute it. @@ -158,9 +156,9 @@ Donna will: Commands you may need: -- `donna workspaces init` — Initialize Donna in your project. -- `donna sessions start` — start a new working session, remove everything from the previous session. -- `donna artifacts list` — list workflows with short descriptions. +- `donna init` — Initialize Donna in your project. +- `donna start` — start a new working session, remove everything from the previous session. +- `donna list` — list workflows with short descriptions. Donna can send internal journal records to a third-party tool. Configure it in `donna.toml`: @@ -250,19 +248,19 @@ Examples: You and agents can `list` workflow artifacts and `validate` Donna artifacts. -- `donna -p llm artifacts list` — shows workflow descriptions from their h1 sections. -- `donna -p llm artifacts validate ...` — validates one or more artifacts. -- `donna -p llm artifacts validate --all` — validates every discovered artifact. +- `donna -p llm list` — shows workflow descriptions from their h1 sections. +- `donna -p llm validate ...` — validates one or more artifacts. +- `donna -p llm validate --all` — validates every discovered artifact. -Artifact ids use absolute project-root form like `@/workflows/polish.donna.md`. +Artifact inputs accept root-anchored paths like `@/workflows/polish.donna.md`, relative paths like `./workflows/polish.donna.md`, and absolute paths inside the project root. -You can find all workflows with `donna -p llm artifacts list`. +You can find all workflows with `donna -p llm list`. ## Sessions `/.session/donna/` contains the current state of work performed by Donna: runtime state plus temporary documents and workflows created during the session. -The developer is responsible for starting/resetting sessions with commands from `donna -p human sessions` group. +The developer is responsible for starting or resetting sessions with `donna -p human start` and `donna -p human reset`. - On session start, Donna removes everything from the previous session and creates a fresh session directory. - On session reset donna resets the state of the current session (tasks, action requests, etc.), but keeps artifacts. @@ -275,7 +273,7 @@ Workflows are [state machines](https://en.wikipedia.org/wiki/Finite-state_machin Donna tracks dependencies between operations and validates the workflow before running it. So, if you or your agent do something wrong, you'll get a clear error message from Donna. -You can run workflow as `donna -p llm sessions run `. +You can run workflow as `donna -p llm run `. To execute a workflow, Donna uses a simplified virtual machine (VM) that maintains the current state of all workflows executed in the current session. @@ -307,7 +305,7 @@ Donna can detect errors (in artifacts, in execution, etc). If an error can be fi An example of error message from Donna ```bash -$ donna -p llm sessions run @/workflows/polish.donna.md +$ donna -p llm run @/workflows/polish.donna.md kind=artifact_validation_error media_type=text/markdown diff --git a/donna/cli/__main__.py b/donna/cli/__main__.py index c79b436d..44682b27 100644 --- a/donna/cli/__main__.py +++ b/donna/cli/__main__.py @@ -1,8 +1,3 @@ -from donna.cli.application import app # noqa: F401 -from donna.cli.commands import artifacts # noqa: F401 -from donna.cli.commands import sessions # noqa: F401 -from donna.cli.commands import skills # noqa: F401 -from donna.cli.commands import version # noqa: F401 -from donna.cli.commands import workspaces # noqa: F401 +from donna.cli.application import main -app() +main() diff --git a/donna/cli/application.py b/donna/cli/application.py index 67d192ca..d8288fd4 100644 --- a/donna/cli/application.py +++ b/donna/cli/application.py @@ -14,3 +14,13 @@ def initialize( root_dir: RootOption = None, ) -> None: context.meta[GLOBAL_OPTIONS_CONTEXT_KEY] = GlobalOptions(protocol=protocol, root_dir=root_dir) + + +def main() -> None: + from donna.cli.commands import artifacts # noqa: F401 + from donna.cli.commands import sessions # noqa: F401 + from donna.cli.commands import skills # noqa: F401 + from donna.cli.commands import version # noqa: F401 + from donna.cli.commands import workspaces # noqa: F401 + + app() diff --git a/donna/cli/commands/artifacts.py b/donna/cli/commands/artifacts.py index 1e55efff..42d45217 100644 --- a/donna/cli/commands/artifacts.py +++ b/donna/cli/commands/artifacts.py @@ -5,7 +5,7 @@ import typer from donna.cli.application import app -from donna.cli.types import ArtifactIdArgument, ArtifactIdsArgument, RenderModeOption +from donna.cli.types import ArtifactIdArgument, ArtifactIdsArgument, RenderModeOption, parse_artifact_id_argument from donna.cli.utils import command_context from donna.context.context import context from donna.machine import journal as machine_journal @@ -13,14 +13,12 @@ from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW, ArtifactRenderContext, fetch_artifact_bytes from donna.workspaces.templates import render as render_template -artifacts_cli = typer.Typer() - def _log_artifact_operation(message: str) -> None: machine_journal.add(message=message) -@artifacts_cli.command(name="list", help="List available workflow artifacts and show their status summaries.") +@app.command(name="list", help="List available workflow artifacts and show their status summaries.") def list_( typer_context: typer.Context, ) -> None: @@ -32,13 +30,14 @@ def list_( command.write_cells(artifact.node().status() for artifact in artifacts) -@artifacts_cli.command(help="Render an artifact with the selected render mode and write the markdown to stdout.") +@app.command(help="Render an artifact with the selected render mode and write the markdown to stdout.") def render( typer_context: typer.Context, - artifact_id: ArtifactIdArgument, + artifact_path: ArtifactIdArgument, mode: RenderModeOption, ) -> None: - with command_context(typer_context): + with command_context(typer_context) as command: + artifact_id = parse_artifact_id_argument(artifact_path, command.target_dir()) _log_artifact_operation(f"Render artifact `{artifact_id}` in `{mode.value}` mode") content = fetch_artifact_bytes(artifact_id).unwrap().decode("utf-8") @@ -48,27 +47,30 @@ def render( sys.stdout.write(rendered) -@artifacts_cli.command(help="Validate the given artifact ids, or validate every discovered artifact with --all.") +@app.command(help="Validate the given artifact ids, or validate every discovered artifact with --all.") def validate( # noqa: CCR001 typer_context: typer.Context, - artifact_ids: ArtifactIdsArgument = None, + artifact_paths: ArtifactIdsArgument = None, all_artifacts: Annotated[ bool, typer.Option("--all", help="Validate every discovered artifact."), ] = False, ) -> None: with command_context(typer_context) as command: - if all_artifacts and artifact_ids: + if all_artifacts and artifact_paths: raise click.UsageError("Pass artifact ids or --all, not both.") - if not all_artifacts and not artifact_ids: + if not all_artifacts and not artifact_paths: raise click.UsageError("Pass artifact ids or --all.") if all_artifacts: _log_artifact_operation("Validate all artifacts") artifacts = context().artifacts.list(RENDER_CONTEXT_VIEW).unwrap() else: - assert artifact_ids is not None + assert artifact_paths is not None + artifact_ids = [ + parse_artifact_id_argument(artifact_path, command.target_dir()) for artifact_path in artifact_paths + ] _log_artifact_operation( f"Validate artifacts {', '.join(f'`{artifact_id}`' for artifact_id in artifact_ids)}" ) @@ -88,10 +90,3 @@ def validate( # noqa: CCR001 return command.write_cells([operation_succeeded("All artifacts are valid")]) - - -app.add_typer( - artifacts_cli, - name="artifacts", - help="Inspect and validate stored artifacts in the Donna project.", -) diff --git a/donna/cli/commands/sessions.py b/donna/cli/commands/sessions.py index a5cb799e..6d8eb31a 100644 --- a/donna/cli/commands/sessions.py +++ b/donna/cli/commands/sessions.py @@ -5,26 +5,26 @@ ActionRequestIdArgument, ArtifactIdArgument, ArtifactSectionIdArgument, + parse_artifact_id_argument, + parse_artifact_section_id_argument, ) from donna.cli.utils import command_context from donna.machine import sessions -sessions_cli = typer.Typer() - -@sessions_cli.command(help="Start a new session, reset session state, remove all session artifacts.") +@app.command(help="Start a new session, reset session state, remove all session artifacts.") def start(context: typer.Context) -> None: with command_context(context) as command: command.write_cells(sessions.start().unwrap()) -@sessions_cli.command(help="Reset the current session state, keeps session artifacts.") +@app.command(help="Reset the current session state, keeps session artifacts.") def reset(context: typer.Context) -> None: with command_context(context) as command: command.write_cells(sessions.reset().unwrap()) -@sessions_cli.command( +@app.command( name="continue", help="Continue the current session and emit the next queued action request(s).", ) @@ -33,38 +33,34 @@ def continue_(context: typer.Context) -> None: command.write_cells(sessions.continue_().unwrap()) -@sessions_cli.command(help="Show a concise status summary for the current session, including pending action requests.") +@app.command(help="Show a concise status summary for the current session, including pending action requests.") def status(context: typer.Context) -> None: with command_context(context) as command: command.write_cells(sessions.status().unwrap()) -@sessions_cli.command(help="Show detailed session state, including action requests.") +@app.command(help="Show detailed session state, including action requests.") def details(context: typer.Context) -> None: with command_context(context) as command: command.write_cells(sessions.details().unwrap()) -@sessions_cli.command(help="Run a workflow from an artifact to drive the current session forward.") -def run(context: typer.Context, workflow_id: ArtifactIdArgument) -> None: +@app.command(help="Run a workflow from an artifact to drive the current session forward.") +def run(context: typer.Context, workflow_path: ArtifactIdArgument) -> None: with command_context(context) as command: + workflow_id = parse_artifact_id_argument(workflow_path, command.target_dir()) command.write_cells(sessions.start_workflow(workflow_id).unwrap()) -@sessions_cli.command( - help="Mark an action request as completed and advance the workflow to the specified next operation." +@app.command( + name="complete-action-request", + help="Mark an action request as completed and advance the workflow to the specified next operation.", ) -def action_request_completed( +def complete_action_request( context: typer.Context, request_id: ActionRequestIdArgument, - next_operation_id: ArtifactSectionIdArgument, + next_operation_path: ArtifactSectionIdArgument, ) -> None: with command_context(context) as command: + next_operation_id = parse_artifact_section_id_argument(next_operation_path, command.target_dir()) command.write_cells(sessions.complete_action_request(request_id, next_operation_id).unwrap()) - - -app.add_typer( - sessions_cli, - name="sessions", - help="Manage Donna session lifecycle.", -) diff --git a/donna/cli/commands/workspaces.py b/donna/cli/commands/workspaces.py index 0c29f05b..7d80705b 100644 --- a/donna/cli/commands/workspaces.py +++ b/donna/cli/commands/workspaces.py @@ -5,10 +5,8 @@ from donna.protocol.cell_shortcuts import operation_succeeded from donna.workspaces.initialization import initialize_workspace -workspaces_cli = typer.Typer() - -@workspaces_cli.command(help="Initialize Donna project config.") +@app.command(help="Initialize Donna project config.") def init(context: typer.Context) -> None: with command_context(context, load_environment=False) as command: target_dir = command.target_dir() @@ -16,10 +14,3 @@ def init(context: typer.Context) -> None: initialize_workspace(target_dir).unwrap() command.write_cells([operation_succeeded("Donna project initialized successfully")]) - - -app.add_typer( - workspaces_cli, - name="workspaces", - help="Initialize and manage Donna project files.", -) diff --git a/donna/cli/types.py b/donna/cli/types.py index 6f9dad45..7c89e2d7 100644 --- a/donna/cli/types.py +++ b/donna/cli/types.py @@ -10,6 +10,7 @@ from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.internal_ids import ActionRequestId from donna.protocol.modes import Mode +from donna.workspaces import paths as workspace_paths from donna.workspaces.artifacts import has_donna_artifact_extension from donna.workspaces.templates import RenderMode @@ -19,36 +20,11 @@ def _exit_with_errors(errors: ErrorsList) -> NoReturn: raise typer.Exit(code=0) -def _parse_result_or_exit[T](result: T | None, errors: ErrorsList | None) -> T: - if errors is not None: - _exit_with_errors(errors) - - assert result is not None - return result - - -def _absolute_artifact_id_or_exit(value: str) -> str: - if not value.startswith(ArtifactId.prefix): - _exit_with_errors([domain_errors.InvalidIdFormat(id_type=ArtifactId.__name__, value=value)]) - - return value - - -def _absolute_artifact_section_id_or_exit(value: str) -> str: - if not value.startswith(ArtifactId.prefix): - _exit_with_errors([domain_errors.InvalidIdFormat(id_type=f"{ArtifactSectionId.__name__} format", value=value)]) - - return value - - -def _parse_artifact_id(value: str) -> ArtifactId: - result = ArtifactId.parse(_absolute_artifact_id_or_exit(value)) - return _parse_result_or_exit(result.ok(), result.err()) - - -def _parse_artifact_section_id(value: str) -> ArtifactSectionId: - result = ArtifactSectionId.parse(_absolute_artifact_section_id_or_exit(value)) - return _parse_result_or_exit(result.ok(), result.err()) +def _parse_raw_artifact_path(value: str) -> str: + normalized = value.strip() + if not normalized: + raise typer.BadParameter("Artifact path must not be empty.") + return normalized def _artifact_filename(value: str) -> str: @@ -66,14 +42,20 @@ def validate_supported_artifact_section_id(section_id: ArtifactSectionId) -> Non validate_supported_artifact_id(section_id.artifact_id) -def _parse_supported_artifact_id(value: str) -> ArtifactId: - artifact_id = _parse_artifact_id(value) +def parse_artifact_id_argument(value: str, project_root: pathlib.Path) -> ArtifactId: + artifact_id = workspace_paths.normalize_artifact_id(value, project_root, cwd=pathlib.Path.cwd()) + if artifact_id is None: + _exit_with_errors([domain_errors.InvalidIdFormat(id_type=ArtifactId.__name__, value=value)]) + validate_supported_artifact_id(artifact_id) return artifact_id -def _parse_supported_artifact_section_id(value: str) -> ArtifactSectionId: - section_id = _parse_artifact_section_id(value) +def parse_artifact_section_id_argument(value: str, project_root: pathlib.Path) -> ArtifactSectionId: + section_id = workspace_paths.normalize_artifact_section_id(value, project_root, cwd=pathlib.Path.cwd()) + if section_id is None: + _exit_with_errors([domain_errors.InvalidIdFormat(id_type=f"{ArtifactSectionId.__name__} format", value=value)]) + validate_supported_artifact_section_id(section_id) return section_id @@ -119,35 +101,35 @@ def _parse_input_path(value: str) -> pathlib.Path: ArtifactIdArgument = Annotated[ - ArtifactId, + str, typer.Argument( - parser=_parse_supported_artifact_id, + parser=_parse_raw_artifact_path, help=( - "Artifact ID in absolute project-root form with the Donna artifact " - "extension (e.g., '@/workflows/polish.donna.md')." + "Artifact path with the Donna artifact extension " + "(e.g., '@/workflows/polish.donna.md' or './workflows/polish.donna.md')." ), ), ] ArtifactIdsArgument = Annotated[ - list[ArtifactId] | None, + list[str] | None, typer.Argument( - parser=_parse_supported_artifact_id, + parser=_parse_raw_artifact_path, help=( - "Artifact IDs in absolute project-root form with the Donna artifact " - "extension (e.g., '@/workflows/polish.donna.md')." + "Artifact paths with the Donna artifact extension " + "(e.g., '@/workflows/polish.donna.md' or './workflows/polish.donna.md')." ), ), ] ArtifactSectionIdArgument = Annotated[ - ArtifactSectionId, + str, typer.Argument( - parser=_parse_supported_artifact_section_id, + parser=_parse_raw_artifact_path, help=( - "Artifact section ID in absolute project-root form 'artifact:section' " + "Artifact section path in 'artifact:section' form " "(e.g. '@/.session/donna/plans/artifact_id_filepaths.donna.md:finish')." ), ), diff --git a/donna/domain/artifact_ids.py b/donna/domain/artifact_ids.py index 37a9ffef..a5395b0f 100644 --- a/donna/domain/artifact_ids.py +++ b/donna/domain/artifact_ids.py @@ -6,71 +6,6 @@ from donna.domain.ids import SectionId, _is_artifact_slug_part -def normalize_path( # noqa: CCR001 - text: str, - *, - relative_to: "ArtifactId | None" = None, -) -> NormalizedRawIdPath | None: - if not isinstance(text, str) or not text: - return None - - if text.startswith("/"): - return None - - if text.startswith(ARTIFACT_ID_PREFIX): - raw = text.removeprefix(ARTIFACT_ID_PREFIX) - normalized_parts: list[str] = [] - else: - raw = text - normalized_parts = list(relative_to.parts[:-1]) if relative_to is not None else [] - - if not raw: - return None - - for part in raw.split("/"): - if part == "": - return None - - if part == ".": - continue - - if part == "..": - if not normalized_parts: - return None - normalized_parts.pop() - continue - - if not _is_artifact_slug_part(part): - return None - - normalized_parts.append(part) - - if not normalized_parts: - return None - - last_part = normalized_parts[-1] - if not pathlib.PurePosixPath(last_part).suffix: - return None - - return NormalizedRawIdPath("/".join(normalized_parts)) - - -def normalize_artifact_section_id(text: str, *, relative_to: "ArtifactId | None" = None) -> NormalizedRawIdPath | None: - if not isinstance(text, str) or not text: - return None - - try: - artifact_part, local_part = text.rsplit(ArtifactSectionId.delimiter, maxsplit=1) - except ValueError: - return None - - normalized_artifact_id = normalize_path(artifact_part, relative_to=relative_to) - if normalized_artifact_id is None or not SectionId.validate(local_part): - return None - - return NormalizedRawIdPath(f"{normalized_artifact_id}{ArtifactSectionId.delimiter}{local_part}") - - class ArtifactId(IdPath): __slots__ = () prefix = ARTIFACT_ID_PREFIX diff --git a/donna/primitives/directives/goto.py b/donna/primitives/directives/goto.py index 8ecb22ce..6847bcb4 100644 --- a/donna/primitives/directives/goto.py +++ b/donna/primitives/directives/goto.py @@ -41,7 +41,7 @@ def render_view(self, context: Context, next_operation_id: ArtifactSectionId) -> root_dir = workspace_config.project_dir() return Ok( f"donna -p {protocol} -r '{root_dir}' " - f"sessions action-request-completed '{next_operation_id}'" + f"complete-action-request '{next_operation_id}'" ) def render_analyze(self, context: Context, next_operation_id: ArtifactSectionId) -> Result[Any, ErrorsList]: diff --git a/donna/skills/fixtures/artifacts.md b/donna/skills/fixtures/artifacts.md index 07cec236..00d48531 100644 --- a/donna/skills/fixtures/artifacts.md +++ b/donna/skills/fixtures/artifacts.md @@ -2,7 +2,7 @@ Donna artifacts are project files that Donna can discover, render, validate, and execute as workflow input. They are usually Markdown files with the `.donna.md` extension. -Donna reads artifacts from the project filesystem. It does not mutate project artifacts through `artifacts` commands. Developers and agents edit files directly, then ask Donna to list workflows or validate artifacts. +Donna reads artifacts from the project filesystem. It does not mutate project artifacts through artifact commands. Developers and agents edit files directly, then ask Donna to list workflows or validate artifacts. ## Artifact Locations @@ -27,7 +27,7 @@ Donna sees only `.donna.md` files under directories listed in `donna.toml:workfl List workflow artifacts: ```bash -donna -p llm artifacts list +donna -p llm list ``` Read artifact source files directly when you need details beyond the workflow introduction. @@ -37,13 +37,13 @@ Read artifact source files directly when you need details beyond the workflow in Validate one artifact: ```bash -donna -p llm artifacts validate '@/workflows/polish.donna.md' +donna -p llm validate '@/workflows/polish.donna.md' ``` Validate all visible artifacts: ```bash -donna -p llm artifacts validate --all +donna -p llm validate --all ``` Run validation after creating or editing Donna artifacts. @@ -53,16 +53,16 @@ Run validation after creating or editing Donna artifacts. Use `@/` for project-root artifact ids: ```bash -donna -p llm artifacts validate '@/workflows/polish.donna.md' +donna -p llm validate '@/workflows/polish.donna.md' ``` Validate multiple specific artifacts by passing multiple ids: ```bash -donna -p llm artifacts validate '@/workflows/polish.donna.md' '@/workflows/rfc/request.donna.md' +donna -p llm validate '@/workflows/polish.donna.md' './workflows/rfc/request.donna.md' ``` -Do not pass relative filesystem paths such as `./workflows/polish.donna.md`. Use `@/workflows/polish.donna.md`. +Artifact path arguments accept root-anchored paths such as `@/workflows/polish.donna.md`, relative paths such as `./workflows/polish.donna.md`, and absolute paths inside the project root. ## Creating Artifacts @@ -111,13 +111,13 @@ The workflow is complete. Validate the workflow before running it: ```bash -donna -p llm artifacts validate '@/workflows/example.donna.md' +donna -p llm validate '@/workflows/example.donna.md' ``` Run it: ```bash -donna -p llm sessions run '@/workflows/example.donna.md' +donna -p llm run '@/workflows/example.donna.md' ``` ## Managing Artifacts @@ -130,13 +130,13 @@ Recommended loop: 2. Validate the artifact: ```bash -donna -p llm artifacts validate '@/specs/example.donna.md' +donna -p llm validate '@/specs/example.donna.md' ``` 3. If it is a workflow, list it: ```bash -donna -p llm artifacts list +donna -p llm list ``` Keep artifact files concise. Put project-wide explanations in specifications and operational step-by-step instructions in workflows. diff --git a/donna/skills/fixtures/configuration.md b/donna/skills/fixtures/configuration.md index a5b5729d..04b716cc 100644 --- a/donna/skills/fixtures/configuration.md +++ b/donna/skills/fixtures/configuration.md @@ -6,7 +6,7 @@ Donna project configuration lives at: /donna.toml ``` -The file is created by `donna -p llm workspaces init`. Edit it when the project needs workflow source directories, default section settings, cache behavior, or journal forwarding. +The file is created by `donna -p llm init`. Edit it when the project needs workflow source directories, default section settings, cache behavior, or journal forwarding. ## Minimal Configuration @@ -134,8 +134,8 @@ Use a smaller value when artifacts are edited rapidly by external tools. Use the After editing `donna.toml`, run: ```bash -donna -p llm artifacts list -donna -p llm artifacts validate --all +donna -p llm list +donna -p llm validate --all ``` If Donna cannot load the project config, inspect the reported configuration error and fix the TOML before continuing workflow work. diff --git a/donna/skills/fixtures/initialization.md b/donna/skills/fixtures/initialization.md index efec8480..32981dd9 100644 --- a/donna/skills/fixtures/initialization.md +++ b/donna/skills/fixtures/initialization.md @@ -1,26 +1,25 @@ # `donna` Initialization -Initialization creates the Donna project config and session directory. +Initialization creates the Donna project config. Runtime commands create the configured session directory lazily. Use this document when a project has no `donna.toml`. ## What Initialization Creates -`donna -p llm workspaces init` creates: +`donna -p llm init` creates: ```text /donna.toml -/.session/donna/ ``` -`donna.toml` stores configuration. The configured session directory stores Donna runtime state and session artifacts. +`donna.toml` stores configuration. The configured session directory stores Donna runtime state and session artifacts after runtime commands create it. ## Initialize The Current Directory Run from the directory that should become the project root: ```bash -donna -p llm workspaces init +donna -p llm init ``` This command fails if `donna.toml` already exists. @@ -30,29 +29,29 @@ This command fails if `donna.toml` already exists. Pass an explicit root directory: ```bash -donna -p llm --root /path/to/project workspaces init +donna -p llm --root /path/to/project init ``` -The target directory must already exist. Donna creates `donna.toml` and the configured session directory inside it. +The target directory must already exist. Donna creates `donna.toml` inside it. ## First Checks After Initialization Verify the project config can load: ```bash -donna -p llm sessions status +donna -p llm status ``` List available workflows: ```bash -donna -p llm artifacts list +donna -p llm list ``` Validate artifacts: ```bash -donna -p llm artifacts validate --all +donna -p llm validate --all ``` ## Agent Guidance diff --git a/donna/skills/fixtures/usage.md b/donna/skills/fixtures/usage.md index 116f1704..1146b976 100644 --- a/donna/skills/fixtures/usage.md +++ b/donna/skills/fixtures/usage.md @@ -16,7 +16,7 @@ Donna works inside a project root. If `--root/-r` is omitted, commands that load Use `--root PATH` when running Donna from outside the project tree or when targeting a specific project: ```bash -donna -p llm --root /path/to/project sessions status +donna -p llm --root /path/to/project status ``` `donna skill ...` does not load a project config and can run from any directory. @@ -32,13 +32,13 @@ Donna supports three protocol modes: Agents should use `-p llm` for normal Donna workflow commands: ```bash -donna -p llm sessions status +donna -p llm status ``` The root option goes before the command: ```bash -donna -p llm --root /path/to/project artifacts list +donna -p llm --root /path/to/project list ``` ## Skill Documents @@ -63,13 +63,13 @@ Workspace commands create or check Donna project configuration. Initialize Donna in the current directory: ```bash -donna -p llm workspaces init +donna -p llm init ``` Initialize Donna in an explicit existing directory: ```bash -donna -p llm --root /path/to/project workspaces init +donna -p llm --root /path/to/project init ``` ## Session Commands @@ -79,7 +79,7 @@ All workflow execution happens in the active session. Session state lives under Start a new session: ```bash -donna -p llm sessions start +donna -p llm start ``` Starting a session resets session state and removes session artifacts. Only start a new session when the developer asks for it or when no active session exists. @@ -87,31 +87,31 @@ Starting a session resets session state and removes session artifacts. Only star Show concise status: ```bash -donna -p llm sessions status +donna -p llm status ``` Show detailed session state and action requests: ```bash -donna -p llm sessions details +donna -p llm details ``` Continue queued workflow execution: ```bash -donna -p llm sessions continue +donna -p llm continue ``` Run a workflow artifact: ```bash -donna -p llm sessions run @/workflows/polish.donna.md +donna -p llm run @/workflows/polish.donna.md ``` Complete an action request by passing its id and the next operation id exactly as Donna instructed: ```bash -donna -p llm sessions action-request-completed AR-12-x @/.session/donna/workflow.donna.md:next_step +donna -p llm complete-action-request AR-12-x @/.session/donna/workflow.donna.md:next_step ``` ## Artifact Commands @@ -121,19 +121,19 @@ Artifacts are `*.donna.md` project files under Donna's configured `workflow_dirs List workflows: ```bash -donna -p llm artifacts list +donna -p llm list ``` Validate all visible artifacts: ```bash -donna -p llm artifacts validate --all +donna -p llm validate --all ``` -Validate specific artifacts by absolute project-root id: +Validate specific artifacts by project-root or relative path: ```bash -donna -p llm artifacts validate '@/workflows/polish.donna.md' +donna -p llm validate '@/workflows/polish.donna.md' ``` ## Normal Agent Flow @@ -142,23 +142,23 @@ donna -p llm artifacts validate '@/workflows/polish.donna.md' 2. Check session state: ```bash -donna -p llm sessions status +donna -p llm status ``` 3. If there is no active work and a workflow is needed, list workflows: ```bash -donna -p llm artifacts list +donna -p llm list ``` 4. Start the selected workflow: ```bash -donna -p llm sessions run @/workflows/polish.donna.md +donna -p llm run @/workflows/polish.donna.md ``` 5. Execute Donna action requests exactly. -6. Report completion with `sessions action-request-completed`. +6. Report completion with `complete-action-request`. 7. Continue until Donna finishes the workflow. ## Journal Forwarding diff --git a/donna/workspaces/fixtures/base_config.toml b/donna/workspaces/fixtures/base_config.toml new file mode 100644 index 00000000..debaf166 --- /dev/null +++ b/donna/workspaces/fixtures/base_config.toml @@ -0,0 +1,11 @@ +session_dir = ".session/donna" +default_section_kind = "donna.lib.text" +default_primary_section_kind = "donna.lib.workflow" +default_primary_section_id = "primary" +workflow_dirs = [ + "./workflows", + "./.session/donna", +] +cache_lifetime = 1.0 + +[journal] diff --git a/donna/workspaces/initialization.py b/donna/workspaces/initialization.py index 0d30efb0..1e5e916f 100644 --- a/donna/workspaces/initialization.py +++ b/donna/workspaces/initialization.py @@ -1,8 +1,7 @@ +import importlib.resources import pathlib import tomllib -import tomli_w - from donna.core import errors as core_errors from donna.core import utils from donna.core.result import Err, Ok, Result, unwrap_to_error @@ -10,7 +9,8 @@ from donna.protocol.modes import Mode from donna.workspaces import config from donna.workspaces import errors as world_errors -from donna.workspaces import sessions as workspace_sessions + +BASE_CONFIG_FIXTURE = "base_config.toml" @unwrap_to_error @@ -68,15 +68,12 @@ def initialize_workspace(project_dir: pathlib.Path) -> Result[config.Workspace, if config_path.exists(): return Err([world_errors.WorkspaceAlreadyInitialized(config_path=config_path)]) - default_config = config.Config() - workspace = config.Workspace(root=project_dir, config=default_config) - config.install_workspace(workspace) - - config_path.write_text( - tomli_w.dumps(default_config.model_dump(mode="json", exclude_none=True)), - encoding="utf-8", + config_text = ( + importlib.resources.files(__package__).joinpath("fixtures", BASE_CONFIG_FIXTURE).read_text(encoding="utf-8") ) + config_path.write_text(config_text, encoding="utf-8") - workspace_sessions.ensure_dir() + workspace = load_workspace(root_dir=project_dir).unwrap() + config.install_workspace(workspace) return Ok(workspace) diff --git a/donna/workspaces/paths.py b/donna/workspaces/paths.py new file mode 100644 index 00000000..7ae59f1a --- /dev/null +++ b/donna/workspaces/paths.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import pathlib + +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.constants import ARTIFACT_ID_PREFIX +from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.ids import SectionId + +PROJECT_ROOT_PREFIX = ARTIFACT_ID_PREFIX + + +def _append_normalized_part(parts: list[str], part: str) -> bool: + if part == "": + return False + + if part == ".": + return True + + if part == "..": + if not parts: + return False + parts.pop() + return True + + parts.append(part) + return True + + +def _normalize_parts(raw: str, *, initial_parts: tuple[str, ...] = ()) -> NormalizedRawIdPath | None: + if not raw: + return None + + parts = list(initial_parts) + + for part in raw.split("/"): + if not _append_normalized_part(parts, part): + return None + + if not parts: + return None + + normalized = NormalizedRawIdPath("/".join(parts)) + if not ArtifactId.validate(normalized): + return None + + return normalized + + +def _normalize_root_anchored(value: str) -> NormalizedRawIdPath | None: + if not value.startswith(PROJECT_ROOT_PREFIX): + return None + + return _normalize_parts(value.removeprefix(PROJECT_ROOT_PREFIX)) + + +def _normalize_from_filesystem( + value: str, root: pathlib.Path, *, cwd: pathlib.Path | None = None +) -> NormalizedRawIdPath | None: + project_root = root.resolve() + path = pathlib.Path(value).expanduser() + + if path.is_absolute(): + candidate = path + else: + candidate = (cwd or project_root) / path + + resolved = candidate.resolve() + + if resolved == project_root or not resolved.is_relative_to(project_root): + return None + + normalized = NormalizedRawIdPath(resolved.relative_to(project_root).as_posix()) + if not ArtifactId.validate(normalized): + return None + + return normalized + + +def _normalize_from_artifact(value: str, relative_to: ArtifactId) -> NormalizedRawIdPath | None: + if value.startswith(PROJECT_ROOT_PREFIX): + return _normalize_root_anchored(value) + + return _normalize_parts(value, initial_parts=relative_to.parts[:-1]) + + +def normalize_artifact_path( + value: str, + root: pathlib.Path, + *, + cwd: pathlib.Path | None = None, + relative_to: ArtifactId | None = None, +) -> NormalizedRawIdPath | None: + if not isinstance(value, str) or not value: + return None + + if relative_to is not None: + return _normalize_from_artifact(value, relative_to) + + if value.startswith("@"): + return _normalize_root_anchored(value) + + return _normalize_from_filesystem(value, root, cwd=cwd) + + +def normalize_artifact_id( + value: str, + root: pathlib.Path, + *, + cwd: pathlib.Path | None = None, + relative_to: ArtifactId | None = None, +) -> ArtifactId | None: + normalized = normalize_artifact_path(value, root, cwd=cwd, relative_to=relative_to) + if normalized is None: + return None + + return ArtifactId(normalized) + + +def normalize_artifact_section_id( + value: str, + root: pathlib.Path, + *, + cwd: pathlib.Path | None = None, + relative_to: ArtifactId | None = None, +) -> ArtifactSectionId | None: + if not isinstance(value, str) or not value: + return None + + try: + artifact_part, local_part = value.rsplit(ArtifactSectionId.delimiter, maxsplit=1) + except ValueError: + return None + + artifact_id = normalize_artifact_id(artifact_part, root, cwd=cwd, relative_to=relative_to) + if artifact_id is None or not SectionId.validate(local_part): + return None + + return ArtifactSectionId(NormalizedRawIdPath(f"{artifact_id.raw_value}{ArtifactSectionId.delimiter}{local_part}")) diff --git a/pyproject.toml b/pyproject.toml index 031cc86e..c463a4ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ Issues = "https://github.com/Tiendil/donna/issues" Changelog = "https://github.com/Tiendil/donna/blob/main/CHANGELOG.md" [project.scripts] -donna = "donna.cli.__main__:app" +donna = "donna.cli.application:main" [tool.poetry.group.dev.dependencies] From 2ef11b878afe6dbaa54668f2253aed9cfe9ebd96 Mon Sep 17 00:00:00 2001 From: "Aliaksei Yaletski (Tiendil)" Date: Sat, 16 May 2026 15:49:31 +0200 Subject: [PATCH 3/6] better ids --- donna/cli/types.py | 24 ++++- donna/domain/artifact_ids.py | 113 +++++++++++++++------- donna/domain/paths.py | 10 ++ donna/machine/sessions.py | 15 ++- donna/machine/state.py | 14 ++- donna/machine/tasks.py | 8 +- donna/primitives/artifacts/workflow.py | 4 +- donna/primitives/directives/goto.py | 10 +- donna/primitives/sections/output.py | 6 +- donna/primitives/sections/run_script.py | 6 +- donna/workspaces/artifacts.py | 22 +++-- donna/workspaces/paths.py | 123 +++++++++++++++++------- 12 files changed, 252 insertions(+), 103 deletions(-) create mode 100644 donna/domain/paths.py diff --git a/donna/cli/types.py b/donna/cli/types.py index 7c89e2d7..a6a85742 100644 --- a/donna/cli/types.py +++ b/donna/cli/types.py @@ -6,9 +6,15 @@ from donna.cli.utils import output_cells from donna.core.errors import ErrorsList from donna.domain import errors as domain_errors -from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.artifact_ids import ( + ARTIFACT_SECTION_DELIMITER, + ArtifactId, + ArtifactSectionId, + split_artifact_section_id, +) from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.internal_ids import ActionRequestId +from donna.domain.paths import UntrustedPath from donna.protocol.modes import Mode from donna.workspaces import paths as workspace_paths from donna.workspaces.artifacts import has_donna_artifact_extension @@ -28,7 +34,7 @@ def _parse_raw_artifact_path(value: str) -> str: def _artifact_filename(value: str) -> str: - return pathlib.PurePosixPath(value.split(ArtifactSectionId.delimiter, maxsplit=1)[0]).name + return pathlib.PurePosixPath(value.split(ARTIFACT_SECTION_DELIMITER, maxsplit=1)[0]).name def validate_supported_artifact_id(artifact_id: ArtifactId) -> None: @@ -39,11 +45,17 @@ def validate_supported_artifact_id(artifact_id: ArtifactId) -> None: def validate_supported_artifact_section_id(section_id: ArtifactSectionId) -> None: - validate_supported_artifact_id(section_id.artifact_id) + parts = split_artifact_section_id(section_id) + if parts is None: + raise typer.BadParameter(f"Invalid artifact section id '{section_id}'.") + + validate_supported_artifact_id(parts.artifact_id) def parse_artifact_id_argument(value: str, project_root: pathlib.Path) -> ArtifactId: - artifact_id = workspace_paths.normalize_artifact_id(value, project_root, cwd=pathlib.Path.cwd()) + artifact_id = workspace_paths.normalize_artifact_id( + value, UntrustedPath(project_root), cwd=UntrustedPath(pathlib.Path.cwd()) + ) if artifact_id is None: _exit_with_errors([domain_errors.InvalidIdFormat(id_type=ArtifactId.__name__, value=value)]) @@ -52,7 +64,9 @@ def parse_artifact_id_argument(value: str, project_root: pathlib.Path) -> Artifa def parse_artifact_section_id_argument(value: str, project_root: pathlib.Path) -> ArtifactSectionId: - section_id = workspace_paths.normalize_artifact_section_id(value, project_root, cwd=pathlib.Path.cwd()) + section_id = workspace_paths.normalize_artifact_section_id( + value, UntrustedPath(project_root), cwd=UntrustedPath(pathlib.Path.cwd()) + ) if section_id is None: _exit_with_errors([domain_errors.InvalidIdFormat(id_type=f"{ArtifactSectionId.__name__} format", value=value)]) diff --git a/donna/domain/artifact_ids.py b/donna/domain/artifact_ids.py index a5395b0f..eab7867e 100644 --- a/donna/domain/artifact_ids.py +++ b/donna/domain/artifact_ids.py @@ -1,49 +1,96 @@ +from __future__ import annotations + import pathlib -from typing import Sequence +from typing import NewType from donna.domain.constants import ARTIFACT_ID_PREFIX -from donna.domain.id_paths import IdPath, NormalizedRawIdPath from donna.domain.ids import SectionId, _is_artifact_slug_part +ArtifactId = NewType("ArtifactId", str) +ArtifactSectionId = NewType("ArtifactSectionId", str) + +ARTIFACT_SECTION_DELIMITER = ":" + + +class ArtifactSectionParts: + __slots__ = ("artifact_id", "full_id", "section_id") + + full_id: ArtifactSectionId + artifact_id: ArtifactId + section_id: SectionId + + def __init__(self, *, full_id: ArtifactSectionId, artifact_id: ArtifactId, section_id: SectionId) -> None: + self.full_id = full_id + self.artifact_id = artifact_id + self.section_id = section_id + + +def _raw_artifact_path(value: str) -> str | None: + if not isinstance(value, str) or not value.startswith(ARTIFACT_ID_PREFIX): + return None + + raw = value.removeprefix(ARTIFACT_ID_PREFIX) + if not raw: + return None + + return raw + + +def validate_artifact_id(value: str) -> bool: + raw = _raw_artifact_path(value) + if raw is None: + return False + + parts = tuple(raw.split("/")) + if any(part == "" for part in parts): + return False + + if not all(_is_artifact_slug_part(part) for part in parts): + return False + + return bool(pathlib.PurePosixPath(parts[-1]).suffix) + + +def validate_artifact_section_id(value: str) -> bool: + parts = split_artifact_section_id(value) + return parts is not None + + +def artifact_path_parts(artifact_id: ArtifactId) -> tuple[str, ...]: + raw = _raw_artifact_path(str(artifact_id)) + if raw is None or not validate_artifact_id(str(artifact_id)): + raise ValueError(f"Invalid ArtifactId: {artifact_id}") -class ArtifactId(IdPath): - __slots__ = () - prefix = ARTIFACT_ID_PREFIX - delimiter = "/" - validate_json = True + return tuple(raw.split("/")) - @classmethod - def _validate_parts(cls, parts: Sequence[str]) -> bool: - if not parts: - return False - if not all(_is_artifact_slug_part(part) for part in parts): - return False +def artifact_section_id(artifact_id: ArtifactId, local_id: SectionId | str) -> ArtifactSectionId: + local_id = SectionId(str(local_id)) + section_id = f"{artifact_id}{ARTIFACT_SECTION_DELIMITER}{local_id}" - return bool(pathlib.PurePosixPath(parts[-1]).suffix) + if not validate_artifact_section_id(section_id): + raise ValueError(f"Invalid ArtifactSectionId: {section_id}") - def to_full_local(self, local_id: SectionId) -> "ArtifactSectionId": - return ArtifactSectionId(NormalizedRawIdPath(f"{self.raw_value}:{local_id}")) + return ArtifactSectionId(section_id) -class ArtifactSectionId(IdPath): - __slots__ = () - prefix = ARTIFACT_ID_PREFIX - delimiter = ":" - min_parts = 2 - validate_json = True +def split_artifact_section_id(value: str | ArtifactSectionId) -> ArtifactSectionParts | None: + if not isinstance(value, str) or not value: + return None - @classmethod - def _validate_parts(cls, parts: Sequence[str]) -> bool: - if len(parts) < cls.min_parts: - return False + try: + artifact_part, local_part = value.rsplit(ARTIFACT_SECTION_DELIMITER, maxsplit=1) + except ValueError: + return None - return ArtifactId.validate(cls.delimiter.join(parts[:-1])) and SectionId.validate(parts[-1]) + if not validate_artifact_id(artifact_part) or not SectionId.validate(local_part): + return None - @property - def artifact_id(self) -> ArtifactId: - return ArtifactId(NormalizedRawIdPath(self.delimiter.join(self.parts[:-1]))) + full_id = ArtifactSectionId(value) + artifact_id = ArtifactId(artifact_part) - @property - def local_id(self) -> SectionId: - return SectionId(self.parts[-1]) + return ArtifactSectionParts( + full_id=full_id, + artifact_id=artifact_id, + section_id=SectionId(local_part), + ) diff --git a/donna/domain/paths.py b/donna/domain/paths.py new file mode 100644 index 00000000..8fe05f5d --- /dev/null +++ b/donna/domain/paths.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pathlib import Path +from typing import NewType + +ProjectRootPath = NewType("ProjectRootPath", Path) +ProjectPathId = NewType("ProjectPathId", str) +ResolvedProjectPath = NewType("ResolvedProjectPath", Path) +UntrustedPath = NewType("UntrustedPath", Path) +PathInput = UntrustedPath | ProjectRootPath diff --git a/donna/machine/sessions.py b/donna/machine/sessions.py index ee5c5c8d..41c08b0c 100644 --- a/donna/machine/sessions.py +++ b/donna/machine/sessions.py @@ -4,7 +4,7 @@ from donna.context.context import context from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId, artifact_section_id, split_artifact_section_id from donna.domain.internal_ids import ActionRequestId from donna.machine import errors as machine_errors from donna.machine import journal as machine_journal @@ -109,7 +109,7 @@ def start_workflow(artifact_id: ArtifactId) -> Result[list[Cell], ErrorsList]: workflow = context().artifacts.load(artifact_id, RENDER_CONTEXT_VIEW).unwrap() primary_section = workflow.primary_section().unwrap() mutator = static_state.mutator() - mutator.start_workflow(workflow.id.to_full_local(primary_section.id)).unwrap() + mutator.start_workflow(artifact_section_id(workflow.id, primary_section.id)).unwrap() _save_state(mutator.freeze()).unwrap() _state_run(mutator).unwrap() return _state_cells() @@ -120,12 +120,17 @@ def _validate_operation_transition( state: MutableState, request_id: ActionRequestId, next_operation_id: ArtifactSectionId ) -> Result[None, ErrorsList]: operation_id = state.get_action_request(request_id).unwrap().operation_id - workflow = context().artifacts.load(operation_id.artifact_id, RENDER_CONTEXT_VIEW).unwrap() - operation = workflow.get_section(operation_id.local_id).unwrap() + operation_parts = split_artifact_section_id(operation_id) + assert operation_parts is not None + next_operation_parts = split_artifact_section_id(next_operation_id) + assert next_operation_parts is not None + + workflow = context().artifacts.load(operation_parts.artifact_id, RENDER_CONTEXT_VIEW).unwrap() + operation = workflow.get_section(operation_parts.section_id).unwrap() assert isinstance(operation.meta, OperationMeta) - if next_operation_id.local_id not in operation.meta.allowed_transtions: + if next_operation_parts.section_id not in operation.meta.allowed_transtions: return Err( [machine_errors.InvalidOperationTransition(operation_id=operation_id, next_operation_id=next_operation_id)] ) diff --git a/donna/machine/state.py b/donna/machine/state.py index c3574d6c..2a2098b1 100644 --- a/donna/machine/state.py +++ b/donna/machine/state.py @@ -8,7 +8,7 @@ from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.artifact_ids import ArtifactSectionId, split_artifact_section_id from donna.domain.internal_ids import ActionRequestId, InternalId, TaskId, WorkUnitId from donna.machine import errors as machine_errors from donna.machine import journal as machine_journal @@ -175,8 +175,10 @@ def complete_action_request( @unwrap_to_error def start_workflow(self, full_operation_id: ArtifactSectionId) -> Result[None, ErrorsList]: - artifact = context().artifacts.load(full_operation_id.artifact_id, RENDER_CONTEXT_VIEW).unwrap() - workflow = artifact.get_section(full_operation_id.local_id).unwrap() + operation_parts = split_artifact_section_id(full_operation_id) + assert operation_parts is not None + artifact = context().artifacts.load(operation_parts.artifact_id, RENDER_CONTEXT_VIEW).unwrap() + workflow = artifact.get_section(operation_parts.section_id).unwrap() machine_journal.add( message=f"Start workflow `{workflow.title}`", @@ -189,8 +191,10 @@ def start_workflow(self, full_operation_id: ArtifactSectionId) -> Result[None, E def finish_workflow(self, task_id: TaskId) -> None: task = self.current_task assert task is not None - artifact = context().artifacts.load(task.workflow_id.artifact_id, RENDER_CONTEXT_VIEW).unwrap() - workflow = artifact.get_section(task.workflow_id.local_id).unwrap() + workflow_parts = split_artifact_section_id(task.workflow_id) + assert workflow_parts is not None + artifact = context().artifacts.load(workflow_parts.artifact_id, RENDER_CONTEXT_VIEW).unwrap() + workflow = artifact.get_section(workflow_parts.section_id).unwrap() machine_journal.add( message=f"Finish workflow `{workflow.title}`", diff --git a/donna/machine/tasks.py b/donna/machine/tasks.py index 1ee18cb2..13c08c10 100644 --- a/donna/machine/tasks.py +++ b/donna/machine/tasks.py @@ -4,7 +4,7 @@ from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.artifact_ids import ArtifactSectionId, split_artifact_section_id from donna.domain.internal_ids import TaskId, WorkUnitId if TYPE_CHECKING: @@ -66,8 +66,10 @@ def run(self, task: Task) -> Result[list["Change"], ErrorsList]: ) ctx = context() with ctx.current_operation_id.scope(self.operation_id): - artifact = ctx.artifacts.load(self.operation_id.artifact_id, render_context).unwrap() - operation = artifact.get_section(self.operation_id.local_id).unwrap() + operation_parts = split_artifact_section_id(self.operation_id) + assert operation_parts is not None + artifact = ctx.artifacts.load(operation_parts.artifact_id, render_context).unwrap() + operation = artifact.get_section(operation_parts.section_id).unwrap() operation_kind = ctx.primitives.resolve(operation.kind).unwrap() machine_journal.add( diff --git a/donna/primitives/artifacts/workflow.py b/donna/primitives/artifacts/workflow.py index 4db59536..c061527a 100644 --- a/donna/primitives/artifacts/workflow.py +++ b/donna/primitives/artifacts/workflow.py @@ -3,7 +3,7 @@ from donna.core import errors as core_errors from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactId +from donna.domain.artifact_ids import ArtifactId, artifact_section_id from donna.domain.ids import SectionId from donna.machine.artifacts import Artifact, ArtifactSection, ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.errors import ArtifactValidationError @@ -135,7 +135,7 @@ def execute_section( section = artifact.get_section(section_id).unwrap() start_operation_id = self._resolve_start_operation_id(artifact, section).unwrap() - full_id = artifact.id.to_full_local(start_operation_id) + full_id = artifact_section_id(artifact.id, start_operation_id) return Ok([ChangeAddWorkUnit(task_id=task.id, operation_id=full_id)]) diff --git a/donna/primitives/directives/goto.py b/donna/primitives/directives/goto.py index 6847bcb4..a56e1229 100644 --- a/donna/primitives/directives/goto.py +++ b/donna/primitives/directives/goto.py @@ -5,7 +5,7 @@ from donna.core import errors as core_errors from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result -from donna.domain.artifact_ids import ArtifactSectionId +from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId, artifact_section_id, split_artifact_section_id from donna.machine.templates import Directive, PreparedDirectiveResult from donna.workspaces import config as workspace_config @@ -30,9 +30,9 @@ def _prepare_arguments( if argv is None or len(argv) != 1: return Err([GoToInvalidArguments(provided_count=0 if argv is None else len(argv))]) - artifact_id = context["artifact_id"] + artifact_id = ArtifactId(context["artifact_id"]) - next_operation_id = artifact_id.to_full_local(argv[0]) + next_operation_id = artifact_section_id(artifact_id, argv[0]) return Ok((next_operation_id,)) @@ -45,4 +45,6 @@ def render_view(self, context: Context, next_operation_id: ArtifactSectionId) -> ) def render_analyze(self, context: Context, next_operation_id: ArtifactSectionId) -> Result[Any, ErrorsList]: - return Ok(f"$$donna {self.analyze_id} {next_operation_id.local_id} donna$$") + parts = split_artifact_section_id(next_operation_id) + assert parts is not None + return Ok(f"$$donna {self.analyze_id} {parts.section_id} donna$$") diff --git a/donna/primitives/sections/output.py b/donna/primitives/sections/output.py index a4e54334..b26c0312 100644 --- a/donna/primitives/sections/output.py +++ b/donna/primitives/sections/output.py @@ -2,7 +2,7 @@ from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactId +from donna.domain.artifact_ids import ArtifactId, artifact_section_id, split_artifact_section_id from donna.domain.ids import SectionId from donna.machine.artifacts import Artifact, ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.errors import ArtifactValidationError @@ -72,7 +72,9 @@ def execute_section( next_operation_id = meta.next_operation_id assert next_operation_id is not None - full_operation_id = unit.operation_id.artifact_id.to_full_local(next_operation_id) + operation_parts = split_artifact_section_id(unit.operation_id) + assert operation_parts is not None + full_operation_id = artifact_section_id(operation_parts.artifact_id, next_operation_id) return Ok([ChangeAddWorkUnit(task_id=task.id, operation_id=full_operation_id)]) diff --git a/donna/primitives/sections/run_script.py b/donna/primitives/sections/run_script.py index 38d1d383..48c13e3e 100644 --- a/donna/primitives/sections/run_script.py +++ b/donna/primitives/sections/run_script.py @@ -9,7 +9,7 @@ from donna.core import errors as core_errors from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactId +from donna.domain.artifact_ids import ArtifactId, artifact_section_id, split_artifact_section_id from donna.domain.ids import SectionId from donna.machine import journal as machine_journal from donna.machine.artifacts import Artifact, ArtifactSectionConfig, ArtifactSectionMeta @@ -179,7 +179,9 @@ def execute_section( changes.append(ChangeSetTaskContext(task_id=task.id, key=meta.save_stderr_to, value=stderr)) next_operation = meta.select_next_operation(exit_code) - full_operation_id = unit.operation_id.artifact_id.to_full_local(next_operation) + operation_parts = split_artifact_section_id(unit.operation_id) + assert operation_parts is not None + full_operation_id = artifact_section_id(operation_parts.artifact_id, next_operation) changes.append(ChangeAddWorkUnit(task_id=task.id, operation_id=full_operation_id)) return Ok(changes) diff --git a/donna/workspaces/artifacts.py b/donna/workspaces/artifacts.py index 08685831..6502a9ae 100644 --- a/donna/workspaces/artifacts.py +++ b/donna/workspaces/artifacts.py @@ -4,12 +4,13 @@ from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.artifact_ids import ArtifactId +from donna.domain.artifact_ids import ArtifactId, artifact_path_parts, validate_artifact_id from donna.domain.constants import DONNA_ARTIFACT_EXTENSION -from donna.domain.id_paths import NormalizedRawIdPath +from donna.domain.paths import UntrustedPath from donna.domain.types import Milliseconds from donna.machine.tasks import Task, WorkUnit from donna.workspaces import errors as world_errors +from donna.workspaces.paths import normalize_existing_path from donna.workspaces.templates import RenderMode if TYPE_CHECKING: @@ -41,11 +42,11 @@ def has_donna_artifact_extension(path: pathlib.Path | str) -> bool: def _artifact_id_from_parts(parts: Sequence[str]) -> ArtifactId | None: - artifact_name = "/".join(parts) - if not ArtifactId.validate(artifact_name): + artifact_name = "@/" + "/".join(parts) + if not validate_artifact_id(artifact_name): return None - return ArtifactId(NormalizedRawIdPath(artifact_name)) + return ArtifactId(artifact_name) def _workflow_dir_parts(path: pathlib.Path) -> tuple[str, ...]: @@ -53,12 +54,14 @@ def _workflow_dir_parts(path: pathlib.Path) -> tuple[str, ...]: def _artifact_is_in_workflow_dirs(artifact_id: ArtifactId, workflow_dirs: Sequence[pathlib.Path]) -> bool: + artifact_parts = artifact_path_parts(artifact_id) + for workflow_dir in workflow_dirs: workflow_parts = _workflow_dir_parts(workflow_dir) - if len(artifact_id.parts) <= len(workflow_parts): + if len(artifact_parts) <= len(workflow_parts): continue - if artifact_id.parts[: len(workflow_parts)] == workflow_parts: + if artifact_parts[: len(workflow_parts)] == workflow_parts: return True return False @@ -134,13 +137,16 @@ def list_artifact_ids() -> list[ArtifactId]: def resolve_artifact_path(artifact_id: ArtifactId) -> Result[pathlib.Path | None, ErrorsList]: from donna.workspaces.config import project_dir - artifact_path = project_dir().joinpath(*artifact_id.parts) + artifact_path = project_dir().joinpath(*artifact_path_parts(artifact_id)) if not artifact_path.parent.exists(): return Ok(None) if not artifact_path.exists() or not artifact_path.is_file(): return Ok(None) + if normalize_existing_path(UntrustedPath(artifact_path), UntrustedPath(project_dir())) is None: + return Ok(None) + return Ok(artifact_path) diff --git a/donna/workspaces/paths.py b/donna/workspaces/paths.py index 7ae59f1a..cc47a823 100644 --- a/donna/workspaces/paths.py +++ b/donna/workspaces/paths.py @@ -2,10 +2,17 @@ import pathlib -from donna.domain.artifact_ids import ArtifactId, ArtifactSectionId +from donna.domain.artifact_ids import ( + ARTIFACT_SECTION_DELIMITER, + ArtifactId, + ArtifactSectionId, + artifact_path_parts, + artifact_section_id, + validate_artifact_id, +) from donna.domain.constants import ARTIFACT_ID_PREFIX -from donna.domain.id_paths import NormalizedRawIdPath from donna.domain.ids import SectionId +from donna.domain.paths import PathInput, ProjectPathId, ProjectRootPath, ResolvedProjectPath, UntrustedPath PROJECT_ROOT_PREFIX = ARTIFACT_ID_PREFIX @@ -27,7 +34,7 @@ def _append_normalized_part(parts: list[str], part: str) -> bool: return True -def _normalize_parts(raw: str, *, initial_parts: tuple[str, ...] = ()) -> NormalizedRawIdPath | None: +def _normalize_parts(raw: str, *, initial_parts: tuple[str, ...] = ()) -> ProjectPathId | None: if not raw: return None @@ -40,74 +47,122 @@ def _normalize_parts(raw: str, *, initial_parts: tuple[str, ...] = ()) -> Normal if not parts: return None - normalized = NormalizedRawIdPath("/".join(parts)) - if not ArtifactId.validate(normalized): + normalized = ProjectPathId(PROJECT_ROOT_PREFIX + "/".join(parts)) + if not validate_artifact_id(normalized): return None return normalized -def _normalize_root_anchored(value: str) -> NormalizedRawIdPath | None: +def resolve_project_root(root: UntrustedPath) -> ProjectRootPath: + return ProjectRootPath(root.resolve()) + + +def _normalize_root_anchored(value: str) -> ProjectPathId | None: if not value.startswith(PROJECT_ROOT_PREFIX): return None return _normalize_parts(value.removeprefix(PROJECT_ROOT_PREFIX)) -def _normalize_from_filesystem( - value: str, root: pathlib.Path, *, cwd: pathlib.Path | None = None -) -> NormalizedRawIdPath | None: - project_root = root.resolve() +def _resolve_inside_project(path: UntrustedPath, root: ProjectRootPath) -> ResolvedProjectPath | None: + resolved = path.resolve() + root_path = pathlib.Path(root) + + if resolved == root_path or not resolved.is_relative_to(root_path): + return None + + return ResolvedProjectPath(resolved) + + +def _canonical_from_resolved(resolved: ResolvedProjectPath, root: ProjectRootPath) -> ProjectPathId | None: + normalized = ProjectPathId(PROJECT_ROOT_PREFIX + pathlib.Path(resolved).relative_to(pathlib.Path(root)).as_posix()) + if not validate_artifact_id(normalized): + return None + + return normalized + + +def _resolve_root_anchored_path(value: str, root: ProjectRootPath) -> ResolvedProjectPath | None: + normalized = _normalize_root_anchored(value) + if normalized is None: + return None + + path = pathlib.Path(root).joinpath(*normalized.removeprefix(PROJECT_ROOT_PREFIX).split("/")) + return _resolve_inside_project(UntrustedPath(path), root) + + +def resolve_project_path(value: str, root: PathInput, *, allow_absolute: bool = True) -> ResolvedProjectPath | None: + project_root = ProjectRootPath(root.resolve()) + + if value.startswith("@"): + if not value.startswith(PROJECT_ROOT_PREFIX): + return None + return _resolve_root_anchored_path(value, project_root) + path = pathlib.Path(value).expanduser() - if path.is_absolute(): - candidate = path - else: - candidate = (cwd or project_root) / path + if path.is_absolute() and not allow_absolute: + return None + + candidate = path if path.is_absolute() else pathlib.Path(project_root) / path + return _resolve_inside_project(UntrustedPath(candidate), project_root) + + +def normalize_path(value: str, root: PathInput, *, cwd: PathInput | None = None) -> ProjectPathId | None: + project_root = ProjectRootPath(root.resolve()) + + if value.startswith("@"): + return _normalize_root_anchored(value) - resolved = candidate.resolve() + path = pathlib.Path(value).expanduser() + candidate = path if path.is_absolute() else pathlib.Path(cwd or project_root) / path + resolved = _resolve_inside_project(UntrustedPath(candidate), project_root) - if resolved == project_root or not resolved.is_relative_to(project_root): + if resolved is None: return None - normalized = NormalizedRawIdPath(resolved.relative_to(project_root).as_posix()) - if not ArtifactId.validate(normalized): + return _canonical_from_resolved(resolved, project_root) + + +def normalize_existing_path(path: UntrustedPath, root: PathInput) -> ProjectPathId | None: + project_root = ProjectRootPath(root.resolve()) + resolved = _resolve_inside_project(path, project_root) + + if resolved is None: return None - return normalized + return _canonical_from_resolved(resolved, project_root) -def _normalize_from_artifact(value: str, relative_to: ArtifactId) -> NormalizedRawIdPath | None: +def _normalize_from_artifact(value: str, relative_to: ArtifactId) -> ProjectPathId | None: if value.startswith(PROJECT_ROOT_PREFIX): return _normalize_root_anchored(value) - return _normalize_parts(value, initial_parts=relative_to.parts[:-1]) + return _normalize_parts(value, initial_parts=artifact_path_parts(relative_to)[:-1]) def normalize_artifact_path( value: str, - root: pathlib.Path, + root: PathInput, *, - cwd: pathlib.Path | None = None, + cwd: PathInput | None = None, relative_to: ArtifactId | None = None, -) -> NormalizedRawIdPath | None: +) -> ProjectPathId | None: if not isinstance(value, str) or not value: return None if relative_to is not None: return _normalize_from_artifact(value, relative_to) - if value.startswith("@"): - return _normalize_root_anchored(value) - - return _normalize_from_filesystem(value, root, cwd=cwd) + return normalize_path(value, root, cwd=cwd) def normalize_artifact_id( value: str, - root: pathlib.Path, + root: PathInput, *, - cwd: pathlib.Path | None = None, + cwd: PathInput | None = None, relative_to: ArtifactId | None = None, ) -> ArtifactId | None: normalized = normalize_artifact_path(value, root, cwd=cwd, relative_to=relative_to) @@ -119,16 +174,16 @@ def normalize_artifact_id( def normalize_artifact_section_id( value: str, - root: pathlib.Path, + root: PathInput, *, - cwd: pathlib.Path | None = None, + cwd: PathInput | None = None, relative_to: ArtifactId | None = None, ) -> ArtifactSectionId | None: if not isinstance(value, str) or not value: return None try: - artifact_part, local_part = value.rsplit(ArtifactSectionId.delimiter, maxsplit=1) + artifact_part, local_part = value.rsplit(ARTIFACT_SECTION_DELIMITER, maxsplit=1) except ValueError: return None @@ -136,4 +191,4 @@ def normalize_artifact_section_id( if artifact_id is None or not SectionId.validate(local_part): return None - return ArtifactSectionId(NormalizedRawIdPath(f"{artifact_id.raw_value}{ArtifactSectionId.delimiter}{local_part}")) + return artifact_section_id(artifact_id, SectionId(local_part)) From f1e2e99db3bb6d2776c7fc60ea022d94942aa323 Mon Sep 17 00:00:00 2001 From: "Aliaksei Yaletski (Tiendil)" Date: Sat, 16 May 2026 15:59:34 +0200 Subject: [PATCH 4/6] wip --- donna/cli/application.py | 5 ++- donna/cli/entities.py | 5 ++- donna/cli/types.py | 14 ++++---- donna/cli/utils.py | 5 +-- donna/core/utils.py | 7 ++-- donna/domain/paths.py | 2 ++ donna/primitives/directives/goto.py | 4 +-- donna/primitives/sections/run_script.py | 4 +-- donna/workspaces/artifacts.py | 44 +++++++++++-------------- donna/workspaces/config.py | 41 +++++++++++++---------- donna/workspaces/errors.py | 7 ++-- donna/workspaces/initialization.py | 18 +++++----- donna/workspaces/sessions.py | 8 ++--- 13 files changed, 85 insertions(+), 79 deletions(-) diff --git a/donna/cli/application.py b/donna/cli/application.py index d8288fd4..1d96c477 100644 --- a/donna/cli/application.py +++ b/donna/cli/application.py @@ -2,6 +2,7 @@ from donna.cli.entities import GLOBAL_OPTIONS_CONTEXT_KEY, GlobalOptions from donna.cli.types import ProtocolModeOption, RootOption +from donna.domain.paths import UntrustedPath from donna.protocol.modes import Mode app = typer.Typer(help="Donna CLI: manage hierarchical state machines to guide your AI agents.") @@ -13,7 +14,9 @@ def initialize( protocol: ProtocolModeOption = Mode.human, root_dir: RootOption = None, ) -> None: - context.meta[GLOBAL_OPTIONS_CONTEXT_KEY] = GlobalOptions(protocol=protocol, root_dir=root_dir) + context.meta[GLOBAL_OPTIONS_CONTEXT_KEY] = GlobalOptions( + protocol=protocol, root_dir=None if root_dir is None else UntrustedPath(root_dir) + ) def main() -> None: diff --git a/donna/cli/entities.py b/donna/cli/entities.py index e81ef339..c67da7fc 100644 --- a/donna/cli/entities.py +++ b/donna/cli/entities.py @@ -1,6 +1,5 @@ -import pathlib - from donna.core.entities import BaseEntity +from donna.domain.paths import UntrustedPath from donna.protocol.modes import Mode GLOBAL_OPTIONS_CONTEXT_KEY = "donna_global_options" @@ -8,4 +7,4 @@ class GlobalOptions(BaseEntity): protocol: Mode - root_dir: pathlib.Path | None = None + root_dir: UntrustedPath | None = None diff --git a/donna/cli/types.py b/donna/cli/types.py index a6a85742..b97be602 100644 --- a/donna/cli/types.py +++ b/donna/cli/types.py @@ -14,7 +14,7 @@ ) from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.internal_ids import ActionRequestId -from donna.domain.paths import UntrustedPath +from donna.domain.paths import PathInput, UntrustedPath from donna.protocol.modes import Mode from donna.workspaces import paths as workspace_paths from donna.workspaces.artifacts import has_donna_artifact_extension @@ -52,7 +52,7 @@ def validate_supported_artifact_section_id(section_id: ArtifactSectionId) -> Non validate_supported_artifact_id(parts.artifact_id) -def parse_artifact_id_argument(value: str, project_root: pathlib.Path) -> ArtifactId: +def parse_artifact_id_argument(value: str, project_root: PathInput) -> ArtifactId: artifact_id = workspace_paths.normalize_artifact_id( value, UntrustedPath(project_root), cwd=UntrustedPath(pathlib.Path.cwd()) ) @@ -63,7 +63,7 @@ def parse_artifact_id_argument(value: str, project_root: pathlib.Path) -> Artifa return artifact_id -def parse_artifact_section_id_argument(value: str, project_root: pathlib.Path) -> ArtifactSectionId: +def parse_artifact_section_id_argument(value: str, project_root: PathInput) -> ArtifactSectionId: section_id = workspace_paths.normalize_artifact_section_id( value, UntrustedPath(project_root), cwd=UntrustedPath(pathlib.Path.cwd()) ) @@ -88,10 +88,10 @@ def _parse_protocol_mode(value: str) -> Mode: raise typer.BadParameter(f"Unsupported protocol mode '{value}'. Expected one of: {allowed}.") from exc -def _parse_input_path(value: str) -> pathlib.Path: +def _parse_input_path(value: str) -> UntrustedPath: normalized = value.strip() if normalized == "-": - return pathlib.Path("-") + return UntrustedPath(pathlib.Path("-")) path = pathlib.Path(normalized).expanduser() if not path.exists(): @@ -102,7 +102,7 @@ def _parse_input_path(value: str) -> pathlib.Path: if not path.is_absolute(): path = path.resolve() - return path + return UntrustedPath(path) ActionRequestIdArgument = Annotated[ @@ -186,7 +186,7 @@ def _parse_input_path(value: str) -> pathlib.Path: ] InputPathArgument = Annotated[ - pathlib.Path, + UntrustedPath, typer.Argument( parser=_parse_input_path, help="Path to an existing local file used as input, or '-' to read from stdin.", diff --git a/donna/cli/utils.py b/donna/cli/utils.py index c6049bee..ef7ffa50 100644 --- a/donna/cli/utils.py +++ b/donna/cli/utils.py @@ -8,6 +8,7 @@ from donna.cli.entities import GLOBAL_OPTIONS_CONTEXT_KEY, GlobalOptions from donna.core.errors import EnvironmentError, ErrorsList from donna.core.result import UnwrapError +from donna.domain.paths import PathInput, UntrustedPath from donna.protocol.cells import Cell from donna.protocol.modes import Mode, get_cell_formatter from donna.workspaces import config as workspace_config @@ -47,14 +48,14 @@ def load_workspace(self) -> workspace_config.Workspace: workspace_config.install_workspace(workspace) return workspace - def target_dir(self) -> pathlib.Path: + def target_dir(self) -> PathInput: if self.global_options.root_dir is not None: return self.global_options.root_dir if workspace_config.project_dir.is_set(): return workspace_config.project_dir() - return pathlib.Path.cwd() + return UntrustedPath(pathlib.Path.cwd()) def write_cells(self, cells: Iterable[Cell]) -> None: output_cells(cells) diff --git a/donna/core/utils.py b/donna/core/utils.py index 60001fa9..474c6c0f 100644 --- a/donna/core/utils.py +++ b/donna/core/utils.py @@ -3,13 +3,14 @@ from donna.core import errors as core_errors from donna.core.result import Err, Ok, Result +from donna.domain.paths import ProjectRootPath def now() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) -def first_project_dir_with_config(config_name: str) -> pathlib.Path | None: +def first_project_dir_with_config(config_name: str) -> ProjectRootPath | None: """Get the first parent directory containing the Donna config file. Search from the current working directory upwards for a folder with Donna config. @@ -19,12 +20,12 @@ def first_project_dir_with_config(config_name: str) -> pathlib.Path | None: for parent in [current_dir] + list(current_dir.parents): config_path = parent / config_name if config_path.is_file(): - return parent + return ProjectRootPath(parent) return None -def discover_project_dir(config_name: str) -> Result[pathlib.Path, core_errors.ErrorsList]: +def discover_project_dir(config_name: str) -> Result[ProjectRootPath, core_errors.ErrorsList]: """Discover the project directory by looking for the Donna config file in parent folders.""" project_dir = first_project_dir_with_config(config_name) diff --git a/donna/domain/paths.py b/donna/domain/paths.py index 8fe05f5d..2e948087 100644 --- a/donna/domain/paths.py +++ b/donna/domain/paths.py @@ -5,6 +5,8 @@ ProjectRootPath = NewType("ProjectRootPath", Path) ProjectPathId = NewType("ProjectPathId", str) +ProjectConfigPath = NewType("ProjectConfigPath", Path) +RelativeProjectPath = NewType("RelativeProjectPath", Path) ResolvedProjectPath = NewType("ResolvedProjectPath", Path) UntrustedPath = NewType("UntrustedPath", Path) PathInput = UntrustedPath | ProjectRootPath diff --git a/donna/primitives/directives/goto.py b/donna/primitives/directives/goto.py index a56e1229..132d1bbc 100644 --- a/donna/primitives/directives/goto.py +++ b/donna/primitives/directives/goto.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from jinja2.runtime import Context @@ -30,7 +30,7 @@ def _prepare_arguments( if argv is None or len(argv) != 1: return Err([GoToInvalidArguments(provided_count=0 if argv is None else len(argv))]) - artifact_id = ArtifactId(context["artifact_id"]) + artifact_id = cast(ArtifactId, context["artifact_id"]) next_operation_id = artifact_section_id(artifact_id, argv[0]) diff --git a/donna/primitives/sections/run_script.py b/donna/primitives/sections/run_script.py index 48c13e3e..7ca7955d 100644 --- a/donna/primitives/sections/run_script.py +++ b/donna/primitives/sections/run_script.py @@ -1,5 +1,4 @@ import os -import pathlib import subprocess # noqa: S404 import tempfile from typing import TYPE_CHECKING, ClassVar, cast @@ -11,6 +10,7 @@ from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId, artifact_section_id, split_artifact_section_id from donna.domain.ids import SectionId +from donna.domain.paths import ProjectRootPath from donna.machine import journal as machine_journal from donna.machine.artifacts import Artifact, ArtifactSectionConfig, ArtifactSectionMeta from donna.machine.errors import ArtifactValidationError @@ -221,7 +221,7 @@ def validate_section(self, artifact: Artifact, section_id: SectionId) -> Result[ return Ok(None) -def _run_script(script: str, timeout: int, project_dir: pathlib.Path) -> tuple[str, str, int]: # noqa: CCR001 +def _run_script(script: str, timeout: int, project_dir: ProjectRootPath) -> tuple[str, str, int]: # noqa: CCR001 temp_path = None try: diff --git a/donna/workspaces/artifacts.py b/donna/workspaces/artifacts.py index 6502a9ae..a7148da5 100644 --- a/donna/workspaces/artifacts.py +++ b/donna/workspaces/artifacts.py @@ -6,7 +6,7 @@ from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId, artifact_path_parts, validate_artifact_id from donna.domain.constants import DONNA_ARTIFACT_EXTENSION -from donna.domain.paths import UntrustedPath +from donna.domain.paths import ProjectPathId, RelativeProjectPath, ResolvedProjectPath, UntrustedPath from donna.domain.types import Milliseconds from donna.machine.tasks import Task, WorkUnit from donna.workspaces import errors as world_errors @@ -27,17 +27,17 @@ class ArtifactRenderContext(BaseEntity): class FilesystemRawArtifact(BaseEntity): - path: pathlib.Path + path: ResolvedProjectPath def get_bytes(self) -> bytes: - return self.path.read_bytes() + return pathlib.Path(self.path).read_bytes() @unwrap_to_error def render(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) -> Result["Artifact", ErrorsList]: return Ok(render_markdown_artifact(artifact_id, self.get_bytes(), render_context).unwrap()) -def has_donna_artifact_extension(path: pathlib.Path | str) -> bool: +def has_donna_artifact_extension(path: ProjectPathId | RelativeProjectPath | ResolvedProjectPath | str) -> bool: return pathlib.PurePath(path).name.lower().endswith(DONNA_ARTIFACT_EXTENSION) @@ -49,11 +49,11 @@ def _artifact_id_from_parts(parts: Sequence[str]) -> ArtifactId | None: return ArtifactId(artifact_name) -def _workflow_dir_parts(path: pathlib.Path) -> tuple[str, ...]: +def _workflow_dir_parts(path: RelativeProjectPath) -> tuple[str, ...]: return pathlib.PurePosixPath(path.as_posix()).parts -def _artifact_is_in_workflow_dirs(artifact_id: ArtifactId, workflow_dirs: Sequence[pathlib.Path]) -> bool: +def _artifact_is_in_workflow_dirs(artifact_id: ArtifactId, workflow_dirs: Sequence[RelativeProjectPath]) -> bool: artifact_parts = artifact_path_parts(artifact_id) for workflow_dir in workflow_dirs: @@ -73,7 +73,7 @@ def _artifact_is_visible_in_workspace(artifact_id: ArtifactId) -> bool: return _artifact_is_in_workflow_dirs(artifact_id, workspace_config.config().workflow_dirs) -def _artifact_path_from_filesystem_entry(entry: pathlib.Path, parts: list[str]) -> pathlib.Path | None: +def _artifact_id_from_filesystem_entry(entry: ResolvedProjectPath, parts: list[str]) -> ArtifactId | None: if not entry.is_file(): return None @@ -81,24 +81,22 @@ def _artifact_path_from_filesystem_entry(entry: pathlib.Path, parts: list[str]) return None artifact_parts = parts + [entry.name] - if _artifact_id_from_parts(artifact_parts) is None: - return None - - return pathlib.Path(*artifact_parts) + return _artifact_id_from_parts(artifact_parts) -def _walk_workflow_dir(node: pathlib.Path, parts: list[str]) -> Iterator[pathlib.Path]: - for entry in sorted(node.iterdir(), key=lambda item: item.name): +def _walk_workflow_dir(node: ResolvedProjectPath, parts: list[str]) -> Iterator[ArtifactId]: + for raw_entry in sorted(pathlib.Path(node).iterdir(), key=lambda item: item.name): + entry = ResolvedProjectPath(raw_entry) if entry.is_dir(): yield from _walk_workflow_dir(entry, parts + [entry.name]) continue - artifact_path = _artifact_path_from_filesystem_entry(entry, parts) - if artifact_path is not None: - yield artifact_path + artifact_id = _artifact_id_from_filesystem_entry(entry, parts) + if artifact_id is not None: + yield artifact_id -def walk_filesystem(workflow_dirs: Sequence[pathlib.Path]) -> Iterator[pathlib.Path]: +def walk_filesystem(workflow_dirs: Sequence[RelativeProjectPath]) -> Iterator[ArtifactId]: from donna.workspaces.config import project_dir root = project_dir() @@ -111,7 +109,7 @@ def walk_filesystem(workflow_dirs: Sequence[pathlib.Path]) -> Iterator[pathlib.P if not workflow_path.exists() or not workflow_path.is_dir(): continue - yield from _walk_workflow_dir(workflow_path, list(workflow_parts)) + yield from _walk_workflow_dir(ResolvedProjectPath(workflow_path), list(workflow_parts)) def list_artifact_ids() -> list[ArtifactId]: @@ -120,11 +118,7 @@ def list_artifact_ids() -> list[ArtifactId]: artifacts: list[ArtifactId] = [] seen: set[ArtifactId] = set() - for relative_path in walk_filesystem(workspace_config.config().workflow_dirs): - artifact_id = _artifact_id_from_parts(relative_path.parts) - if artifact_id is None: - continue - + for artifact_id in walk_filesystem(workspace_config.config().workflow_dirs): if artifact_id in seen: continue @@ -134,7 +128,7 @@ def list_artifact_ids() -> list[ArtifactId]: return artifacts -def resolve_artifact_path(artifact_id: ArtifactId) -> Result[pathlib.Path | None, ErrorsList]: +def resolve_artifact_path(artifact_id: ArtifactId) -> Result[ResolvedProjectPath | None, ErrorsList]: from donna.workspaces.config import project_dir artifact_path = project_dir().joinpath(*artifact_path_parts(artifact_id)) @@ -147,7 +141,7 @@ def resolve_artifact_path(artifact_id: ArtifactId) -> Result[pathlib.Path | None if normalize_existing_path(UntrustedPath(artifact_path), UntrustedPath(project_dir())) is None: return Ok(None) - return Ok(artifact_path) + return Ok(ResolvedProjectPath(artifact_path)) @unwrap_to_error diff --git a/donna/workspaces/config.py b/donna/workspaces/config.py index d58cb4c9..fdc4fa3d 100644 --- a/donna/workspaces/config.py +++ b/donna/workspaces/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import enum -import pathlib from typing import TYPE_CHECKING import pydantic @@ -10,6 +9,7 @@ from donna.domain.constants import DONNA_DEFAULT_SESSION_DIR, DONNA_DEFAULT_WORKFLOW_DIR from donna.domain.id_paths import NormalizedRawIdPath from donna.domain.ids import SectionId +from donna.domain.paths import ProjectRootPath, RelativeProjectPath from donna.domain.python_path import PythonPath from donna.workspaces import errors as world_errors @@ -60,44 +60,49 @@ def validate_cmd(cls, value: list[str] | None) -> list[str] | None: return value -def _default_workflow_dirs() -> list[pathlib.Path]: +def _default_workflow_dirs() -> list[RelativeProjectPath]: return [ - DONNA_DEFAULT_WORKFLOW_DIR, - DONNA_DEFAULT_SESSION_DIR, + RelativeProjectPath(DONNA_DEFAULT_WORKFLOW_DIR), + RelativeProjectPath(DONNA_DEFAULT_SESSION_DIR), ] -def _serialize_workflow_dir(path: pathlib.Path) -> str: +def _serialize_workflow_dir(path: RelativeProjectPath) -> str: return f"./{path.as_posix()}" -def _validate_workflow_dir(path: pathlib.Path) -> pathlib.Path: +def _validate_relative_project_path(path: RelativeProjectPath) -> RelativeProjectPath: if path.is_absolute(): - raise ValueError("Workflow directories must be relative to the Donna project root.") + raise ValueError("Project paths must be relative to the Donna project root.") if any(part == ".." for part in path.parts): - raise ValueError("Workflow directories must not contain parent-directory references.") + raise ValueError("Project paths must not contain parent-directory references.") - return path + return RelativeProjectPath(path) class Config(BaseEntity): - session_dir: pathlib.Path = DONNA_DEFAULT_SESSION_DIR + session_dir: RelativeProjectPath = RelativeProjectPath(DONNA_DEFAULT_SESSION_DIR) default_section_kind: PythonPath = PythonPath(NormalizedRawIdPath("donna.lib.text")) default_primary_section_kind: PythonPath = PythonPath(NormalizedRawIdPath("donna.lib.workflow")) default_primary_section_id: SectionId = SectionId("primary") - workflow_dirs: list[pathlib.Path] = pydantic.Field(default_factory=_default_workflow_dirs) + workflow_dirs: list[RelativeProjectPath] = pydantic.Field(default_factory=_default_workflow_dirs) journal: JournalConfig = pydantic.Field(default_factory=JournalConfig) cache_lifetime: float = 1.0 + @pydantic.field_validator("session_dir", mode="after") + @classmethod + def validate_session_dir(cls, value: RelativeProjectPath) -> RelativeProjectPath: + return _validate_relative_project_path(value) + @pydantic.field_validator("workflow_dirs", mode="after") @classmethod - def validate_workflow_dirs(cls, value: list[pathlib.Path]) -> list[pathlib.Path]: - workflow_dirs: list[pathlib.Path] = [] + def validate_workflow_dirs(cls, value: list[RelativeProjectPath]) -> list[RelativeProjectPath]: + workflow_dirs: list[RelativeProjectPath] = [] for path in value: - path = _validate_workflow_dir(path) + path = _validate_relative_project_path(path) if path in workflow_dirs: continue @@ -106,14 +111,14 @@ def validate_workflow_dirs(cls, value: list[pathlib.Path]) -> list[pathlib.Path] return workflow_dirs @pydantic.field_serializer("workflow_dirs") - def serialize_workflow_dirs(self, value: list[pathlib.Path]) -> list[str]: + def serialize_workflow_dirs(self, value: list[RelativeProjectPath]) -> list[str]: return [_serialize_workflow_dir(path) for path in value] class Workspace(BaseEntity): model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) - root: pydantic.DirectoryPath + root: ProjectRootPath config: Config @@ -142,14 +147,14 @@ def __call__(self) -> V: return self.get() -project_dir = GlobalConfig[pathlib.Path]() +project_dir = GlobalConfig[ProjectRootPath]() config = GlobalConfig[Config]() protocol: GlobalConfig["Mode"] = GlobalConfig() def install_workspace(workspace: Workspace) -> None: if not project_dir.is_set(): - project_dir.set(pathlib.Path(workspace.root)) + project_dir.set(ProjectRootPath(workspace.root)) if not config.is_set(): config.set(workspace.config) diff --git a/donna/workspaces/errors.py b/donna/workspaces/errors.py index 6c0f3b2d..04c35289 100644 --- a/donna/workspaces/errors.py +++ b/donna/workspaces/errors.py @@ -1,8 +1,7 @@ -import pathlib - from donna.core import errors as core_errors from donna.domain.artifact_ids import ArtifactId from donna.domain.constants import DONNA_ARTIFACT_EXTENSION +from donna.domain.paths import ProjectConfigPath class InternalError(core_errors.InternalError): @@ -15,7 +14,7 @@ class WorkspaceError(core_errors.EnvironmentError): class WorkspaceConfigError(WorkspaceError): cell_kind: str = "workspace_config_error" - config_path: pathlib.Path + config_path: ProjectConfigPath def content_intro(self) -> str: return f"Error in Donna config file '{self.config_path}'" @@ -41,7 +40,7 @@ class WorkspaceAlreadyInitialized(WorkspaceError): "Remove the existing `donna.toml` file if you want to reinitialize.", "Choose a different project directory.", ] - config_path: pathlib.Path + config_path: ProjectConfigPath class JournalCommandConfigInvalid(WorkspaceError): diff --git a/donna/workspaces/initialization.py b/donna/workspaces/initialization.py index 1e5e916f..5c4a4e97 100644 --- a/donna/workspaces/initialization.py +++ b/donna/workspaces/initialization.py @@ -6,24 +6,26 @@ from donna.core import utils from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.constants import DONNA_CONFIG_NAME +from donna.domain.paths import PathInput, ProjectConfigPath, ProjectRootPath, UntrustedPath from donna.protocol.modes import Mode from donna.workspaces import config from donna.workspaces import errors as world_errors +from donna.workspaces.paths import resolve_project_root BASE_CONFIG_FIXTURE = "base_config.toml" @unwrap_to_error -def load_workspace(root_dir: pathlib.Path | None = None) -> Result[config.Workspace, core_errors.ErrorsList]: +def load_workspace(root_dir: PathInput | None = None) -> Result[config.Workspace, core_errors.ErrorsList]: """Load workspace configuration without mutating process-global state.""" if root_dir is None: project_dir = utils.discover_project_dir(DONNA_CONFIG_NAME).unwrap() else: - project_dir = root_dir.resolve() - if not (project_dir / DONNA_CONFIG_NAME).is_file(): + project_dir = resolve_project_root(UntrustedPath(root_dir)) + if not (pathlib.Path(project_dir) / DONNA_CONFIG_NAME).is_file(): return Err([core_errors.ProjectDirNotFound(config_name=DONNA_CONFIG_NAME)]) - config_path = project_dir / DONNA_CONFIG_NAME + config_path = ProjectConfigPath(pathlib.Path(project_dir) / DONNA_CONFIG_NAME) if not config_path.exists(): return Ok(config.Workspace(root=project_dir, config=config.Config())) @@ -43,7 +45,7 @@ def load_workspace(root_dir: pathlib.Path | None = None) -> Result[config.Worksp @unwrap_to_error def initialize_runtime( - root_dir: pathlib.Path | None = None, + root_dir: PathInput | None = None, protocol: Mode | None = None, ) -> Result[config.Workspace, core_errors.ErrorsList]: """Initialize the runtime environment for the application. @@ -60,10 +62,10 @@ def initialize_runtime( @unwrap_to_error -def initialize_workspace(project_dir: pathlib.Path) -> Result[config.Workspace, core_errors.ErrorsList]: +def initialize_workspace(project_dir: PathInput) -> Result[config.Workspace, core_errors.ErrorsList]: """Initialize Donna project configuration.""" - project_dir = project_dir.resolve() - config_path = project_dir / DONNA_CONFIG_NAME + project_dir = ProjectRootPath(pathlib.Path(project_dir).resolve()) + config_path = ProjectConfigPath(pathlib.Path(project_dir) / DONNA_CONFIG_NAME) if config_path.exists(): return Err([world_errors.WorkspaceAlreadyInitialized(config_path=config_path)]) diff --git a/donna/workspaces/sessions.py b/donna/workspaces/sessions.py index 4da1268b..80b2dded 100644 --- a/donna/workspaces/sessions.py +++ b/donna/workspaces/sessions.py @@ -1,15 +1,15 @@ -import pathlib import shutil from donna.domain.constants import STATE_FILE_NAME +from donna.domain.paths import ResolvedProjectPath from donna.workspaces.config import config, project_dir -def _path() -> pathlib.Path: - return project_dir() / config().session_dir +def _path() -> ResolvedProjectPath: + return ResolvedProjectPath(project_dir() / config().session_dir) -def dir() -> pathlib.Path: +def dir() -> ResolvedProjectPath: session_dir = _path() session_dir.mkdir(parents=True, exist_ok=True) return session_dir From c5f5527368a6e5bd23766a2f571b35af87328fce Mon Sep 17 00:00:00 2001 From: "Aliaksei Yaletski (Tiendil)" Date: Sat, 16 May 2026 16:08:57 +0200 Subject: [PATCH 5/6] better config example --- donna/workspaces/fixtures/base_config.toml | 32 +++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/donna/workspaces/fixtures/base_config.toml b/donna/workspaces/fixtures/base_config.toml index debaf166..ffe16a33 100644 --- a/donna/workspaces/fixtures/base_config.toml +++ b/donna/workspaces/fixtures/base_config.toml @@ -1,11 +1,35 @@ + +# Directory where Donna will keep its state. session_dir = ".session/donna" -default_section_kind = "donna.lib.text" -default_primary_section_kind = "donna.lib.workflow" -default_primary_section_id = "primary" + +# Defaults to simplify initialization of a workflow. +# Most likely you don't need to change these unless you implement a custom operation or workflow. +# +# default_section_kind = "donna.lib.text" +# default_primary_section_kind = "donna.lib.workflow" +# default_primary_section_id = "primary" + +# Directories where Donna will look for workflow files. workflow_dirs = [ "./workflows", "./.session/donna", ] -cache_lifetime = 1.0 + +# Lifetime of the cache of loaded files in seconds. +# cache_lifetime = 1.0 [journal] +# Command Donna will execute to log a workflow execution progress. +# If the command is not specified, Donna will not log anything. +# +# Example: append journal records to a project-local text file. +# Uses only /bin/sh and printf, which are available on standard Linux systems. +# cmd = [ +# "/bin/sh", +# "-c", +# "printf '%s [%s] %s\\n' \"$1\" \"$2\" \"$3\" >> donna.log", +# "journal", +# "{timestamp}", +# "{actor_id}", +# "{message}", +# ] From a6f74e5b82ba37e51b9be5df9a78515515673e3f Mon Sep 17 00:00:00 2001 From: "Aliaksei Yaletski (Tiendil)" Date: Sat, 16 May 2026 16:36:40 +0200 Subject: [PATCH 6/6] Simplified cache --- donna.toml | 1 - donna/context/artifacts.py | 46 ++++++++++---------- donna/context/entity_cache.py | 32 -------------- donna/context/primitives.py | 31 +++----------- donna/context/state.py | 49 ++++++++++++---------- donna/domain/types.py | 3 -- donna/machine/errors.py | 9 ++++ donna/skills/fixtures/configuration.md | 15 +------ donna/workspaces/artifacts.py | 8 ++-- donna/workspaces/config.py | 2 - donna/workspaces/files.py | 28 +++++++++++++ donna/workspaces/fixtures/base_config.toml | 4 -- donna/workspaces/sessions.py | 13 +++++- 13 files changed, 107 insertions(+), 134 deletions(-) delete mode 100644 donna/context/entity_cache.py delete mode 100644 donna/domain/types.py create mode 100644 donna/workspaces/files.py diff --git a/donna.toml b/donna.toml index df0e30b2..ddc6ee44 100644 --- a/donna.toml +++ b/donna.toml @@ -2,7 +2,6 @@ session_dir = ".session/donna" default_section_kind = "donna.lib.text" default_primary_section_kind = "donna.lib.workflow" default_primary_section_id = "primary" -cache_lifetime = 1.0 workflow_dirs = ["./workflows", "./.session/donna"] [journal] diff --git a/donna/context/artifacts.py b/donna/context/artifacts.py index 56134dd6..14bf9323 100644 --- a/donna/context/artifacts.py +++ b/donna/context/artifacts.py @@ -1,43 +1,42 @@ from typing import TYPE_CHECKING -from donna.context.entity_cache import TimedCache, TimedCacheValue from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.artifact_ids import ArtifactId -from donna.domain.types import Milliseconds from donna.machine.artifacts import Artifact +from donna.workspaces import errors as workspace_errors from donna.workspaces.templates import RenderMode if TYPE_CHECKING: from donna.workspaces.artifacts import ArtifactRenderContext, FilesystemRawArtifact + from donna.workspaces.files import FileFingerprint -class _ArtifactCacheValue(TimedCacheValue): - __slots__ = ("raw_artifact", "rendered_artifacts") +class _ArtifactCacheValue: + __slots__ = ("fingerprint", "raw_artifact", "rendered_artifacts") def __init__( self, raw_artifact: "FilesystemRawArtifact", rendered_artifacts: dict[RenderMode, Artifact], - loaded_at_ms: Milliseconds, - checked_at_ms: Milliseconds, + fingerprint: "FileFingerprint", ) -> None: - super().__init__(loaded_at_ms=loaded_at_ms, checked_at_ms=checked_at_ms) self.raw_artifact = raw_artifact self.rendered_artifacts = rendered_artifacts + self.fingerprint = fingerprint -class ArtifactsCache(TimedCache): +class ArtifactsCache: __slots__ = ("_cache",) def __init__(self) -> None: self._cache: dict[ArtifactId, _ArtifactCacheValue] = {} @unwrap_to_error - def _is_cache_stale(self, artifact_id: ArtifactId, loaded_at_ms: Milliseconds) -> Result[bool, ErrorsList]: - from donna.workspaces.artifacts import has_artifact_changed + def _is_cache_stale(self, artifact_id: ArtifactId, fingerprint: "FileFingerprint") -> Result[bool, ErrorsList]: + from donna.workspaces.artifacts import artifact_fingerprint - return Ok(has_artifact_changed(artifact_id, since=loaded_at_ms).unwrap()) + return Ok(artifact_fingerprint(artifact_id).unwrap() != fingerprint) @staticmethod @unwrap_to_error @@ -47,15 +46,18 @@ def _load_raw_artifact(artifact_id: ArtifactId) -> Result["FilesystemRawArtifact return Ok(fetch_raw_artifact(artifact_id).unwrap()) @unwrap_to_error - def _refresh_cache_value( - self, artifact_id: ArtifactId, now_ms: Milliseconds - ) -> Result[_ArtifactCacheValue, ErrorsList]: + def _refresh_cache_value(self, artifact_id: ArtifactId) -> Result[_ArtifactCacheValue, ErrorsList]: + from donna.workspaces.files import FileFingerprint + raw_artifact = self._load_raw_artifact(artifact_id).unwrap() + fingerprint = FileFingerprint.from_path(raw_artifact.path) + if fingerprint is None: + return Err([workspace_errors.ArtifactNotFound(artifact_id=artifact_id)]) + refreshed = _ArtifactCacheValue( raw_artifact=raw_artifact, rendered_artifacts={}, - loaded_at_ms=now_ms, - checked_at_ms=now_ms, + fingerprint=fingerprint, ) self._cache[artifact_id] = refreshed return Ok(refreshed) @@ -63,21 +65,15 @@ def _refresh_cache_value( @unwrap_to_error def _get_cache_value(self, artifact_id: ArtifactId) -> Result[_ArtifactCacheValue, ErrorsList]: cached = self._cache.get(artifact_id) - now_ms = self._now_ms() if cached is None: - return Ok(self._refresh_cache_value(artifact_id, now_ms).unwrap()) - - # Skip expensive filesystem checks when cache lifetime has not elapsed yet. - if self._is_within_lifetime(cached, now_ms): - return Ok(cached) + return Ok(self._refresh_cache_value(artifact_id).unwrap()) - cache_stale = self._is_cache_stale(artifact_id, cached.loaded_at_ms).unwrap() - self._mark_checked(cached, now_ms) + cache_stale = self._is_cache_stale(artifact_id, cached.fingerprint).unwrap() if not cache_stale: return Ok(cached) - return Ok(self._refresh_cache_value(artifact_id, now_ms).unwrap()) + return Ok(self._refresh_cache_value(artifact_id).unwrap()) def invalidate(self, artifact_id: ArtifactId) -> None: self._cache.pop(artifact_id, None) diff --git a/donna/context/entity_cache.py b/donna/context/entity_cache.py deleted file mode 100644 index 569ff1a6..00000000 --- a/donna/context/entity_cache.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import time -from abc import ABC - -from donna.domain.types import Milliseconds -from donna.workspaces.config import config - - -class TimedCacheValue: - __slots__ = ("loaded_at_ms", "checked_at_ms") - - def __init__(self, loaded_at_ms: Milliseconds, checked_at_ms: Milliseconds) -> None: - self.loaded_at_ms = loaded_at_ms - self.checked_at_ms = checked_at_ms - - -class TimedCache(ABC): - @staticmethod - def _now_ms() -> Milliseconds: - return Milliseconds(time.time_ns() // 1_000_000) - - @staticmethod - def _cache_lifetime_ms() -> Milliseconds: - return Milliseconds(max(0, int(config().cache_lifetime * 1000))) - - def _is_within_lifetime(self, cached: TimedCacheValue, now_ms: Milliseconds) -> bool: - return (now_ms - cached.checked_at_ms) < self._cache_lifetime_ms() - - @staticmethod - def _mark_checked(cached: TimedCacheValue, now_ms: Milliseconds) -> None: - cached.checked_at_ms = now_ms diff --git a/donna/context/primitives.py b/donna/context/primitives.py index 2def036f..4e91309f 100644 --- a/donna/context/primitives.py +++ b/donna/context/primitives.py @@ -1,39 +1,28 @@ import importlib from typing import TYPE_CHECKING -from donna.context.entity_cache import TimedCache, TimedCacheValue from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.python_path import PythonPath -from donna.domain.types import Milliseconds from donna.machine import errors as machine_errors if TYPE_CHECKING: from donna.machine.primitives import Primitive -class _PrimitiveCacheValue(TimedCacheValue): - __slots__ = ("primitive",) - - def __init__(self, primitive: "Primitive", loaded_at_ms: Milliseconds, checked_at_ms: Milliseconds) -> None: - super().__init__(loaded_at_ms=loaded_at_ms, checked_at_ms=checked_at_ms) - self.primitive = primitive - - -class PrimitivesCache(TimedCache): +class PrimitivesCache: __slots__ = ("_cache",) def __init__(self) -> None: - self._cache: dict[PythonPath, _PrimitiveCacheValue] = {} + self._cache: dict[PythonPath, "Primitive"] = {} @unwrap_to_error def resolve(self, primitive_id: PythonPath) -> Result["Primitive", ErrorsList]: # noqa: CCR001 from donna.machine.primitives import Primitive cached = self._cache.get(primitive_id) - now_ms = self._now_ms() - if cached is not None and self._is_within_lifetime(cached, now_ms): - return Ok(cached.primitive) + if cached is not None: + return Ok(cached) import_path_str = str(primitive_id) @@ -55,14 +44,6 @@ def resolve(self, primitive_id: PythonPath) -> Result["Primitive", ErrorsList]: if not isinstance(primitive, Primitive): return Err([machine_errors.PrimitiveNotPrimitive(import_path=import_path_str)]) - if cached is not None: - previous_primitive = cached.primitive - cached.primitive = primitive - self._mark_checked(cached, now_ms) - if previous_primitive is not primitive: - cached.loaded_at_ms = now_ms - else: - cached = _PrimitiveCacheValue(primitive=primitive, loaded_at_ms=now_ms, checked_at_ms=now_ms) - self._cache[primitive_id] = cached + self._cache[primitive_id] = primitive - return Ok(cached.primitive) + return Ok(primitive) diff --git a/donna/context/state.py b/donna/context/state.py index fef8c2d5..609406b5 100644 --- a/donna/context/state.py +++ b/donna/context/state.py @@ -1,31 +1,27 @@ from typing import TYPE_CHECKING -from donna.context.entity_cache import TimedCache, TimedCacheValue from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.types import Milliseconds from donna.machine import errors as machine_errors if TYPE_CHECKING: from donna.machine.state import ConsistentState + from donna.workspaces.files import FileFingerprint -class _StateCacheValue(TimedCacheValue): - __slots__ = ("state", "state_json") +class _StateCacheValue: + __slots__ = ("fingerprint", "state") def __init__( self, state: "ConsistentState", - state_json: bytes, - loaded_at_ms: Milliseconds, - checked_at_ms: Milliseconds, + fingerprint: "FileFingerprint", ) -> None: - super().__init__(loaded_at_ms=loaded_at_ms, checked_at_ms=checked_at_ms) self.state = state - self.state_json = state_json + self.fingerprint = fingerprint -class StateCache(TimedCache): +class StateCache: __slots__ = ("_session_state",) def __init__(self) -> None: @@ -36,26 +32,30 @@ def load(self) -> Result["ConsistentState", ErrorsList]: from donna.machine.state import ConsistentState from donna.workspaces import sessions as workspace_sessions - now_ms = self._now_ms() cached = self._session_state + fingerprint = workspace_sessions.state_fingerprint() + + if cached is not None: + if fingerprint != cached.fingerprint: + return Err([machine_errors.SessionStateChangedExternally()]) - if cached is not None and self._is_within_lifetime(cached, now_ms): return Ok(cached.state) + if fingerprint is None: + return Err([machine_errors.SessionStateNotInitialized()]) + content = workspace_sessions.read_state() if content is None: return Err([machine_errors.SessionStateNotInitialized()]) - if cached is not None and cached.state_json == content: - self._mark_checked(cached, now_ms) - return Ok(cached.state) + latest_fingerprint = workspace_sessions.state_fingerprint() + if latest_fingerprint != fingerprint: + return Err([machine_errors.SessionStateChangedExternally()]) state = ConsistentState.from_json(content.decode("utf-8")) self._session_state = _StateCacheValue( state=state, - state_json=content, - loaded_at_ms=now_ms, - checked_at_ms=now_ms, + fingerprint=fingerprint, ) return Ok(state) @@ -63,13 +63,18 @@ def load(self) -> Result["ConsistentState", ErrorsList]: def save(self, state: "ConsistentState") -> Result[None, ErrorsList]: from donna.workspaces import sessions as workspace_sessions + cached = self._session_state + if cached is not None and workspace_sessions.state_fingerprint() != cached.fingerprint: + return Err([machine_errors.SessionStateChangedExternally()]) + content = state.to_json().encode("utf-8") workspace_sessions.write_state(content) - now_ms = self._now_ms() + fingerprint = workspace_sessions.state_fingerprint() + if fingerprint is None: + return Err([machine_errors.SessionStateNotInitialized()]) + self._session_state = _StateCacheValue( state=state, - state_json=content, - loaded_at_ms=now_ms, - checked_at_ms=now_ms, + fingerprint=fingerprint, ) return Ok(None) diff --git a/donna/domain/types.py b/donna/domain/types.py deleted file mode 100644 index a5b1a5c0..00000000 --- a/donna/domain/types.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import NewType - -Milliseconds = NewType("Milliseconds", int) diff --git a/donna/machine/errors.py b/donna/machine/errors.py index c9da94e1..68c0d6b0 100644 --- a/donna/machine/errors.py +++ b/donna/machine/errors.py @@ -34,6 +34,15 @@ class SessionStateNotInitialized(EnvironmentError): ways_to_fix: list[str] = ["Run Donna session start to initialize session state."] +class SessionStateChangedExternally(EnvironmentError): + code: str = "donna.machine.session_state_changed_externally" + message: str = "Session state changed after Donna loaded it in the current process." + ways_to_fix: list[str] = [ + "Stop the current operation and reload the session state before continuing.", + "Avoid running multiple Donna processes that mutate the same session at the same time.", + ] + + class JournalMessageContainsNewlines(EnvironmentError): code: str = "donna.machine.journal_message_contains_newlines" message: str = "Journal message must be a single line and must not contain newline characters." diff --git a/donna/skills/fixtures/configuration.md b/donna/skills/fixtures/configuration.md index 04b716cc..9a2b47f4 100644 --- a/donna/skills/fixtures/configuration.md +++ b/donna/skills/fixtures/configuration.md @@ -6,7 +6,7 @@ Donna project configuration lives at: /donna.toml ``` -The file is created by `donna -p llm init`. Edit it when the project needs workflow source directories, default section settings, cache behavior, or journal forwarding. +The file is created by `donna -p llm init`. Edit it when the project needs workflow source directories, default section settings, or journal forwarding. ## Minimal Configuration @@ -17,7 +17,6 @@ session_dir = ".session/donna" default_section_kind = "donna.lib.text" default_primary_section_kind = "donna.lib.workflow" default_primary_section_id = "primary" -cache_lifetime = 1.0 workflow_dirs = ["./workflows", "./.session/donna"] [journal] @@ -117,18 +116,6 @@ cmd = [ ] ``` -## Cache Lifetime - -`cache_lifetime` controls how long Donna may reuse cached project data, in seconds. - -Example: - -```toml -cache_lifetime = 0.25 -``` - -Use a smaller value when artifacts are edited rapidly by external tools. Use the default unless stale reads are observed. - ## Validation Workflow After editing `donna.toml`, run: diff --git a/donna/workspaces/artifacts.py b/donna/workspaces/artifacts.py index a7148da5..bd0b529f 100644 --- a/donna/workspaces/artifacts.py +++ b/donna/workspaces/artifacts.py @@ -7,9 +7,9 @@ from donna.domain.artifact_ids import ArtifactId, artifact_path_parts, validate_artifact_id from donna.domain.constants import DONNA_ARTIFACT_EXTENSION from donna.domain.paths import ProjectPathId, RelativeProjectPath, ResolvedProjectPath, UntrustedPath -from donna.domain.types import Milliseconds from donna.machine.tasks import Task, WorkUnit from donna.workspaces import errors as world_errors +from donna.workspaces.files import FileFingerprint from donna.workspaces.paths import normalize_existing_path from donna.workspaces.templates import RenderMode @@ -199,10 +199,10 @@ def render_markdown_artifact( @unwrap_to_error -def has_artifact_changed(artifact_id: ArtifactId, since: Milliseconds) -> Result[bool, ErrorsList]: +def artifact_fingerprint(artifact_id: ArtifactId) -> Result[FileFingerprint | None, ErrorsList]: artifact_path = resolve_artifact_path(artifact_id).unwrap() if artifact_path is None: - return Ok(True) + return Ok(None) - return Ok((artifact_path.stat().st_mtime_ns // 1_000_000) > since) + return Ok(FileFingerprint.from_path(artifact_path)) diff --git a/donna/workspaces/config.py b/donna/workspaces/config.py index fdc4fa3d..1a7e32cf 100644 --- a/donna/workspaces/config.py +++ b/donna/workspaces/config.py @@ -89,8 +89,6 @@ class Config(BaseEntity): workflow_dirs: list[RelativeProjectPath] = pydantic.Field(default_factory=_default_workflow_dirs) journal: JournalConfig = pydantic.Field(default_factory=JournalConfig) - cache_lifetime: float = 1.0 - @pydantic.field_validator("session_dir", mode="after") @classmethod def validate_session_dir(cls, value: RelativeProjectPath) -> RelativeProjectPath: diff --git a/donna/workspaces/files.py b/donna/workspaces/files.py new file mode 100644 index 00000000..57910568 --- /dev/null +++ b/donna/workspaces/files.py @@ -0,0 +1,28 @@ +from pathlib import Path +from stat import S_ISREG + + +class FileFingerprint: + __slots__ = ("mtime_ns", "size") + + def __init__(self, *, mtime_ns: int, size: int) -> None: + self.mtime_ns = mtime_ns + self.size = size + + @classmethod + def from_path(cls, path: Path) -> "FileFingerprint | None": + try: + file_stat = path.stat() + except FileNotFoundError: + return None + + if not S_ISREG(file_stat.st_mode): + return None + + return cls(mtime_ns=file_stat.st_mtime_ns, size=file_stat.st_size) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FileFingerprint): + return NotImplemented + + return self.mtime_ns == other.mtime_ns and self.size == other.size diff --git a/donna/workspaces/fixtures/base_config.toml b/donna/workspaces/fixtures/base_config.toml index ffe16a33..2fec7bb6 100644 --- a/donna/workspaces/fixtures/base_config.toml +++ b/donna/workspaces/fixtures/base_config.toml @@ -14,10 +14,6 @@ workflow_dirs = [ "./workflows", "./.session/donna", ] - -# Lifetime of the cache of loaded files in seconds. -# cache_lifetime = 1.0 - [journal] # Command Donna will execute to log a workflow execution progress. # If the command is not specified, Donna will not log anything. diff --git a/donna/workspaces/sessions.py b/donna/workspaces/sessions.py index 80b2dded..81fad5d3 100644 --- a/donna/workspaces/sessions.py +++ b/donna/workspaces/sessions.py @@ -3,12 +3,17 @@ from donna.domain.constants import STATE_FILE_NAME from donna.domain.paths import ResolvedProjectPath from donna.workspaces.config import config, project_dir +from donna.workspaces.files import FileFingerprint def _path() -> ResolvedProjectPath: return ResolvedProjectPath(project_dir() / config().session_dir) +def _state_path() -> ResolvedProjectPath: + return ResolvedProjectPath(dir() / STATE_FILE_NAME) + + def dir() -> ResolvedProjectPath: session_dir = _path() session_dir.mkdir(parents=True, exist_ok=True) @@ -28,13 +33,17 @@ def reset_dir() -> None: def read_state() -> bytes | None: - path = dir() / STATE_FILE_NAME + path = _state_path() if not path.exists(): return None return path.read_bytes() +def state_fingerprint() -> FileFingerprint | None: + return FileFingerprint.from_path(_state_path()) + + def write_state(content: bytes) -> None: - path = dir() / STATE_FILE_NAME + path = _state_path() path.write_bytes(content)