Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d06875bb7 | ||
|
|
ffefa8c07f | ||
|
|
883908ab55 | ||
|
|
eed4025918 | ||
|
|
6b90c7eb0d | ||
|
|
958645ed7f | ||
|
|
eb8cc17993 | ||
|
|
b3a4d131d1 | ||
|
|
f368f3a1c0 | ||
|
|
8ecc588a3e | ||
|
|
55677dd671 | ||
|
|
d615090dcd | ||
|
|
8c58faf292 | ||
|
|
802346f909 | ||
|
|
552bc5bc63 | ||
|
|
d59713d3e3 | ||
|
|
6ab532018a | ||
|
|
047d7183d7 | ||
|
|
4d85e5144b | ||
|
|
dcc8c3ee42 | ||
|
|
289c58ef36 | ||
|
|
094eb4b8ba | ||
|
|
dd7825b481 | ||
|
|
4c186b62d4 | ||
|
|
fef3c925cd | ||
|
|
785ae19757 | ||
|
|
873f870e23 | ||
|
|
54927f692f | ||
|
|
efcd191f96 | ||
|
|
2e8a4f2443 | ||
|
|
2f5f62ab09 | ||
|
|
919435eb84 | ||
|
|
bed9f8cff6 | ||
|
|
6c7995f5f5 | ||
|
|
7ea226509c | ||
|
|
6bef4a3f82 | ||
|
|
16494d4b1e | ||
|
|
f84dfb8476 | ||
|
|
07b217cf4a | ||
|
|
9bfb95d795 | ||
|
|
285dbb14cc | ||
|
|
cac6626586 | ||
|
|
5a373d1031 | ||
|
|
fe2e916937 | ||
|
|
66ac4dbf02 | ||
|
|
44c3dd1239 | ||
|
|
87abe8c335 | ||
|
|
148ba4e07b | ||
|
|
89ee213454 | ||
|
|
a04651905f | ||
|
|
600ea041ef | ||
|
|
1ec2aa136a | ||
|
|
4ced6681c2 | ||
|
|
08e2f9998d | ||
|
|
792695b65a | ||
|
|
da2ed6da25 | ||
|
|
bceba665c0 | ||
|
|
714b4617dd | ||
|
|
e9a8e93c83 | ||
|
|
ab1a54b72e | ||
|
|
5aaedbf787 |
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: commit-and-push
|
||||
description: Enforce Conventional Commits specification for all commit messages and push workflows
|
||||
---
|
||||
|
||||
# Commit and Push Rule
|
||||
|
||||
All commits MUST follow the [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) specification. No exceptions.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
| Type | When to use |
|
||||
|------|-------------|
|
||||
| `feat` | New feature — correlates with `MINOR` in semver |
|
||||
| `fix` | Bug fix — correlates with `PATCH` in semver |
|
||||
| `chore` | Maintenance, deps, config — no production code change |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | Formatting, whitespace — no logic change |
|
||||
| `refactor` | Code restructure — no feature or fix |
|
||||
| `perf` | Performance improvement |
|
||||
| `test` | Adding or correcting tests |
|
||||
| `build` | Build system or external dependency changes |
|
||||
| `ci` | CI configuration and scripts |
|
||||
| `revert` | Reverts a previous commit |
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Append `!` after type/scope to indicate a breaking change. This correlates with `MAJOR` in semver.
|
||||
|
||||
```
|
||||
feat(api)!: remove deprecated /v1/users endpoint
|
||||
|
||||
BREAKING CHANGE: /v1/users has been removed. Use /v2/users instead.
|
||||
```
|
||||
|
||||
A `BREAKING CHANGE:` footer can also be used in the commit body.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Type is ALWAYS lowercase.
|
||||
2. Description is imperative mood ("add", not "added" or "adds").
|
||||
3. Description is lowercase, no trailing period.
|
||||
4. Keep subject line under 72 characters.
|
||||
5. Scope is optional but recommended for `feat` and `fix` — use the affected module name.
|
||||
6. One logical change per commit. If a commit spans multiple types, split into multiple commits.
|
||||
7. Body wraps at 72 characters. Use it to explain **why**, not **what**.
|
||||
8. Footer uses `git trailer` format (e.g., `BREAKING CHANGE:`, `Reviewed-by:`, `Refs:`).
|
||||
|
||||
## Lefthook Hooks
|
||||
|
||||
Every commit and push MUST go through Lefthook's `pre-commit` and `pre-push` hooks. Hooks are the gatekeeper — if they fail, the commit/push does not happen.
|
||||
|
||||
1. `pre-commit` runs lint-staged on staged files. Commit is blocked until lint-staged passes.
|
||||
2. `pre-push` runs lint-staged diff check and version bump. Push is blocked until both pass.
|
||||
3. If a hook fails, **fix the root cause**. Do not work around it.
|
||||
|
||||
## Push
|
||||
|
||||
1. Every push MUST pass pre-commit and pre-push hooks (see `push-flow-convention` skill).
|
||||
2. **NEVER use `--no-verify`** to bypass hooks. No exceptions. No "just this once." If hooks fail, fix the issue and retry.
|
||||
3. **NEVER use `git commit --no-verify`**. If pre-commit fails, fix linting/formatting and restage.
|
||||
4. **NEVER use `git push --no-verify`**. If pre-push fails, fix the failing check and push again.
|
||||
5. Commit message quality is enforced — reject vague messages like "fix stuff", "update", "wip", "misc".
|
||||
6. If Lefthook is not installed, run `pnpm exec lefthook install` before committing. Do not commit without hooks registered.
|
||||
7. **NEVER add `Co-Authored-By` trailers for AI tools** (e.g., `Co-Authored-By: Claude Code <noreply@anthropic.com>`). Commits are authored by humans only. No AI attribution in commit messages.
|
||||
8. **NEVER stage all files in one commit** (`git add .` or `git add -A` then commit). Group related changes into separate, focused commits. Each commit = one logical change. If a feature touches auth + billing, split into separate commits per module.
|
||||
9. Stage files deliberately by name (`git add src/auth/login.ts src/auth/types.ts`). Review what's staged before committing (`git status`, `git diff --cached`).
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
feat(auth): add google oauth sign-in
|
||||
fix(cart): correct total calculation when discount is zero
|
||||
chore: update eslint config
|
||||
docs: add setup guide to README
|
||||
refactor(billing): extract invoice calculation to service layer
|
||||
test(users): add unit tests for avatar upload
|
||||
perf(api): cache user profile queries
|
||||
feat(api)!: change response format for /orders endpoint
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
Full specification: [conventionalcommits.org/en/v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: docs-folder
|
||||
description: Route non-hexagonal files to docs/ folder to keep domain architecture clean
|
||||
---
|
||||
|
||||
# Docs Folder Rule
|
||||
|
||||
Any file that does not fit the hexagonal architecture design pattern MUST live in the `docs/` folder. The source tree stays clean — only hexagonal-compliant code belongs in `src/`.
|
||||
|
||||
## Hexagonal Architecture Recap
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ # Pure business logic, entities, value objects, ports (interfaces)
|
||||
├── application/ # Use cases, orchestration, input/output ports
|
||||
├── infrastructure/ # Adapters — DB, HTTP clients, messaging, external APIs
|
||||
└── interfaces/ # Controllers, routes, CLI, resolvers (driving adapters)
|
||||
```
|
||||
|
||||
Only code that fits one of these layers belongs in the source tree.
|
||||
|
||||
## What goes in `docs/`
|
||||
|
||||
| Item | Why it's not hexagonal |
|
||||
|------|----------------------|
|
||||
| Architecture decision records (ADRs) | Documentation, not code |
|
||||
| API documentation / OpenAPI specs | Reference material |
|
||||
| Database diagrams / ERDs | Design artifacts |
|
||||
| Flowcharts / sequence diagrams | Visual documentation |
|
||||
| Meeting notes / technical decisions | Project context |
|
||||
| Onboarding guides | People documentation |
|
||||
| RFC / proposal documents | Decision records |
|
||||
| Scratch files / experiments | Not production code |
|
||||
| Third-party integration guides | Reference material |
|
||||
| Deployment runbooks | Ops documentation |
|
||||
| Configuration examples / templates | Not domain logic |
|
||||
| Migration guides / upgrade notes | Process documentation |
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── adr/ # Architecture Decision Records
|
||||
├── api/ # API specs, OpenAPI/Swagger files
|
||||
├── diagrams/ # ERDs, flowcharts, sequence diagrams
|
||||
├── guides/ # Onboarding, deployment, migration guides
|
||||
├── rfcs/ # Proposals and RFCs
|
||||
└── notes/ # Meeting notes, scratch, experiments
|
||||
```
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. NEVER put documentation files in `src/` — they pollute the domain.
|
||||
2. NEVER put scratch code, experiments, or spikes in `src/` — use `docs/notes/` or a separate branch.
|
||||
3. NEVER put config examples or templates in `src/` — use `docs/` or project root.
|
||||
4. If a file doesn't implement a port, adapter, use case, or entity — it doesn't belong in `src/`.
|
||||
5. Keep `docs/` organized by category, not by date or author.
|
||||
6. README at project root is fine — detailed docs go in `docs/`.
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
name: clean-code
|
||||
description: Apply Robert C. Martin's (Uncle Bob's) Clean Code, Clean Architecture, and Clean Craftsmanship principles when writing, reviewing, or refactoring code. Use this skill whenever the user asks to write new code of non-trivial size (functions, classes, modules, services), refactor or clean up existing code, review code for quality, design a module or system boundary, write tests, or whenever the user mentions "clean code," "clean architecture," "SOLID," "SRP," "OCP," "LSP," "ISP," "DIP," "TDD," "refactor," "code smells," "code review," "Uncle Bob," or "Robert Martin." Also engage proactively when producing or examining code that shows poor naming, long functions (>20 lines), deep nesting, unclear abstractions, duplicated logic, switch/if-else chains that should be polymorphic, missing tests, leaky boundaries, or frameworks bleeding into business logic — even if the user did not explicitly ask for a cleanup. Do not wait for magic words; if you are writing or touching code, consult this skill.
|
||||
---
|
||||
|
||||
# Clean Code (Uncle Bob)
|
||||
|
||||
This skill codifies the principles that Robert C. Martin teaches across *Clean Code* (2008), *Clean Architecture* (2017), *Clean Craftsmanship* (2021), his blog at blog.cleancoder.com, the older wiki at butunclebob.com, his Google Sites articles, and his courses at cleancoder.com. It applies during code generation, review, refactoring, and system design.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
Three mental anchors to carry into every edit:
|
||||
|
||||
1. **Code is read far more than written.** The ratio is well over 10:1. Optimize for the reader — future teammates, and your future self.
|
||||
2. **The Boy Scout Rule.** Leave every module cleaner than you found it — even if just by renaming one variable or extracting one tiny function.
|
||||
3. **The only way to go fast is to go well.** Dirty code slows the whole team down. "We'll clean it up later" rarely happens, and productivity collapses into the Productivity Roller-Coaster or the Grand-Redesign Myth. Clean as you go, always.
|
||||
|
||||
Some quotes from other practitioners Uncle Bob cites:
|
||||
|
||||
- "Clean code is simple and direct. Clean code reads like well-written prose." — Grady Booch
|
||||
- "Clean code always looks like it was written by someone who cares." — Michael Feathers
|
||||
- "You know you are working on clean code when each routine you read turns out to be pretty much what you expected." — Ward Cunningham
|
||||
|
||||
## How to Use This Skill in a Session
|
||||
|
||||
When generating, reviewing, or refactoring code, work in this order:
|
||||
|
||||
1. **Name first.** Before writing a function body, confirm the name tells you what it does and why. If you cannot name it, you do not yet understand it.
|
||||
2. **Extract till you drop.** "A function does one thing if, and only if, you cannot extract another function from it." Keep extracting until you cannot.
|
||||
3. **Keep diffs honest.** When refactoring, do not also change behavior. When adding a feature, refactor *before* or *after*, never *during*.
|
||||
4. **Prefer tests first** for non-trivial logic. If infeasible, write them immediately after. See [references/tdd.md](references/tdd.md).
|
||||
5. **Respect boundaries.** Business rules must never depend on frameworks, databases, or UIs. See [references/architecture.md](references/architecture.md).
|
||||
6. **Prefer polymorphism to conditionals** for anything that varies by type — place if/else/switch in a factory that creates polymorphic objects. See [references/paradigms.md](references/paradigms.md).
|
||||
7. **Reread as a stranger.** Before declaring a task done, reread the code as if you had not written it.
|
||||
|
||||
## Deeper References
|
||||
|
||||
When the task calls for it, load the matching reference file:
|
||||
|
||||
- **[references/solid.md](references/solid.md)** — The five SOLID principles (SRP, OCP, LSP, ISP, DIP), their origins in Parnas (1972), Meyer (1988), Liskov (1987), their 2020 re-affirmation, and the component principles (REP, CCP, CRP, ADP, SDP, SAP). Load when designing a class, module, or microservice boundary.
|
||||
- **[references/architecture.md](references/architecture.md)** — Clean Architecture: the Dependency Rule, concentric layers, Screaming Architecture, why "the database is a detail," Ivar Jacobson's use-case foundation. Load when structuring a new service, deciding what a microservice should own, or untangling framework coupling.
|
||||
- **[references/tdd.md](references/tdd.md)** — The Three Laws of TDD, F.I.R.S.T., canonical test taxonomy (unit/acceptance/integration/system/micro/functional), test doubles, Chicago vs. London schools, fragile tests, the Transformation Priority Premise, the Cycles of TDD. Load when writing or reviewing tests.
|
||||
- **[references/paradigms.md](references/paradigms.md)** — The three programming paradigms (structured, OO, functional), the reductionist definitions Uncle Bob uses for each, the Data/Object Anti-Symmetry (why DTOs are not objects), polymorphism as the heart of OO, if-else-switch refactoring, why FP and OO are orthogonal not exclusive. Load when the task involves choosing between procedural and OO style, writing code in a functional language, refactoring switch statements, or handling persistence.
|
||||
- **[references/craft.md](references/craft.md)** — The craftsmanship ethic: mess vs. technical debt, Martin's First Law of Documentation, Saying No, estimation, pairing guidelines, "Going Fast" vs. "Speed Kills," the "Screaming Architecture" mindset applied to whole projects. Load when the task raises professional-judgment questions.
|
||||
- **[references/oath.md](references/oath.md)** — The Programmer's Oath. Load when the task raises a question of professional responsibility (shipping under pressure, accumulating "temporary" hacks, padding estimates, degrading code to hit a deadline).
|
||||
|
||||
The remaining sections are the core rulebook for code at the function and class level. Scan them against anything you produce.
|
||||
|
||||
---
|
||||
|
||||
## 1. Meaningful Names
|
||||
|
||||
Names are the single highest-leverage lever for readability. From Tim Ottinger's naming rules, expanded in the book:
|
||||
|
||||
- **Use intention-revealing names.** `int d;` → `int elapsedTimeInDays;`. Names should answer: *What is this? Why does it exist? How is it used?*
|
||||
- **Avoid disinformation.** Don't call something `accountList` unless it truly is a List. Don't use lowercase `l` or uppercase `O` as variable names (look like 1 and 0).
|
||||
- **Make meaningful distinctions.** `productInfo` vs. `productData` is noise. `a1`, `a2`, `a3` is a red flag.
|
||||
- **Use pronounceable, searchable names.** `genymdhms` is bad. Single letters are acceptable only for tiny local scopes.
|
||||
- **Class names are nouns** (`Customer`, `WikiPage`, `AddressParser`). **Method names are verbs** (`postPayment`, `deletePage`, `save`).
|
||||
- **Do not encode types.** Skip Hungarian notation, `m_` prefixes, `I` prefixes for interfaces.
|
||||
- **Ubiquitous language.** Use the business domain's vocabulary. If the domain says "policyholder," do not name it `user`.
|
||||
- **Pick one word per concept.** Standardize `fetch` vs. `retrieve` vs. `get`. Same for `controller` vs. `manager` vs. `driver`.
|
||||
- **Add meaningful context, and no more.** Scattered `firstName`/`street`/`city` need an `addr_` prefix or an `Address` type.
|
||||
|
||||
## 2. Functions
|
||||
|
||||
> First rule: functions should be small. Second rule: smaller than that.
|
||||
|
||||
- **Target ~20 lines, often far fewer.** If you cannot see the whole function without scrolling, it is too long.
|
||||
- **Do one thing.** *Operational definition:* a function does one thing if, and only if, you cannot extract another function from it (Uncle Bob, "Extract till you drop," 2009).
|
||||
- **One level of abstraction per function.** The Step-Down Rule: public high-level functions at top, calling slightly lower-level helpers, and so on. Reading top to bottom should feel like descending a staircase.
|
||||
- **Extract till you drop.** Most programmers stop far too early. Extract until you cannot.
|
||||
- **Descriptive names beat short names.** A long descriptive name is better than a long descriptive comment.
|
||||
- **Few arguments.** 0 ideal. 1–2 fine. 3 suspect. 4+ almost always means a struct or a split.
|
||||
- **No flag arguments.** `render(true)` is terrible. Split into `renderForSuite()` and `renderForSingleTest()`.
|
||||
- **No hidden side effects.** A function named `checkPassword` must not also initialize a session.
|
||||
- **Command-Query Separation.** A function either *does* something or *answers* something, never both.
|
||||
- **Tell, don't ask.** Alan Kay's original OO concept: cells in a biological system tell each other what to do; they do not ask for state and decide. Neurons, hormones — all tellers.
|
||||
- **Prefer exceptions (or Result types) to error codes.**
|
||||
- **Extract try/catch bodies.** Each should be a single function call.
|
||||
- **Avoid switch statements** in business logic. See [references/paradigms.md](references/paradigms.md) for the factory+polymorphism pattern.
|
||||
- **Don't Repeat Yourself.** Duplication is the root of many evils.
|
||||
|
||||
**Example — doing one thing:**
|
||||
|
||||
Bad:
|
||||
```
|
||||
def process_order(order):
|
||||
if not order.items:
|
||||
raise ValueError("Empty")
|
||||
total = sum(i.price * i.qty for i in order.items)
|
||||
total *= 1.1 # tax
|
||||
send_email(order.customer, f"Total: {total}")
|
||||
db.save(order, total)
|
||||
return total
|
||||
```
|
||||
|
||||
Clean:
|
||||
```
|
||||
def process_order(order):
|
||||
validate(order)
|
||||
total = calculate_total_with_tax(order)
|
||||
notify_customer(order, total)
|
||||
persist(order, total)
|
||||
return total
|
||||
```
|
||||
|
||||
## 3. Comments
|
||||
|
||||
> "Don't comment bad code — rewrite it." — Brian Kernighan
|
||||
|
||||
Every comment represents a failure to make the code self-explanatory. Before writing a comment, ask: *can I rename or extract to make this unnecessary?*
|
||||
|
||||
**Good comments (rare but valuable):**
|
||||
- Legal headers (when required).
|
||||
- Informative comments you cannot encode in names (regex explanations, format specs, wire protocol details).
|
||||
- Explanation of **intent** — *why*, not *what*. Why this ordering, why this tradeoff, why this workaround.
|
||||
- Clarification of obscure arguments or return values you cannot rename.
|
||||
- Warnings of consequences ("this test takes two hours to run").
|
||||
- TODOs — prune regularly; a stale TODO list is worse than none.
|
||||
- Public API documentation (Javadoc, rustdoc, TSDoc, etc.).
|
||||
- **Genuine complexity that resists expression in code.** Uncle Bob's own "Necessary Comments" (2017) example: a "choked function" (throttled cache wrapper) required a timing diagram in the test comments because no amount of naming or extraction could communicate the six interleaved test cases. Rare — but it happens.
|
||||
|
||||
**Bad comments — delete on sight:**
|
||||
- Mumbling (written for yourself, unclear to others).
|
||||
- Redundant (restates the code).
|
||||
- Misleading (out of date or wrong — actively harmful).
|
||||
- Mandated (comment-every-function policies produce noise).
|
||||
- Journal/changelog (that's what git is for).
|
||||
- Noise (`// default constructor`).
|
||||
- Commented-out code (delete it; git remembers).
|
||||
- Closing-brace comments (`} // end if`) — your function is too long.
|
||||
- Attributions (`// added by Rick`) — `git blame` exists.
|
||||
|
||||
**Martin's First Law of Documentation** (from *Agile Software Development: PPP*): Produce no document unless its need is immediate and significant. Note the scope: *documents*, not code comments. Agile is not the rejection of documentation — that is "flawed religious behavior." Documentation earns its keep on a prioritized, ROI basis.
|
||||
|
||||
## 4. Formatting
|
||||
|
||||
**Vertical formatting — the newspaper metaphor:**
|
||||
- Top of file: high-level concept. Details grow as you scroll.
|
||||
- Related concepts stay vertically close.
|
||||
- Dependent functions: caller above callee (the Step-Down Rule).
|
||||
- Blank lines separate concepts, not pad.
|
||||
|
||||
**Horizontal formatting:**
|
||||
- Keep lines readable (~100–120 chars).
|
||||
- Do not align assignments artificially.
|
||||
- Use indentation consistently; never collapse a multi-branch `if`/`while` onto one line.
|
||||
- Follow the team's standard. Consistency within a project beats any individual preference.
|
||||
|
||||
**Indentation is an abstraction-level signal.** Ideal functions have zero indentation beyond the function body; one or two levels (one if/while, one try) is acceptable. Deeper nesting usually means you have missed an extraction.
|
||||
|
||||
## 5. Objects and Data Structures
|
||||
|
||||
**Data/Object Anti-Symmetry** (Chapter 6, *Clean Code*):
|
||||
|
||||
- **Object:** a set of functions that operate on **implied** data. Data exists but is hidden.
|
||||
- **Data structure:** a set of data elements operated on by **implied** functions. Data is exposed; functions are not specified by the structure itself.
|
||||
|
||||
These are **complements**, not siblings. Consequences:
|
||||
|
||||
- DTOs are data structures, not objects.
|
||||
- Database tables are data structures, not objects.
|
||||
- "ORM" is a misnomer — there is no real mapping between tables and objects.
|
||||
- **The axis of expected change determines the style.** If you expect more new functions than new types, prefer procedural style (data structures + functions). If you expect more new types than new functions, prefer OO (classes + polymorphism). The Visitor pattern bridges the two.
|
||||
|
||||
Other rules:
|
||||
|
||||
- **Law of Demeter — don't talk to strangers.** A method `f` of class `C` should only call methods of: `C` itself, objects it creates, objects passed as arguments, or objects it holds as fields. Avoid train wrecks: `a.getB().getC().doSomething()`.
|
||||
- **Tell, don't ask.** Instead of asking for state and deciding, tell the object to do the work.
|
||||
|
||||
More on this in [references/paradigms.md](references/paradigms.md).
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
- **Use exceptions (or Result types), not return codes.**
|
||||
- **Write try-catch-finally first** when an operation can fail. It defines the transactional scope.
|
||||
- **Provide context with exceptions.** Wrap third-party exceptions in your own types.
|
||||
- **Define exception classes by the needs of the caller**, not by implementation detail.
|
||||
- **Do not return null.** Return empty collections, use Option/Result/Maybe, or throw. Null checks pollute callers.
|
||||
- **Do not pass null.** If a function cannot handle null, do not accept it. Fail fast at the boundary.
|
||||
|
||||
## 7. Boundaries
|
||||
|
||||
- **Wrap third-party APIs in adapters.** Your code talks to the adapter, not the library. Localizes changes when the library upgrades or is replaced.
|
||||
- **Write learning tests** when exploring a new library: small, focused tests that probe its behavior. When the library upgrades, they tell you what broke.
|
||||
- Keep boundaries clean so replacing a dependency is a localized change, not a project-wide rewrite.
|
||||
- The big structural story is in [references/architecture.md](references/architecture.md).
|
||||
|
||||
## 8. Tests
|
||||
|
||||
See [references/tdd.md](references/tdd.md) for the full treatment. Essentials:
|
||||
|
||||
**Three Laws of TDD:**
|
||||
1. Do not write production code until you have a failing test.
|
||||
2. Do not write more of a test than is sufficient to fail.
|
||||
3. Do not write more production code than is sufficient to pass.
|
||||
|
||||
**F.I.R.S.T.:** Fast, Independent, Repeatable, Self-validating, Timely.
|
||||
|
||||
**Michael Feathers' definition of legacy code:** *Legacy code is code without tests.* Uncle Bob calls Feathers's *Working Effectively with Legacy Code* "the only book I know of that addresses this topic."
|
||||
|
||||
Test code is first-class. Hold it to the same clarity bar as production code.
|
||||
|
||||
## 9. Classes
|
||||
|
||||
- **Small.** For functions we counted lines. For classes we count **responsibilities**.
|
||||
- **Single Responsibility Principle.** The formulation has evolved: "do one thing" → "one reason to change" → most recently (Clean Architecture, 2017) "responsible to one, and only one, **actor**" (where actor is a person or tightly coupled group). See [references/solid.md](references/solid.md).
|
||||
- **Cohesion.** Methods should use most of the instance variables. Low cohesion = two classes in a trench coat.
|
||||
- **Organize for change.** Isolate volatile concepts behind interfaces so changes do not ripple.
|
||||
- **Class organization order:** public static constants → private static variables → private instance variables → public functions → private helpers (grouped with their public caller).
|
||||
|
||||
## 10. Systems
|
||||
|
||||
- **Separate construction from use.** Startup code (wiring dependencies) lives in one place; business logic does not touch it.
|
||||
- **Dependency injection** over hardcoded `new` expressions deep in business logic.
|
||||
- **Cross-cutting concerns** (logging, transactions, security, metrics) belong in middleware/aspects/interceptors, not scattered through the domain.
|
||||
- Let architecture **emerge** as the system grows, but defend the seams — the places where modules plug together — at every stage.
|
||||
- See [references/architecture.md](references/architecture.md).
|
||||
|
||||
## 11. Emergent Design — Kent Beck's Four Rules
|
||||
|
||||
A design is *simple* if, in priority order, it:
|
||||
|
||||
1. **Runs all the tests.**
|
||||
2. **Contains no duplication.**
|
||||
3. **Expresses the intent of the programmer.**
|
||||
4. **Minimizes the number of classes and methods.**
|
||||
|
||||
Order matters. Never sacrifice test coverage to reduce class count.
|
||||
|
||||
---
|
||||
|
||||
## 12. Code Smells — A Review Checklist
|
||||
|
||||
Scan for these before declaring code done.
|
||||
|
||||
**Function smells**
|
||||
- Too many arguments (>3).
|
||||
- Flag arguments (booleans that switch behavior).
|
||||
- Selector arguments (enums that drive internal switches).
|
||||
- Dead parameters, dead code paths.
|
||||
- Obscure intent — body reads as a sequence of mystery steps.
|
||||
- Misplaced responsibility — function lives on the wrong class/module.
|
||||
- Inappropriate static — method should be polymorphic.
|
||||
|
||||
**Class smells**
|
||||
- Feature envy (method uses another class's data more than its own).
|
||||
- Large class / god class.
|
||||
- Too many responsibilities.
|
||||
- Inappropriate intimacy between classes.
|
||||
- Lazy class — no longer pulls its weight.
|
||||
|
||||
**General smells**
|
||||
- **Duplication** — the #1 smell; hunt it everywhere.
|
||||
- Magic numbers or strings — extract named constants.
|
||||
- Inconsistent names for the same concept.
|
||||
- Artificial coupling — things glued together that don't belong.
|
||||
- Negative conditionals (`if (!isNotEmpty)`) — invert.
|
||||
- **If-else or switch chains on type** — replace with factory + polymorphism; see [references/paradigms.md](references/paradigms.md).
|
||||
- Dead code.
|
||||
- Vertical separation — variables declared far from use.
|
||||
- Boundaries violated — business logic importing framework classes, entities reaching the DB.
|
||||
|
||||
**Name smells**
|
||||
- Non-descriptive (`data`, `info`, `handle`, `process`).
|
||||
- Names not matching level of abstraction.
|
||||
- Mental mapping required (decode `r`, `q`, `tmp`).
|
||||
- Encoded names (Hungarian, type prefixes).
|
||||
- Side info stuffed into names ("the `u` here is because…").
|
||||
|
||||
**Test smells**
|
||||
- Insufficient tests.
|
||||
- Skipped or ignored tests accumulating.
|
||||
- Tests dependent on execution order.
|
||||
- Tests that test the framework, not your code.
|
||||
- Slow tests (they will stop being run).
|
||||
- Over-mocking — tests break on refactor without any real regression.
|
||||
|
||||
---
|
||||
|
||||
## A Note on Disagreement
|
||||
|
||||
Clean Code is not scripture. Uncle Bob himself has revised definitions across his career (SRP has three formulations; LSP was initially taught as about inheritance and later clarified as about subtyping). He has also publicly recommended John Ousterhout's *A Philosophy of Software Design* (2022) while noting disagreements with Ousterhout on two key Clean Code points:
|
||||
|
||||
- Ousterhout prefers larger functions with "deep implementations behind narrow interfaces."
|
||||
- Ousterhout advocates more use of comments.
|
||||
|
||||
These are honest, ongoing debates. The skill's default aligns with Uncle Bob; you are expected to think about the tradeoffs, not apply the rules blindly. When a codebase's structure makes the opposite choice more readable for the reader-in-context, the reader wins.
|
||||
|
||||
## One More Thing
|
||||
|
||||
Clean code is not a destination — it is a **practice**. Every function is an opportunity to practice. The single most important lesson is not any rule; it is the **attitude of caring enough to leave the code better than you found it**.
|
||||
|
||||
The professional commitment behind all of this is captured in [references/oath.md](references/oath.md) and elaborated in [references/craft.md](references/craft.md).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Sources
|
||||
|
||||
This skill was built from material crawled from the Uncle Bob source network you provided. This file is an honest accounting of what was fetched and what wasn't, so you can verify the provenance of any claim and follow up if something seems off.
|
||||
|
||||
## Fully read (body captured)
|
||||
|
||||
### Course outlines (cleancoder.com/files/)
|
||||
|
||||
- cleanCodeCourse.md
|
||||
- cleanArchitectureCourse.md
|
||||
- tdd.md
|
||||
- advanced-tdd.md
|
||||
- clean-agile.md
|
||||
- immersion.md
|
||||
|
||||
### Blog posts (blog.cleancoder.com) — full or near-full body
|
||||
|
||||
- The Clean Architecture (2012-08-13)
|
||||
- The Programmer's Oath (2015-11-18)
|
||||
- The Single Responsibility Principle (2014-05-08, partial)
|
||||
- The Open Closed Principle (2014-05-12, via search)
|
||||
- OO vs FP (2014-11-24, via search)
|
||||
- First-Class Tests (2017-05-05, via search)
|
||||
- Testing Like the TSA (2017-03-06, via search)
|
||||
- Test Contra-variance (2017-10-03, via search)
|
||||
- Necessary Comments (2017-02-23)
|
||||
- Solid Relevance (2020-10-18)
|
||||
- if-else-switch (2021-03-06, via search)
|
||||
- Screaming Architecture (2011-09-30, via search)
|
||||
- A Little Architecture (2016-01-04, via search)
|
||||
- Classes vs Data Structures (2019-06-16, via search)
|
||||
- Functional Classes (2023-01-18, via search)
|
||||
- Loopy (2020-09-30, via search)
|
||||
|
||||
### Other sources
|
||||
|
||||
- cleancoder.com/books (full — Uncle Bob's recommended-reading list with annotations)
|
||||
- sites.google.com/site/unclebobconsultingllc/.../articles (index + inline excerpts of ~40 articles)
|
||||
- sites.google.com/.../articles/one-thing-extract-till-you-drop (full body after fighting Google Sites' massive-nav rendering)
|
||||
- butunclebob.com old-wiki pages via web_search:
|
||||
- ArticleS.UncleBob.PrinciplesOfOod (SRP evolution, component principles)
|
||||
- ArticleS.UncleBob.AgilePeopleStillDontGetIt (shipping untested code is unacceptable)
|
||||
- ArticleS.UncleBob.OnDocumentation (Martin's First Law of Documentation)
|
||||
- ArticleS.UncleBob.P2M2 (pairing guidelines)
|
||||
- ArticleS.UncleBob.IuseVisitor (Visitor pattern as SRP-preserver)
|
||||
- ArticleS.MichaelFeathers.LiskovSubstitutionInDynamicLanguages (LSP in dynamic languages)
|
||||
|
||||
## Titles only (index excerpts captured, body not fetched)
|
||||
|
||||
~120 additional blog.cleancoder.com posts — titles, dates, and (for ~30 of them) 1–3 sentence excerpts from Anthropic web_search results. Topics include: The Cycles of TDD, The Little Mocker, When to Mock, Monogamous TDD, Test Induced Design Damage?, The Transformation Priority Premise (+ three follow-ups), Three Paradigms, Why Clojure, FP Basics E1–E4, The Principles of Craftsmanship, The Humble Craftsman, Saying No, The Churn, The Lurn, NO DB, Clean Micro-service Architecture, Framework Bound, 'Interface' Considered Harmful, The Little Singleton, Type Wars, TDD Doesn't Work, TDD Harms Architecture, and roughly 90 others (including essays on hiring, certification, industry culture, and a handful of politically-themed posts).
|
||||
|
||||
The remaining ~40 Google Sites top-level articles — again, titles captured with some inline excerpts, bodies not individually fetched due to Google Sites' nav-heavy rendering (each fetch costs ~20K tokens in nav alone before the article body begins).
|
||||
|
||||
## Not fetched
|
||||
|
||||
- butunclebob.com front page and the full wiki structure beyond the few articles surfaced via web_search. The old wiki is largely dormant; the direct URLs I tried returned empty pages; content was reachable only via search result excerpts.
|
||||
- cleancoders.com video-episode descriptions (peripheral; the book/blog material covers the same ground).
|
||||
- Uncle Bob's Twitter/X archive (referenced in a few places but I fetched only material that appeared in search results).
|
||||
|
||||
## What this means for the skill
|
||||
|
||||
- Core-principle claims (the SOLID wording, the Clean Architecture layers, the TDD three laws, the F.I.R.S.T. attributes, the test taxonomy, the Data/Object Anti-Symmetry, the three paradigms, the oath) are backed by directly fetched body text or search-excerpt evidence.
|
||||
- Some narrower historical and biographical claims (the Parnas 1972 citation, Liskov 1987 date, Meyer 1988 OOSC citation, attribution of specific phrasings to specific articles) were corroborated across multiple sources but not verified at their original citations. If any of them matters for a formal use, double-check against the original paper.
|
||||
- The sections on ~120 unfetched blog posts are not directly represented in the skill — the skill is built from the ~25 sources I did read fully, plus the consistent pattern of excerpts from the rest.
|
||||
|
||||
If there's a specific blog post from the unfetched list that you want me to integrate, point me at it and I'll fetch it directly and revise the relevant reference file.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Clean Architecture
|
||||
|
||||
When to load this reference: when structuring a new service or module, drawing boundaries between components, deciding what a microservice should own, untangling framework coupling, reviewing a system for testability and longevity, or choosing a top-level folder structure.
|
||||
|
||||
Clean Architecture is Uncle Bob's synthesis of Hexagonal Architecture (Alistair Cockburn), Onion Architecture (Jeffrey Palermo), DCI (Coplien & Reenskaug), and BCE (Ivar Jacobson, *Object-Oriented Software Engineering*, 1992). They differ in detail but agree on one goal: **separation of concerns by layering**, with business rules isolated from delivery mechanisms.
|
||||
|
||||
The foundational insight comes from Jacobson: **architectures are structures that support the use cases of the system.** Not frameworks. Not databases. Not UIs. Use cases.
|
||||
|
||||
---
|
||||
|
||||
## What a Clean Architecture Produces
|
||||
|
||||
A system that is:
|
||||
|
||||
1. **Independent of frameworks.** Frameworks are tools, not constraints.
|
||||
2. **Testable.** Business rules tested without UI, DB, web server, or any external element.
|
||||
3. **Independent of UI.** The UI can be replaced (web → console → CLI → TUI) without touching business rules.
|
||||
4. **Independent of database.** Swap PostgreSQL for MongoDB, ClickHouse, or in-memory without rewriting domain logic.
|
||||
5. **Independent of any external agency.** The core business rules know nothing about the outside world.
|
||||
|
||||
**The database is a detail.** So is the web. So is the framework. These are the most common sources of architectural rot because developers mistake them for foundations.
|
||||
|
||||
> "The database is merely an IO device. It happens to provide some useful tools for sorting, querying, and reporting but those are ancillary to the system architecture." — *A Little Architecture* (2016)
|
||||
|
||||
---
|
||||
|
||||
## The Dependency Rule
|
||||
|
||||
The one rule that makes everything else work:
|
||||
|
||||
> **Source code dependencies point only inward, toward higher-level policy.**
|
||||
|
||||
- Nothing in an inner layer may name anything from an outer layer — no function, class, variable, or data format.
|
||||
- Data formats convenient for the outer layer (ORM row struct, JSON DTO) must not leak inward.
|
||||
- Control flow may cross boundaries in either direction, but *source dependencies* point only inward. The Dependency Inversion Principle (see [solid.md](solid.md)) is the mechanism that makes this possible when control flow runs outward.
|
||||
|
||||
When this rule is obeyed, external details — databases, frameworks, UIs — become replaceable plugins.
|
||||
|
||||
---
|
||||
|
||||
## The Four Concentric Layers
|
||||
|
||||
Schematic. You may need more or fewer for a given system, but the Dependency Rule always applies.
|
||||
|
||||
### 1. Entities (innermost)
|
||||
|
||||
Encapsulate **enterprise-wide** business rules. An entity can be a class with methods or a data structure plus functions — style choice.
|
||||
|
||||
- Entities know nothing about applications, use cases, frameworks, or anything outside.
|
||||
- For single applications (no "enterprise"), these are your core business objects.
|
||||
- These are the least affected by operational change. Changes to page navigation, auth mechanisms, or DB schemas must not reach here.
|
||||
|
||||
### 2. Use Cases
|
||||
|
||||
Encapsulate **application-specific** business rules. Use cases orchestrate entities to accomplish the application's goals.
|
||||
|
||||
- A use case directs entities; it does not contain enterprise-wide rules itself.
|
||||
- Changes to the application's *behavior* land here. Changes to externalities do not.
|
||||
- Simple request/response data structures (not entities) flow in and out.
|
||||
|
||||
### 3. Interface Adapters
|
||||
|
||||
Convert data between the format convenient for use cases/entities and the format convenient for external agencies.
|
||||
|
||||
- MVC's Controllers, Presenters, and Views live here.
|
||||
- All SQL lives here (if the database is SQL). Nothing inside knows about SQL.
|
||||
- DTOs are translated into domain types and back here.
|
||||
|
||||
### 4. Frameworks and Drivers (outermost)
|
||||
|
||||
The web framework, the database, the message broker, the file system. Glue code only — you do not write much application logic here. Details live here because details change, and the outer ring is where change is cheap.
|
||||
|
||||
---
|
||||
|
||||
## Crossing Boundaries
|
||||
|
||||
When control flow needs to run outward — a use case needs to call a presenter — a direct call violates the Dependency Rule (the inner layer names something in the outer layer).
|
||||
|
||||
**Solution: the Dependency Inversion Principle.** The use case calls an interface (an "output port") defined in its own layer. The outer-layer presenter implements that interface. Control flows outward; source dependencies point inward. Same pattern works for repositories, gateways, any outward call.
|
||||
|
||||
---
|
||||
|
||||
## What Crosses Boundaries
|
||||
|
||||
Only **simple data structures** cross boundaries:
|
||||
- Plain structs or Data Transfer Objects.
|
||||
- Primitive arguments in function calls.
|
||||
- Maps/dictionaries, when appropriate.
|
||||
|
||||
Never pass Entity objects or ORM row objects across boundaries — that couples layers. Translate to the format most convenient for the inner circle at every boundary crossing.
|
||||
|
||||
---
|
||||
|
||||
## Screaming Architecture
|
||||
|
||||
From the 2011 blog post of the same name. The top-level layout of a project should *scream* what the system does, not what framework it uses.
|
||||
|
||||
**The blueprint metaphor.** Imagine looking at the blueprints of a building. A single-family residence: front entrance, foyer, living room, dining room, kitchen. A library: grand entrance, check-in clerks, reading areas, galleries of bookshelves. A shopping mall: corridors, store bays, parking lots. You can tell what kind of building it is before you see any sign.
|
||||
|
||||
What does *your* application architecture scream?
|
||||
|
||||
**Bad top-level:** `controllers/`, `models/`, `views/`, `services/`. Tells you the system uses MVC. Tells you nothing about what the system is for.
|
||||
|
||||
**Good top-level:** `billing/`, `shipping/`, `catalog/`, `fraud_detection/`. Now you know what the system does.
|
||||
|
||||
**Why it matters:** A good architecture lets you defer decisions about Rails, Spring, Hibernate, Tomcat, MySQL, or React until much later in the project. A framework-centric top-level locks those decisions in day one, and also makes the code base mute about its own purpose. The web is a *delivery mechanism*; the database is a *detail*. Neither should dominate your system structure.
|
||||
|
||||
If a stranger cannot tell from the directory structure whether they are looking at an e-commerce platform or a hospital records system, the architecture is failing at the highest level.
|
||||
|
||||
---
|
||||
|
||||
## Component Principles
|
||||
|
||||
Once modules are organized, they group into **components** — independently deployable units (libraries, services, jars, crates). Two sets of principles govern them.
|
||||
|
||||
### Component Cohesion
|
||||
|
||||
- **REP — Reuse/Release Equivalence Principle.** The unit of reuse is the unit of release.
|
||||
- **CCP — Common Closure Principle.** Group together classes that change for the same reasons at the same times. (SRP at component scale.)
|
||||
- **CRP — Common Reuse Principle.** Classes used together belong together; classes not used together don't. (ISP at component scale.)
|
||||
|
||||
These three pull in different directions — the **tension diagram** is a triangle and component design is an ongoing balance. Early-stage projects lean toward REP+CCP (ship quickly, include more); mature, widely-reused components shift toward CRP (exclude what clients don't need).
|
||||
|
||||
### Component Coupling
|
||||
|
||||
- **ADP — Acyclic Dependencies Principle.** The dependency graph among components must have no cycles. Break cycles with DIP or by extracting a new component both sides depend on.
|
||||
- **SDP — Stable Dependencies Principle.** Depend in the direction of stability.
|
||||
- **SAP — Stable Abstractions Principle.** Stable components should be abstract; volatile components should be concrete.
|
||||
|
||||
---
|
||||
|
||||
## Applying This in Practice
|
||||
|
||||
- **"NO DB" and "NO Web" are valid starting positions.** Business rules should be expressible, testable, and useful before either is chosen.
|
||||
- **Frameworks are tools, not partners.** Wrap them. Keep `import django` or `import axum::Router` out of the core. (Uncle Bob's 2014 "Framework Bound" is a full rant on this.)
|
||||
- **Not every project needs four full circles.** Small projects may collapse Entities and Use Cases into one layer. The Dependency Rule still applies whatever the count.
|
||||
- **The seams matter most.** Architecture lives at the boundaries between components. Defend them at every review — once they rot, replacing a dependency stops being a weekend task and becomes a six-month project.
|
||||
- **Dialog from *A Little Architecture* (2016).** An aspiring architect says they want to make decisions about databases, frameworks, and webservers. Uncle Bob's response: "Oh. Well, then you don't want to become a Software Architect after all." The architect's job is to make decisions that let you **defer** the irrelevant decisions.
|
||||
|
||||
---
|
||||
|
||||
## Architecture and Agility
|
||||
|
||||
From "The Scatology of Agile Architecture" (2009): Agile does *not* mean no up-front architecture. The myth that you evolve architecture from zero is, in Uncle Bob's words, "horse shit." Good teams do enough architecture up front to get the seams right, then let the details emerge inside those seams. See [craft.md](craft.md) for more on this.
|
||||
@@ -0,0 +1,135 @@
|
||||
# The Craftsmanship Ethic
|
||||
|
||||
When to load this reference: when the task raises questions of professional judgment — estimation, deadline pressure, sloppy code accumulating, pairing, saying no to bad requests, or when the user invokes "technical debt" or "mess" or "craftsmanship."
|
||||
|
||||
The behaviors in *Clean Code* and *Clean Architecture* are not ends in themselves. They are instrumental to a larger ethic that Uncle Bob has been refining since the early 2000s: the software craftsmanship movement, which evolved into the Programmer's Oath (see [oath.md](oath.md)) and the 2022 book *Clean Craftsmanship*. This reference captures the non-code parts of that ethic that still materially affect how Claude should behave when writing or reviewing code.
|
||||
|
||||
---
|
||||
|
||||
## Clean Code Is a Practice, Not a Destination
|
||||
|
||||
From many posts, consolidated:
|
||||
|
||||
- Every function is an opportunity to practice. You don't reach "clean" and stop.
|
||||
- The **Boy Scout Rule** is the daily discipline: leave each module cleaner than you found it, even if just by renaming one variable.
|
||||
- "The only way to go fast is to go well." Dirty code does not trade speed for quality; it trades illusory short-term speed for enormous long-term slowness. This is the Productivity Roller-Coaster: feel fast for weeks, slow to a crawl over months.
|
||||
- From *Going Fast*: "Fast" is a property you get by being disciplined, not by skipping discipline.
|
||||
- From *Speed Kills*: conversely, the illusion that you can get fast by cutting corners almost always kills a project.
|
||||
|
||||
---
|
||||
|
||||
## A Mess Is Not Technical Debt
|
||||
|
||||
**This distinction matters.** People conflate them, and the conflation is a way to make sloppiness sound respectable.
|
||||
|
||||
**Ward Cunningham's Technical Debt (the original, 1992):** a **deliberate, considered** engineering trade-off when a schedule or learning situation justifies using a suboptimal design temporarily. You know what the right design is; you are choosing the wrong one now, *with intent*, and you will fix it later. Example: initial website uses server-rendered pages because there's no time to build an Ajax framework.
|
||||
|
||||
**A Mess:** bad code written by someone who did not do the work to understand the problem, did not refactor, did not test, did not think. It is not "debt" because it was never a considered choice — it is just poor craftsmanship.
|
||||
|
||||
From "A Mess is not a Technical Debt" (2009): calling a mess "technical debt" launders bad craftsmanship as if it were responsible engineering. It is not. When refusing to ship a mess, do not accept the framing that "we're just taking on some debt." Debt is deliberate; a mess is sloppy.
|
||||
|
||||
**Fowler's four quadrants of debt** (prudent/imprudent × deliberate/inadvertent) are a better map:
|
||||
- Deliberate+prudent: the original Cunningham case ("we must ship now, we'll fix X next sprint").
|
||||
- Deliberate+imprudent: "we don't have time for design" (toxic, not actually debt).
|
||||
- Inadvertent+prudent: "now I know how we should have done it" (honest learning).
|
||||
- Inadvertent+imprudent: plain-old-mess masquerading as debt.
|
||||
|
||||
---
|
||||
|
||||
## Saying No
|
||||
|
||||
From "Saying No!" (2009) and elaborated in *The Clean Coder*: professionals have an obligation to refuse impossible or unethical demands.
|
||||
|
||||
- When a manager asks for something that cannot be done correctly in the time allowed, the professional answer is "no, but here's what I can do," not "yes" followed by silent quality compromise.
|
||||
- "Yes and then failing to deliver" is worse than "no" — the manager loses the ability to plan around reality.
|
||||
- Professionals push back on their own estimates. If pressure makes you shorten a number you believe, you have stopped being the expert the organization pays you to be.
|
||||
|
||||
Applied to Claude: when a user asks for something that cannot be done well under the stated constraints (skip the tests, skip the error handling, ship something that will crash), the right response includes the pushback. Offer what you *can* deliver cleanly, not a degraded version of what was asked for.
|
||||
|
||||
---
|
||||
|
||||
## Honest Estimates
|
||||
|
||||
From "Why is Estimating so Hard?" (2012) and related posts:
|
||||
|
||||
- Estimates are **probability distributions, not numbers.** Give a range: optimistic, nominal, pessimistic. Three-point estimates are honest; single-point estimates almost always compress uncertainty.
|
||||
- "I don't know yet, let me do a spike" is a professional answer. "I'll have it by Friday" said under duress without real confidence is not.
|
||||
- An estimate is not a commitment; commitments come from negotiating after estimates are honestly given.
|
||||
|
||||
---
|
||||
|
||||
## On Documentation
|
||||
|
||||
**Martin's First Law of Documentation** (from *Agile Software Development: PPP*): "Produce no document unless its need is immediate and significant."
|
||||
|
||||
This is often misread as "Agile means no documentation." It does not. From the butunclebob.com wiki:
|
||||
|
||||
> "Agile Development is NOT development without documentation. Rejecting documentation in the name of 'Agility' is a flawed religious behavior. It is just as flawed as uncritically accepting the production of dozens of different documents."
|
||||
|
||||
Documentation, like any engineering activity, is prioritized by ROI. Create documents that more than pay back the effort to produce them. Skip documents written because policy requires them but no one will read them.
|
||||
|
||||
What counts as documentation:
|
||||
- API docs (rustdoc, TSDoc, javadoc) — high value, close to code.
|
||||
- Architecture decision records (ADRs) — capture *why* decisions were made.
|
||||
- Onboarding / how-to guides — pay back every time a new person joins.
|
||||
- Specs for important flows — pay back every time a flow breaks.
|
||||
|
||||
What does not:
|
||||
- Status reports that recapitulate information already in the tracker.
|
||||
- Design documents written after implementation that no one will read.
|
||||
- Comments that restate the code.
|
||||
|
||||
---
|
||||
|
||||
## Pairing Guidelines
|
||||
|
||||
From "Pairing Guidelines" (2021) and earlier posts:
|
||||
|
||||
- Pairing is a **tool**, not a religion. Use it when it works; don't when it doesn't.
|
||||
- Mature agile teams pair maybe 50–70% of the time, not 100%.
|
||||
- Some problems require "time, focus, and silence" to study before attacking. Pairing on those is worse than solo.
|
||||
- Pair at the start of a story to align direction; solo for deep-focus passages; reunite to review.
|
||||
- The strategy "separate the syntax issues from the semantic issues" is a useful pattern when stuck as a pair — refactor the mechanical noise (parsing, config, regex) into a helper module so the core algorithm can be reasoned about on its own.
|
||||
|
||||
---
|
||||
|
||||
## Shipping Under Pressure
|
||||
|
||||
From "AgilePeopleStillDontGetIt" (2006) and "We must ship now and deal with consequences" (2009):
|
||||
|
||||
- "It is completely unacceptable to release code that you aren't sure works. Either make sure it works, or don't ship it. Period."
|
||||
- "A feature that crashes is much worse than a feature that doesn't exist. A feature that doesn't exist will defer revenue. A feature that crashes makes enemies out of customers."
|
||||
- "Our customers interpret features as promises. When we release a feature we are promising that it works. When it crashes we have broken that promise."
|
||||
- "Shipping untested software is shipping something unfinished and your customers will force you to finish it. The pressure will be higher at orders of magnitude if you finish it AFTER you have shipped it."
|
||||
|
||||
Applied to Claude: when asked to ship quickly and drop tests, the honest response is that the tests aren't slowing you down; they are the only way to ship correctly. "Going fast" without tests produces code that will return tenfold in debugging and firefighting over the next weeks.
|
||||
|
||||
---
|
||||
|
||||
## Professionalism Is Not Rigid Formalism
|
||||
|
||||
From "Why the sea is boiling hot" (2009) — the closing statement of Uncle Bob's 2009 Rails Conf keynote:
|
||||
|
||||
> "Professionalism does not mean rigid formalism. Professionalism does not mean adhering to bureaucracy. Professionalism is **honor**. Professionalism is being honest with yourself and disciplined in the way you work. Professionalism is not letting fear take over."
|
||||
|
||||
Honor and discipline. Not process for its own sake. The rules in this skill are tools for being disciplined; they are not a rulebook to hide behind.
|
||||
|
||||
---
|
||||
|
||||
## The Tricky Bit
|
||||
|
||||
From "The Tricky Bit" (2010): a British MP flew the Concorde and complained to the designer that going supersonic "didn't feel any different at all." The designer beamed: "Yes, that was the tricky bit."
|
||||
|
||||
Clean code, good architecture, solid tests — when they are working, the reader doesn't notice. The absence of friction is the product. Code that *announces* how clever it is, how much architecture it has, how sophisticated its patterns are, is usually the opposite of clean. The goal is invisibility — the reader moves through the code and feels nothing but understanding.
|
||||
|
||||
---
|
||||
|
||||
## When Claude Should Invoke Any of This
|
||||
|
||||
- **User wants to skip tests "just this once":** reference the "A Mess is not Debt" framing and the shipping-under-pressure material.
|
||||
- **User wants a speculative number instead of a range:** offer a range and explain why.
|
||||
- **User wants you to document something they won't read:** suggest the minimum viable doc that pays its way.
|
||||
- **User wants a "quick fix" that you can see will rot the module:** explain the Boy Scout Rule cost — a quick fix that makes the code worse is a negative-value change even at zero time cost.
|
||||
- **User says "we're doing Agile, we don't write documentation":** redirect to Martin's First Law and the "it's about ROI" framing.
|
||||
|
||||
The oath ([oath.md](oath.md)) captures the promises. This file captures the attitude and the vocabulary for navigating the hard conversations where craft meets pressure.
|
||||
@@ -0,0 +1,81 @@
|
||||
# The Programmer's Oath
|
||||
|
||||
When to load this reference: when the task raises a question of professional responsibility — shipping under pressure with known defects, accumulating "temporary" hacks, padding estimates, degrading code to hit a deadline, or pushing back on a manager who is asking for the impossible.
|
||||
|
||||
In 2015, Uncle Bob proposed an oath for programmers, modeled loosely on the Hippocratic Oath, that captures the professional commitments behind Clean Code, Clean Architecture, and Agile practice. The oath exists because software increasingly runs civilization — cars, medical devices, infrastructure, money — and the people who write that software carry a corresponding weight of responsibility. His 2019 post "737 Max 8" is the most concrete illustration of why this matters: what happens when mission-critical software ships without the oath.
|
||||
|
||||
---
|
||||
|
||||
## The Oath
|
||||
|
||||
*In order to defend and preserve the honor of the profession of computer programmers, I promise that, to the best of my ability and judgement:*
|
||||
|
||||
1. **I will not produce harmful code.** Not code known to be defective. Not code that degrades the product. Not code that lies.
|
||||
2. **The code I produce will always be my best work.** I will not knowingly allow defective behavior or defective structure to accumulate.
|
||||
3. **I will produce, with each release, a quick, sure, and repeatable proof that every element of the code works as it should.** Automated tests that run fast and tell the truth.
|
||||
4. **I will make frequent, small releases** so that I do not impede the progress of others.
|
||||
5. **I will fearlessly and relentlessly improve my creations at every opportunity.** I will never degrade them. Each commit leaves the system at least as clean as I found it — preferably cleaner.
|
||||
6. **I will do all that I can to keep the productivity of myself and others as high as possible.** I will do nothing that decreases that productivity. This is why clean code matters: dirty code taxes every future developer who reads it.
|
||||
7. **I will continuously ensure that others can cover for me, and that I can cover for them.** No knowledge silos. No indispensable person. Shared ownership of the codebase.
|
||||
8. **I will produce estimates that are honest** both in magnitude and precision. **I will not make promises without certainty.** "I don't know yet" is a professional answer. Padding to please is not.
|
||||
9. **I will never stop learning and improving my craft.** Programming is a practice, not a credential.
|
||||
|
||||
---
|
||||
|
||||
## How This Oath Informs the Skill
|
||||
|
||||
The oath is not abstract ethics — it is directly wired into the practices that Clean Code, Clean Architecture, and TDD embody.
|
||||
|
||||
- **"Not produce harmful code"** → No flag arguments hiding branches, no null returns that hide failures, no functions with secret side effects. Clean Code, Chapter 7 (Error Handling). Mission-critical relevance: the 737 MAX is what happens when software that can kill people is built without the oath.
|
||||
- **"Best work … will not allow defective structure to accumulate"** → the Boy Scout Rule. A mess is not technical debt (see [craft.md](craft.md)).
|
||||
- **"Quick, sure, repeatable proof"** → TDD and F.I.R.S.T. tests. See [tdd.md](tdd.md).
|
||||
- **"Frequent small releases"** → continuous integration and the Agile practices. If a release takes a day, releases are infrequent by economic necessity; fix the release process, not the schedule.
|
||||
- **"Fearlessly improve"** → only possible with a test net. Without tests, every change is a gamble, so code rots by default. *Legacy code is code without tests* (Feathers).
|
||||
- **"Keep productivity high"** → the whole argument for clean code. Dirty code slows the team down. "We'll clean it up later" is almost always a lie.
|
||||
- **"Others can cover for me"** → pairing, code review, shared ownership, honest naming. See [craft.md](craft.md). If only one person understands the billing module, the billing module is a liability.
|
||||
- **"Honest estimates"** → ranges, not points. Three-point estimates (optimistic, nominal, pessimistic). "I cannot know yet, I will know more after the spike" is honest; a number pulled from the air to calm a manager is not.
|
||||
- **"Never stop learning"** → the craftsmanship attitude. Every function is practice.
|
||||
|
||||
---
|
||||
|
||||
## The Underlying Argument: Professionalism Is Honor
|
||||
|
||||
From Uncle Bob's 2009 Rails Conf keynote "Why the sea is boiling hot":
|
||||
|
||||
> "Professionalism does not mean rigid formalism. Professionalism does not mean adhering to bureaucracy. Professionalism is honor. Professionalism is being honest with yourself and disciplined in the way you work. Professionalism is not letting fear take over."
|
||||
|
||||
The oath is not a rulebook; it is a set of promises about the kind of engineer you intend to be. When pressure mounts and shortcuts beckon, those promises are what keep the work honest.
|
||||
|
||||
---
|
||||
|
||||
## The 737 MAX Argument
|
||||
|
||||
From "737 Max 8" (2019). Software increasingly operates in domains where failure kills. Cars, medical devices, aircraft, infrastructure control systems. The argument Uncle Bob makes:
|
||||
|
||||
- Our industry still doesn't act like a profession. There are no widely-enforced standards for competence. Anyone can ship anything.
|
||||
- When failures in a civilian product only mean frustrated customers, this is tolerable. When failures mean dead people, it is not.
|
||||
- Either the industry disciplines itself, or governments will do it for us — and government-imposed discipline will be crude and bureaucratic compared to what we could choose.
|
||||
- The oath is a voluntary first step.
|
||||
|
||||
For Claude-written code, this usually doesn't feel immediate. But the oath's standards apply to any code that runs. A user asking Claude to "just ship it, we'll fix bugs later" in a payment system is asking Claude to contribute to harm the user probably hasn't imagined.
|
||||
|
||||
---
|
||||
|
||||
## When to Cite This in a Session
|
||||
|
||||
- **User is asking Claude to skip tests "just this once."** Clean code practice says no — the "just this once" mindset is how codebases accumulate the slow-rotting debt the oath forbids.
|
||||
- **User is asking Claude to estimate something the code cannot yet answer.** Honest ranges beat false precision. "I do not know, let me find out" is honest.
|
||||
- **User is under deadline pressure and proposes shipping code with known defects.** The oath is unambiguous: unknown defects happen to everyone; knowingly shipped ones are a professional failure.
|
||||
- **User asks Claude to produce code that lies** — silently swallows errors, hides side effects, misrepresents state. Decline and explain why.
|
||||
- **A proposed change would make future developers' lives worse** for a short-term gain. Promise 6 of the oath is explicitly about not decreasing others' productivity.
|
||||
- **User is shipping to a safety-critical domain** and cutting corners. Name the risk explicitly; the user may not have considered it.
|
||||
|
||||
The oath is not a stick to beat users with. It is a reminder of the stakes that clean practice is trying to address, and a useful touchstone when a decision has no obvious technical answer. Lead with helpfulness; invoke the oath when helpfulness would mean producing work Claude should not be producing.
|
||||
|
||||
---
|
||||
|
||||
## Related Reading
|
||||
|
||||
- [craft.md](craft.md) — the surrounding ethic in more detail: mess vs. debt, saying no, estimation, pairing, the "tricky bit."
|
||||
- Uncle Bob's *The Clean Coder* (2011) — the book-length treatment of professional ethics in software. Unlike *Clean Code*, it is about behavior rather than technique.
|
||||
- Uncle Bob's *Clean Craftsmanship* (2021) — the discipline-focused sequel to *Clean Code*, integrating TDD, refactoring, simple design, and the oath.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Programming Paradigms
|
||||
|
||||
When to load this reference: when choosing between procedural and OO style, writing code in a functional language, refactoring switch statements, handling persistence, or when the user asks about OO vs FP, design patterns, or Clean Code's chapter on objects and data structures.
|
||||
|
||||
Uncle Bob's reductionist framing of the three paradigms is a powerful lens for reasoning about code shape. Each paradigm imposes **discipline** by **taking something away** from the programmer.
|
||||
|
||||
---
|
||||
|
||||
## The Three Paradigms
|
||||
|
||||
Each paradigm is defined by what it *forbids*, not by what it enables. This is Dijkstra-style reasoning: fewer primitives mean fewer ways to be wrong.
|
||||
|
||||
### Structured Programming
|
||||
|
||||
- **Forbids:** `goto` (direct transfer of control).
|
||||
- **Provides:** Sequence, Selection (if/else), Iteration (while). Dijkstra proved any algorithm can be expressed with just these three.
|
||||
- **Why:** Dijkstra's 1968 letter "Go To Statement Considered Harmful." Unrestricted `goto` makes programs impossible to reason about. Restricted control flow is provably correct for sequence, selection, iteration; not provably correct with arbitrary `goto`.
|
||||
- **Status today:** Won so completely that most developers don't even realize they're using it. Modern languages don't have `goto` (or discourage it).
|
||||
|
||||
### Object-Oriented Programming
|
||||
|
||||
- **Forbids:** Raw function pointers / indirect transfer of control through unmanaged pointers.
|
||||
- **Provides:** Polymorphism. The language manages the function pointers for you.
|
||||
- **Why:** Raw function pointers (as in C) are correct but fragile — every caller must follow conventions every time. Polymorphism provides the same runtime capability through a disciplined mechanism: objects carry their own dispatch table, set up once when the object is created.
|
||||
- **The reductionist core:** OO = polymorphism. Encapsulation, methods-bound-to-data, and simple inheritance exist in C and Pascal too. **What OO uniquely gives you is convenient polymorphism.** "OO without polymorphism is not OO."
|
||||
|
||||
### Functional Programming
|
||||
|
||||
- **Forbids:** Assignment / mutation of state.
|
||||
- **Provides:** Referential transparency. Same inputs → same outputs, always, everywhere.
|
||||
- **Why:** Shared mutable state is the source of most concurrency bugs and most "action at a distance" reasoning failures. Forbidding it means state changes are explicit and localized.
|
||||
- **The reductionist core:** FP = referential transparency. Higher-order functions exist in OO languages too (Smalltalk, etc.). What FP uniquely gives you is the guarantee that a function call cannot change anything you didn't pass to it.
|
||||
|
||||
### Why "Three Paradigms" Matters
|
||||
|
||||
These are **orthogonal**, not competing. Each removes a different freedom:
|
||||
|
||||
| Paradigm | Discipline on | Mechanism |
|
||||
|---|---|---|
|
||||
| Structured | Direct transfer of control | No `goto` |
|
||||
| OO | Indirect transfer of control | Polymorphism |
|
||||
| FP | Assignment | Referential transparency |
|
||||
|
||||
A language can (and modern ones often do) impose all three disciplines at once. You can write OO code functionally, and you can apply SOLID inside a functional program.
|
||||
|
||||
---
|
||||
|
||||
## OO and FP Are Orthogonal, Not Exclusive
|
||||
|
||||
From Uncle Bob's 2014 and 2018 "FP vs OO" posts:
|
||||
|
||||
> "The principles of software design still apply, regardless of your programming style. The fact that you've decided to use a language that doesn't have an assignment operator does not mean that you can ignore the Single Responsibility Principle; or that the Open Closed Principle is somehow automatic."
|
||||
|
||||
And from his 2023 *Functional Classes* post: "Should you subdivide a functional program into classes the way you would an object oriented program? Yes. You should. Because the rules don't change just because you've chosen to use immutable data structures."
|
||||
|
||||
**A class, reductively:** "A group of cohesive and narrowly defined functions that operate on an encapsulated data structure. The functions may, or may not, be polymorphically deployed." This definition works in Clojure, Haskell, Rust, Java, TypeScript, Python.
|
||||
|
||||
**The design principles transcend paradigm:**
|
||||
- SRP applies in Clojure (group functions by actor).
|
||||
- OCP applies in Haskell (use abstraction, add type class instances).
|
||||
- DIP applies anywhere there are modules.
|
||||
- A "class" in the sense above is a cohesive namespace of related functions plus the data they operate on.
|
||||
|
||||
---
|
||||
|
||||
## Data/Object Anti-Symmetry
|
||||
|
||||
From Chapter 6 of *Clean Code* and elaborated in the 2019 blog post "Classes vs. Data Structures."
|
||||
|
||||
**Two definitions that complement each other:**
|
||||
|
||||
- **Object:** A set of functions that operate on **implied** data. Data exists but is hidden. Callers see only functions.
|
||||
- **Data structure:** A set of data elements operated on by **implied** functions. Data is exposed. Functions exist but are not specified by the structure.
|
||||
|
||||
They are **diametric opposites**. You cannot fully be both.
|
||||
|
||||
### Consequences
|
||||
|
||||
- **DTOs are data structures, not objects.**
|
||||
- **Database tables are data structures, not objects.**
|
||||
- **"ORM" is a misnomer.** There is no mapping between database tables and objects. ORMs map tables to data structures. (This is not pedantic; it explains why ORMs have the smells they do.)
|
||||
- **Polymorphism is the marker of objects.** When `shape.area()` dispatches dynamically to the Circle or Square implementation, you are doing OO. When `area(shape)` is a free function with `match shape { Circle => …, Square => … }`, you are doing procedural work.
|
||||
|
||||
### The Four Symmetry Rules
|
||||
|
||||
These tell you when to choose each style.
|
||||
|
||||
| | Add new FUNCTION | Add new TYPE |
|
||||
|---|---|---|
|
||||
| **Classes (OO)** | **Hard** — change every class | **Easy** — add one class |
|
||||
| **Data structures (procedural)** | **Easy** — add one function | **Hard** — change every function |
|
||||
|
||||
**Choose by expected axis of change:**
|
||||
|
||||
- If you expect more new functions than new types → procedural style with data structures + functions (e.g., visitor pattern, pattern matching over enums, Clojure-style).
|
||||
- If you expect more new types than new functions → OO style with classes and polymorphism.
|
||||
- The **Visitor pattern** is procedural-style behavior over OO data — it bridges the two.
|
||||
|
||||
**In Rust specifically:** enums with `match` are procedural by this taxonomy (add a variant → every match must handle it); traits with implementations are OO (add an impl → no existing code changes). Neither is wrong; choose by axis of change. If new variants are rare and new operations are common, the enum wins. If new types are common, the trait wins.
|
||||
|
||||
---
|
||||
|
||||
## Polymorphism and if-else-switch
|
||||
|
||||
From "if-else-switch" (2021). A very common refactor:
|
||||
|
||||
**The pattern.** When you see an if/else chain or switch that branches by type or by "kind," replace it with:
|
||||
|
||||
1. A base class or interface with one method per case.
|
||||
2. Concrete implementations, one per branch.
|
||||
3. A **factory** that creates the right implementation based on the discriminator (this is where the if/else/switch ends up, condensed into one place).
|
||||
4. The business logic calls the interface, never the discriminator.
|
||||
|
||||
**Runtime characteristics are identical.** If/else does a procedural lookup, switch uses a compiler-built jump table, polymorphic dispatch uses a vtable — similar performance.
|
||||
|
||||
**What you gain:**
|
||||
- The high-level business code no longer transitively depends on every low-level case.
|
||||
- Each case is its own named method, not an indented block within a branch.
|
||||
- New cases = new classes (OCP).
|
||||
- Independent deployment becomes possible: the high-level module and each implementation can live in separate components.
|
||||
|
||||
**When not to apply:** if the switch is small, stable, and not type-based (e.g., processing a small enum of flags in one place), leaving it as a switch is fine. The rule is "factor out switches on *type*," not "destroy every conditional."
|
||||
|
||||
---
|
||||
|
||||
## The Tell-Don't-Ask Style
|
||||
|
||||
Alan Kay's original OO conception: objects as cells in a biological system.
|
||||
|
||||
> "Neurons are tellers, not askers. Hormones are tellers, not askers. In biological systems, communication was half-duplex."
|
||||
|
||||
Instead of:
|
||||
```
|
||||
if account.getBalance() < amount:
|
||||
throw InsufficientFunds
|
||||
account.setBalance(account.getBalance() - amount)
|
||||
```
|
||||
|
||||
Say:
|
||||
```
|
||||
account.withdraw(amount) // account decides if it can, and how
|
||||
```
|
||||
|
||||
The caller stops interrogating state and deciding. The object owns the decision. This is what Law of Demeter is a weak shadow of — the deeper principle is that state should not leak out of objects.
|
||||
|
||||
---
|
||||
|
||||
## Loops and State Machines
|
||||
|
||||
From the 2020 "Loopy" post. Any program with nested loops can be refactored step-by-step into a Turing-style finite state machine, with tests passing at every step. This is a useful mental exercise: a nested loop is a state machine that a programmer wrote too compactly.
|
||||
|
||||
Practical takeaway: when a loop body is getting complex, consider extracting an explicit state (enum of states) and transitioning between them. Reads better than four nested `if`s; generalizes better; easier to test.
|
||||
|
||||
---
|
||||
|
||||
## Applying This in Practice
|
||||
|
||||
- **Default to OO + polymorphism** for business logic where types vary (entities, strategies, handlers). Polymorphism is the mechanism behind DIP, OCP, and Clean Architecture boundaries.
|
||||
- **Default to data structures + free functions** for values, messages, and records that flow through the system. DTOs, events, API payloads, DB rows.
|
||||
- **Keep the two species apart.** A "hybrid" that has both public fields and rich behavior usually gets the worst of both worlds.
|
||||
- **FP is not an exception to SOLID.** Cohesion, SRP, DIP all still apply; you express them with namespaces, protocols, or type classes instead of classes.
|
||||
@@ -0,0 +1,138 @@
|
||||
# SOLID Principles
|
||||
|
||||
The five class- and module-level principles that make a codebase flexible, testable, and resistant to rot. Load when designing a new class, module, or microservice; when refactoring for flexibility; or when reviewing dependencies between units.
|
||||
|
||||
Uncle Bob reaffirmed in 2020 that these principles remain as relevant as they were in the 1990s, and that microservices and dynamic typing do *not* make them obsolete. The shape of software — sequence, selection, iteration — has not fundamentally changed since the first stored-program computer, and neither has the shape of good design.
|
||||
|
||||
The "SOLID" acronym was popularized by Uncle Bob, but most of the individual principles predate him. Understanding the roots helps you reason about them when they seem to conflict.
|
||||
|
||||
---
|
||||
|
||||
## SRP — Single Responsibility Principle
|
||||
|
||||
**Roots.** David L. Parnas, "On the Criteria To Be Used in Decomposing Systems into Modules" (CACM 15:12, December 1972): "begin decomposition with a list of difficult design decisions or design decisions *which are likely to change*. Each module is designed to hide such a decision from the others." Edsger Dijkstra's 1974 paper "On the role of scientific thought" coined **Separation of Concerns**. Larry Constantine, Tom DeMarco, and Meilir Page-Jones formalized **cohesion** as "functional relatedness" through the 1970s and 1980s. Uncle Bob consolidated these into "SRP" in the late 1990s (he suspects he borrowed the name from Bertrand Meyer).
|
||||
|
||||
**The definition has evolved three times:**
|
||||
|
||||
1. *Early:* "A module should do one thing, do it well, and do it only."
|
||||
2. *Clean Code era (2008):* "A class should have one, and only one, reason to change."
|
||||
3. *Clean Architecture (2017):* "A module should be responsible to one, and only one, **actor**." An actor is a person or a tightly coupled group representing a single narrowly defined business function.
|
||||
|
||||
The actor formulation is the current canonical one. Uncle Bob gave this example (2014): "SRP is about people. When you write a software module, you want to make sure that when changes are requested, those changes can only originate from a single person, or rather, a single tightly coupled group of people representing a single narrowly defined business function. Why? Because we don't want to get the COO fired because we made a change requested by the CTO."
|
||||
|
||||
**What this means in practice:**
|
||||
- Business rules do not live in GUI code.
|
||||
- SQL queries do not live next to communication protocols.
|
||||
- A module modified because of a change in report format should not also be modified because of a change in tax law — those are different actors.
|
||||
- Microservices do not solve SRP. A tangled microservice is still tangled; a tangled set of microservices is worse than a tangled monolith because the tangles cross network boundaries.
|
||||
|
||||
**Smells that suggest SRP violation:**
|
||||
- The class keeps getting edited by different people for different reasons.
|
||||
- Changing one feature breaks tests for an unrelated feature.
|
||||
- The class name contains "and," "manager," "util," or "helper."
|
||||
|
||||
---
|
||||
|
||||
## OCP — Open-Closed Principle
|
||||
|
||||
**Roots.** Bertrand Meyer, *Object-Oriented Software Construction* (1988). Meyer's original formulation used **implementation inheritance**: "A class is closed, since it may be compiled, stored in a library, baselined, and used by client classes. But it is also open, since any new class may use it as parent, adding new features."
|
||||
|
||||
In the 1990s the principle was **reinterpreted polymorphically** (largely by Uncle Bob's writing): use abstracted interfaces with multiple implementations, not base-class inheritance. This is the dominant modern reading.
|
||||
|
||||
**Definition (modern):** "A module should be open for extension but closed for modification." Or: "You should be able to extend the behavior of a system without having to modify that system."
|
||||
|
||||
**In practice:**
|
||||
- Imagine writing a system where writing to disk, printer, screen, or network pipe were scattered as `if` cases throughout business logic. That is the OCP failure mode — and why operating systems invented device independence.
|
||||
- New payment methods plug in without modifying the checkout flow. New report formats plug in without modifying the report generator.
|
||||
- **Plugin architectures are the apotheosis of OCP.** Eclipse, IntelliJ, VS Code, Vim, Minecraft — all extend without modifying.
|
||||
|
||||
**Simple code is both open and closed.** Complexity in this dimension comes from *missing* abstractions, not extra ones. Do not over-abstract speculatively.
|
||||
|
||||
---
|
||||
|
||||
## LSP — Liskov Substitution Principle
|
||||
|
||||
**Roots.** Barbara Liskov's 1987 keynote "Data abstraction and hierarchy" at OOPSLA, later formalized with Jeannette Wing (1994). Liskov's own framing is mathematical: about subtypes that preserve the behavior their supertype's clients expect.
|
||||
|
||||
**A common misread:** LSP is about inheritance. It is not — it is about **subtyping**. Subtyping includes:
|
||||
- Interface implementations.
|
||||
- Trait/protocol implementations.
|
||||
- Duck types that satisfy an implicit interface.
|
||||
- Any context where "type B can be used where type A is expected."
|
||||
|
||||
**Canonical definition (Uncle Bob):** "A program that uses an interface must not be confused by an implementation of that interface."
|
||||
|
||||
**In practice:**
|
||||
- The classic Square-from-Rectangle example: Square cannot be substituted for Rectangle without surprising callers who expect to vary width and height independently.
|
||||
- Keep subtype contracts crisp. Document invariants, preconditions, postconditions (Meyer's Design by Contract from *OOSC*).
|
||||
- If a subtype needs to refuse operations the base type promised, the hierarchy is wrong.
|
||||
- An abstraction that leaks its concretions breaks LSP.
|
||||
|
||||
Michael Feathers noted (2006) that in dynamic languages LSP applies just as strongly — it is about substitutability, not inheritance. Duck typing gives you substitutability; LSP is what ensures substituted objects behave sensibly.
|
||||
|
||||
---
|
||||
|
||||
## ISP — Interface Segregation Principle
|
||||
|
||||
**Definition:** "Keep interfaces small so that clients don't end up depending on things they don't need."
|
||||
|
||||
ISP matters most where compile-time or link-time coupling exists — which is still most of the industry. In statically typed languages (Rust, Java, Go, C#, C++, Swift, TypeScript with strict mode), when module A depends on module B at compile time but only uses one method, a change to an unrelated method in B still triggers recompilation and redeployment of A.
|
||||
|
||||
Dynamically typed languages are not immune — package managers (npm, Maven, Cargo, pip) impose coupling through version resolution.
|
||||
|
||||
**In practice:**
|
||||
- Prefer many small, role-focused interfaces over one fat interface.
|
||||
- Split a class with two unrelated interfaces into two classes (often aligns with SRP).
|
||||
- In Rust, favor small focused traits over giant trait blobs.
|
||||
|
||||
---
|
||||
|
||||
## DIP — Dependency Inversion Principle
|
||||
|
||||
**Definition:** "Depend in the direction of abstraction. High-level modules should not depend on low-level details; both should depend on abstractions."
|
||||
|
||||
This is the single most important architectural principle. Computations that produce business value must not depend on:
|
||||
- SQL dialects.
|
||||
- HTTP framework types.
|
||||
- File formats.
|
||||
- UI widget libraries.
|
||||
- Vendor SDKs.
|
||||
|
||||
**The mechanic (from "OO vs FP", 2014):** In most software systems when one function calls another, the runtime dependency and the source-code dependency point the same direction. When polymorphism is injected between them, an **inversion of the source-code dependency** occurs. The calling module still depends on the called module at runtime, but the source of the calling module depends only on a polymorphic interface — not on the source of the called module. The called module becomes a plugin.
|
||||
|
||||
**In practice:**
|
||||
- Define interfaces in terms of what the domain needs, not what the infrastructure provides.
|
||||
- Place those interfaces in the high-level module; place implementations in the low-level module.
|
||||
- Wire them together in a single composition root (the Main component).
|
||||
- "To be robust, a system must employ polymorphism across significant architectural boundaries."
|
||||
|
||||
DIP is the mechanism that makes Clean Architecture's Dependency Rule enforceable. See [architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Component Principles
|
||||
|
||||
Once modules are organized, they group into **components** — independently deployable units (libraries, services, jars, crates). Two sets of principles govern them.
|
||||
|
||||
### Component Cohesion — what belongs together
|
||||
|
||||
- **REP — Reuse/Release Equivalence Principle.** The unit of reuse is the unit of release. Things reused together must be released together, with version numbers.
|
||||
- **CCP — Common Closure Principle.** Group together classes that change for the same reasons at the same times. (SRP at component scale.)
|
||||
- **CRP — Common Reuse Principle.** Classes that are used together belong together; classes that are not used together do not belong together. (ISP at component scale.)
|
||||
|
||||
**The tension diagram.** These three principles pull in different directions — REP and CCP tend to include more; CRP tends to exclude. Designing components is an ongoing balance within the triangle they form. Where you place a component in this triangle depends on maturity: early-stage components lean toward REP+CCP (include-more); mature, widely-reused components shift toward CRP (exclude).
|
||||
|
||||
### Component Coupling — how they relate
|
||||
|
||||
- **ADP — Acyclic Dependencies Principle.** The dependency graph among components must have no cycles. Break cycles with DIP or by extracting a new component both sides depend on.
|
||||
- **SDP — Stable Dependencies Principle.** Depend in the direction of stability. Volatile components may depend on stable ones, never the reverse.
|
||||
- **SAP — Stable Abstractions Principle.** Stable components should be abstract, so they can be extended. Volatile components should be concrete. Corollary: depend on stable abstractions.
|
||||
|
||||
---
|
||||
|
||||
## Applying SOLID in Practice
|
||||
|
||||
- **Do not apply all five at once on day one.** Let the code tell you which principle is being violated. Pain surfaces one at a time.
|
||||
- **Duplication and rigidity are the strongest signals.** If you cannot change one thing without changing ten, some SOLID principle is being violated — usually SRP or DIP.
|
||||
- **Beware of over-abstraction.** SOLID is about *managing* dependencies, not maximizing interfaces. A speculative interface with one implementation is YAGNI until a second implementation appears or tests demand it.
|
||||
- **Uncle Bob's synthesis (2020):** Simple code is both open and closed. Simple code maintains crisp subtype relationships. Simple code depends on abstractions. The principles describe what simple code looks like when it survives contact with change.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Test Driven Development
|
||||
|
||||
When to load this reference: when writing new tests, reviewing tests, debugging brittle tests, dealing with legacy code that resists testing, or deciding on a testing strategy for a module.
|
||||
|
||||
Tests are the safety net that makes fearless refactoring possible. Without that net, every change is a gamble; with it, every change can be confident. Tests are also the most precise, executable documentation a system will ever have.
|
||||
|
||||
**Michael Feathers's definition of legacy code:** *Legacy code is code without tests.* Uncle Bob adopted this definition and it underpins the TDD practice.
|
||||
|
||||
---
|
||||
|
||||
## The Three Laws of TDD
|
||||
|
||||
1. **You are not allowed to write any production code unless it is to make a failing unit test pass.**
|
||||
2. **You are not allowed to write any more of a unit test than is sufficient to fail — and compilation failures are failures.**
|
||||
3. **You are not allowed to write any more production code than is sufficient to pass the one failing unit test.**
|
||||
|
||||
The loop is measured in seconds, not minutes. Write a line or two of test, see it fail, write a line or two of production, see it pass, repeat. This is the **nano-cycle**.
|
||||
|
||||
**Why these rules:**
|
||||
|
||||
- **Debugging time plummets** — you were never more than 60 seconds away from working code.
|
||||
- **Tests are automatic documentation** that cannot fall out of sync with the system.
|
||||
- **Design improves** because code written to be testable is naturally decoupled.
|
||||
- **Refactoring becomes fearless** because the net catches regressions instantly.
|
||||
|
||||
This is double-entry bookkeeping for software. Every behavior is stated twice — once in the test, once in the code — and they must agree.
|
||||
|
||||
---
|
||||
|
||||
## F.I.R.S.T. — Clean Tests
|
||||
|
||||
Clean tests are:
|
||||
|
||||
- **Fast.** Slow tests will stop being run. If a suite takes 10 minutes, people will commit without running it. 15-minute CI feedback is too slow for the TDD loop.
|
||||
- **Independent.** No test depends on another. Any test can run alone, in any order.
|
||||
- **Repeatable.** Same result in every environment — laptop, CI, staging. If a test depends on the network, wall clock, or shared database, it is flaky and must be fixed.
|
||||
- **Self-validating.** Pass or fail. No manual inspection.
|
||||
- **Timely.** Written *just before* the production code they cover — not "when we have time."
|
||||
|
||||
Test code is first-class. Hold it to the same clarity bar as production code. When tests rot, production code rots.
|
||||
|
||||
---
|
||||
|
||||
## Canonical Test Definitions (First-Class Tests, 2017)
|
||||
|
||||
The industry has been sloppy about what "unit," "integration," "acceptance," etc. mean. Uncle Bob's proposed taxonomy:
|
||||
|
||||
- **Unit Test.** Written by a programmer, for a programmer. Ensures production code does what the programmer expected. Sometimes called **programmer test** or **micro-test**.
|
||||
- **Acceptance Test.** Written by the business (or a BA/QA representing the business). Ensures production code does what the business expects. Sometimes called **customer test**.
|
||||
- **Integration Test.** Written by architects or technical leads. Ensures a sub-assembly of system components operates correctly. **These are plumbing tests, not business-rule tests** — rules are already verified by unit and acceptance tests.
|
||||
- **System Test.** An integration test for the whole integrated system.
|
||||
- **Micro-test** (Mike Hill / @GeePawHill). A unit test at very small scope — tests a single function or small group.
|
||||
- **Functional Test.** A unit test at larger scope, with mocks for slow components.
|
||||
|
||||
> "Integration tests do not test business rules. Those rules have already been tested, once by programmer (unit) tests, and again by customer (acceptance) tests. Integration tests test the plumbing and choreography of the components." — Uncle Bob (Twitter, 2019)
|
||||
|
||||
**Implication for Claude when writing tests:** Know which kind of test you are writing and don't couple it to the wrong kind. If you're asked to "add tests" for a pure function, write unit/micro tests. If you're asked to "test the API works end-to-end," that's integration/system. Don't test business rules in an integration test — the rules should already have unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Test Structure
|
||||
|
||||
Use one of these structures; be consistent.
|
||||
|
||||
- **Arrange / Act / Assert** — set up context, perform action, check result.
|
||||
- **Given / When / Then** — same thing in BDD vocabulary.
|
||||
- **Build / Operate / Check** — same thing, different vocabulary.
|
||||
|
||||
One *concept* per test. Often one assertion, but "one concept" is the real rule — several assertions verifying the same behavior are fine.
|
||||
|
||||
### Test Naming
|
||||
|
||||
Name the test for what it verifies about behavior, not for the method. `returns_empty_list_when_given_empty_input` beats `test_filter_1`. If the name runs long, the test is probably doing more than one thing.
|
||||
|
||||
---
|
||||
|
||||
## Test Doubles — The Hierarchy
|
||||
|
||||
Adapted from Gerard Meszaros's *xUnit Patterns*, with Uncle Bob's gloss. Each is a degree of sophistication above the last.
|
||||
|
||||
- **Dummy.** Passed around but never used. Fills a parameter slot.
|
||||
- **Stub.** Returns canned answers. No logic.
|
||||
- **Spy.** A stub that records the calls it received.
|
||||
- **Mock.** A spy with expectations built in: set up *before* the act, verified *after*. Fails if expected interactions didn't happen.
|
||||
- **Fake.** A working implementation with production-unfit shortcuts — e.g., in-memory repo that stands in for a real database.
|
||||
|
||||
Pick the lowest-sophistication double that does the job. A mock where a stub would suffice adds coupling and fragility.
|
||||
|
||||
**Uncle Bob hand-rolls most of his Java mocks** ("Manual Mocking," 2009) rather than using mockito, to keep explicit control over ceremony. This is a taste preference, not a rule, but his reasoning (less magic, clearer test code) is worth knowing.
|
||||
|
||||
---
|
||||
|
||||
## Chicago vs. London (State-ism vs. Mockism)
|
||||
|
||||
Two schools of TDD.
|
||||
|
||||
- **Chicago / Classical / State-ist.** Test behavior through state. Exercise the object, assert on its final state (or collaborators' state). Minimal mocking. Less coupled to implementation detail.
|
||||
- **London / Mockist.** Test behavior through interactions. Mock collaborators; assert on calls. More explicit about collaboration but more coupled to it.
|
||||
|
||||
**Practical guidance:** Use Chicago for value objects, algorithms, internal logic. Use London at **boundaries** — where the code coordinates external collaborators. Never mock what you own when you could exercise it directly; mock (or fake) what you do not own when the real thing would make the test slow or flaky.
|
||||
|
||||
---
|
||||
|
||||
## Fragile Tests
|
||||
|
||||
Tests that break without a real regression are worse than no tests — they train developers to ignore the suite. Known causes:
|
||||
|
||||
- **Interface sensitivity.** Tests break because a signature changed, not behavior. Often a sign of excessive mocking.
|
||||
- **Behavior sensitivity.** Tests break because an unrelated behavior changed. A sign of poor isolation.
|
||||
- **Data sensitivity.** Tests break because shared fixtures changed. Fix by making tests own their data.
|
||||
- **Context sensitivity.** Tests pass locally, fail in CI. Remove environmental coupling: clock, network, filesystem, time zone.
|
||||
- **Over-specification.** Tests assert on more than the behavior under test — internal call order, private fields, log output. Assert on what the *user of the code* would observe.
|
||||
|
||||
A fragile test is a design signal — usually a missing abstraction, a leaky boundary, or an over-eager mock.
|
||||
|
||||
"Skilled TDDers understand that neither micro-tests, nor functional tests, nor acceptance tests should be coupled to the implementation of the system." — *First-Class Tests* (2017)
|
||||
|
||||
---
|
||||
|
||||
## As Tests Get More Specific, Code Gets More Generic
|
||||
|
||||
Uncle Bob's formulation (2009): tests are specifications. As you add tests, the specifications grow more specific. To satisfy them all, the production code must grow more *generic*. This is the inverse relationship that drives TDD-induced good design — the code gets pushed toward abstractions that cover many cases rather than one.
|
||||
|
||||
---
|
||||
|
||||
## The Transformation Priority Premise (TPP)
|
||||
|
||||
When making a failing test pass, there is a natural ordering of changes, simpler before more complex. Prefer earlier transformations when more than one would work:
|
||||
|
||||
1. `{} → nil` — no code → returning nil
|
||||
2. `nil → constant` — return a constant
|
||||
3. `constant → variable` — replace constant with a variable
|
||||
4. `statement → statements` — add another statement
|
||||
5. `unconditional → if` — introduce a branch
|
||||
6. `scalar → array` — move from a single value to a collection
|
||||
7. `array → container` — move to a richer collection type
|
||||
8. `statement → recursion` — replace a statement with recursion
|
||||
9. `if → while` — replace a branch with iteration
|
||||
10. `expression → function` — extract a function
|
||||
11. `variable → assignment` — introduce mutation
|
||||
|
||||
Using lower-priority transformations earlier creates needless complexity; using higher-priority ones later often indicates a design that could be simpler. TPP is a tiebreaker, not a law — but it usually guides tests toward algorithms that generalize cleanly.
|
||||
|
||||
---
|
||||
|
||||
## The Cycles of TDD
|
||||
|
||||
TDD operates at multiple time scales simultaneously. Working at only one scale produces bad software.
|
||||
|
||||
- **Seconds (Red-Green-Refactor).** The nano-cycle.
|
||||
- **Minutes (Specific-to-Generic).** Tests grow more specific; code grows more generic.
|
||||
- **Tens of minutes (Boundary).** Periodically step back and ask whether the module is still well-factored. Extract. Rename. Regroup.
|
||||
- **Hours (Architecture).** Once a day or so, step back further: are the component boundaries still correct? Does the Dependency Rule still hold?
|
||||
- **Days (Acceptance).** Acceptance tests (at the feature/use-case level) close the loop with the business.
|
||||
|
||||
Skipping the larger cycles is the most common failure mode. Red-Green-Refactor religiously, but never step back to reconsider architecture, and you end up with a suite of fine-grained tests wrapped around a tangled ball of mud.
|
||||
|
||||
---
|
||||
|
||||
## Testing Across Architectural Boundaries
|
||||
|
||||
- **The test boundary** is a first-class part of architecture. Tests live outside the system they test.
|
||||
- **Do not couple tests to UI frameworks or databases.** If a test needs a browser to exercise a use case, the boundary between use case and UI is broken.
|
||||
- **Legacy code strategy** (Feathers). Find a seam — a place where behavior can be varied without modifying code. Write a characterization test at that seam to pin down current behavior. Refactor behind the pin. Repeat.
|
||||
|
||||
Uncle Bob's position on test placement: "Don't test through UIs. Don't test through web servers. Test as close to the code as you can." — *Testing Like the TSA* (2017)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Writing tests after the fact.** Produces tests that confirm whatever the code happens to do, including the bugs. Much lower value than TDD.
|
||||
- **Slow test suites.** If any unit test takes more than a fraction of a second, isolate it. Keep the unit suite fast and run integration tests separately.
|
||||
- **Mocking what you own.** Prefer real objects for your own code.
|
||||
- **Testing implementation details.** Refactors then break tests without any real regression, and people conclude "TDD gets in the way of refactoring." It doesn't — the tests were just wrong.
|
||||
- **Skipping refactor.** Red-Green-… is not TDD. The third step is where design emerges.
|
||||
- **Over-coverage religion.** Uncle Bob's ratio for some project types: 20% test-first, 80% test-after is acceptable for controllers/models/views (per *Testing Like the TSA*, 2017). The three laws are guidance for the hottest logic in the system, not dogma for every trivial accessor.
|
||||
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: kana-rust-backend-best-practice
|
||||
description: Reference guide for building a Rust clean-architecture backend with Axum, SeaORM, Argon2, JWT, and sea-orm-migration. Use when scaffolding a new Rust service, adding a feature (domain + use-case + repository + handler), or reviewing Rust code against the axum-clean-architecture reference layout.
|
||||
---
|
||||
|
||||
# Axum Clean Architecture Skill
|
||||
|
||||
Reference stack (see `../axum-clean-architecture`):
|
||||
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| HTTP framework | Axum 0.8 |
|
||||
| ORM | SeaORM 1.1 (PostgreSQL via sqlx + rustls) |
|
||||
| Migrations | sea-orm-migration |
|
||||
| Auth | Argon2 (password hashing) + jsonwebtoken (JWT) |
|
||||
| Validation | zod-rs (schema-driven, mirrors Zod) |
|
||||
| Pagination | paginator-rs + paginator-sea-orm + paginator-axum |
|
||||
| Observability | tracing + tracing-subscriber |
|
||||
| Middleware | tower-http (CORS, TraceLayer) |
|
||||
| Runtime | Tokio (full features) |
|
||||
| Error handling | anyhow (app-level), typed domain errors |
|
||||
|
||||
---
|
||||
|
||||
## 0. Workspace layout
|
||||
|
||||
```
|
||||
axum-clean-architecture/
|
||||
├── Cargo.toml # workspace, resolver = "3"
|
||||
├── apps/
|
||||
│ ├── iam/ # core domain library (lib crate)
|
||||
│ │ └── src/
|
||||
│ │ ├── domain/ # entities, repository traits, domain errors
|
||||
│ │ ├── application/ # use cases + port traits
|
||||
│ │ ├── infrastructure/ # SeaORM repos + auth services
|
||||
│ │ └── presentation/ # Axum handlers, DTOs, middleware, state
|
||||
│ ├── gateway/ # binary — assembles router, runs server
|
||||
│ └── bootstrap/ # binary — seeds permissions/roles/admin
|
||||
├── .config/ # AppServer, database/env helpers
|
||||
└── .migrations/ # sea-orm-migration crate
|
||||
```
|
||||
|
||||
The `iam` app is a **library crate**. `gateway` and `bootstrap` depend on it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dependency rules (strictly enforced)
|
||||
|
||||
```
|
||||
presentation → application → domain
|
||||
infrastructure → domain (implements domain traits)
|
||||
presentation → infrastructure (only to wire AppState)
|
||||
```
|
||||
|
||||
- Domain has **zero** external crate dependencies beyond `uuid`, `chrono`.
|
||||
- Use cases depend only on port traits — never on concrete infrastructure types.
|
||||
- Presentation instantiates use cases from `AppState` on every request; use cases are not stored.
|
||||
|
||||
---
|
||||
|
||||
## 2. Domain layer
|
||||
|
||||
### Entity pattern
|
||||
|
||||
Plain Rust structs — no derives beyond what domain logic needs. No ORM annotations.
|
||||
|
||||
```rust
|
||||
// domain/user/entity.rs
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserPatch {
|
||||
pub email: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Repository trait pattern
|
||||
|
||||
Use `impl Future` in trait methods (Rust 2024 edition, no `async_trait` needed).
|
||||
Always `Send + Sync` on the trait.
|
||||
|
||||
```rust
|
||||
// domain/user/repository.rs
|
||||
pub trait UserRepository: Send + Sync {
|
||||
fn find_by_id(&self, id: Uuid)
|
||||
-> impl Future<Output = Result<Option<User>, RepositoryError>> + Send;
|
||||
fn find_by_email(&self, email: &str)
|
||||
-> impl Future<Output = Result<Option<User>, RepositoryError>> + Send;
|
||||
fn create(&self, user: NewUser)
|
||||
-> impl Future<Output = Result<User, RepositoryError>> + Send;
|
||||
fn update(&self, id: Uuid, patch: UserPatch)
|
||||
-> impl Future<Output = Result<User, RepositoryError>> + Send;
|
||||
fn delete(&self, id: Uuid)
|
||||
-> impl Future<Output = Result<(), RepositoryError>> + Send;
|
||||
fn list(&self, params: &PaginationParams)
|
||||
-> impl Future<Output = Result<PaginatorResponse<User>, RepositoryError>> + Send;
|
||||
}
|
||||
```
|
||||
|
||||
### Shared RepositoryError (lives in domain)
|
||||
|
||||
```rust
|
||||
pub enum RepositoryError {
|
||||
NotFound,
|
||||
Conflict(String),
|
||||
Database(String),
|
||||
}
|
||||
```
|
||||
|
||||
### Domain errors
|
||||
|
||||
Per-aggregate. `AuthError` lives in `domain/auth/errors.rs`:
|
||||
|
||||
```rust
|
||||
pub enum AuthError {
|
||||
InvalidCredentials,
|
||||
EmailAlreadyExists,
|
||||
UserNotFound,
|
||||
PasswordHashFailed(String),
|
||||
PasswordVerificationFailed(String),
|
||||
TokenGenerationFailed(String),
|
||||
InvalidToken(String),
|
||||
RepositoryError(String),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Application layer
|
||||
|
||||
### Port traits (interfaces for external services)
|
||||
|
||||
```rust
|
||||
// application/auth/ports/password.rs
|
||||
pub trait PasswordService: Send + Sync {
|
||||
fn hash(&self, password: &str)
|
||||
-> impl Future<Output = Result<String, PasswordError>> + Send;
|
||||
fn verify(&self, password: &str, hash: &str)
|
||||
-> impl Future<Output = Result<bool, PasswordError>> + Send;
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// application/auth/ports/token.rs
|
||||
pub trait TokenService: Send + Sync {
|
||||
fn generate_auth_tokens(&self, sub: &str)
|
||||
-> impl Future<Output = Result<(String, String), TokenError>> + Send;
|
||||
fn verify_access_token(&self, token: &str)
|
||||
-> Result<String, TokenError>;
|
||||
}
|
||||
```
|
||||
|
||||
### Use case pattern
|
||||
|
||||
Generic over port traits and repository traits. Constructed in the handler, not stored.
|
||||
|
||||
```rust
|
||||
// application/user/use_cases/create.rs
|
||||
pub struct CreateUserCommand { pub email: String, pub password: String }
|
||||
|
||||
pub struct CreateUserUseCase<P, R> {
|
||||
password_service: P,
|
||||
user_repository: R,
|
||||
}
|
||||
|
||||
impl<P: PasswordService, R: UserRepository> CreateUserUseCase<P, R> {
|
||||
pub fn new(password_service: P, user_repository: R) -> Self { ... }
|
||||
|
||||
pub async fn execute(&self, cmd: CreateUserCommand) -> Result<User, AuthError> {
|
||||
// 1. guard: check uniqueness
|
||||
// 2. hash password via port
|
||||
// 3. create domain entity with Uuid::new_v4()
|
||||
// 4. persist via repository
|
||||
// 5. log + return
|
||||
info!(user_id = %user.id, "user created");
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use case naming convention
|
||||
|
||||
| File | Struct | Command/Query |
|
||||
|---|---|---|
|
||||
| `create.rs` | `CreateXxxUseCase` | `CreateXxxCommand` |
|
||||
| `update.rs` | `UpdateXxxUseCase` | `UpdateXxxCommand` |
|
||||
| `delete.rs` | `DeleteXxxUseCase` | `DeleteXxxCommand` |
|
||||
| `detail.rs` | `XxxDetailUseCase` | `XxxDetailQuery` |
|
||||
| `list.rs` | `ListXxxsUseCase` | takes `&PaginationParams` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Infrastructure layer
|
||||
|
||||
### SeaORM repository implementation
|
||||
|
||||
```rust
|
||||
// infrastructure/repository/user.rs
|
||||
#[derive(Clone)]
|
||||
pub struct SeaOrmUserRepository { db: DatabaseConnection }
|
||||
|
||||
// Convert ORM Model → domain entity here (not in domain)
|
||||
impl From<Model> for User { ... }
|
||||
|
||||
// Map DbErr → RepositoryError
|
||||
fn map_db_err(e: DbErr) -> RepositoryError {
|
||||
match e {
|
||||
DbErr::RecordNotFound(_) => RepositoryError::NotFound,
|
||||
other => { error!(error = %other, "database operation failed"); RepositoryError::Database(other.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRepository for SeaOrmUserRepository {
|
||||
async fn create(&self, user: NewUser) -> Result<User, RepositoryError> {
|
||||
let model = ActiveModel {
|
||||
id: Set(user.id),
|
||||
email: Set(user.email),
|
||||
password_hash: Set(user.password_hash),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
let inserted = model.insert(&self.db).await.map_err(|e| match e {
|
||||
DbErr::Exec(ref msg) | DbErr::Query(ref msg)
|
||||
if msg.to_string().contains("unique") =>
|
||||
RepositoryError::Conflict("email already exists".into()),
|
||||
other => RepositoryError::Database(other.to_string()),
|
||||
})?;
|
||||
Ok(User::from(inserted))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pagination uses `paginator-sea-orm`:
|
||||
|
||||
```rust
|
||||
let response = UserEntity::find()
|
||||
.paginate_with(&self.db, params)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Database(e.to_string()))?;
|
||||
let mapped: Vec<User> = response.data.into_iter().map(User::from).collect();
|
||||
Ok(PaginatorResponse { data: mapped, meta: response.meta })
|
||||
```
|
||||
|
||||
### Auth services
|
||||
|
||||
- `Argon2PasswordService`: uses `spawn_blocking` for CPU-bound hashing, `SaltString::generate(OsRng)`.
|
||||
- `JwtTokenService`: stores `secret: Vec<u8>`, generates separate access/refresh tokens with a `type` claim. `verify_access_token` checks `claims.token_type == "access"`.
|
||||
|
||||
### SeaORM entities (ORM models)
|
||||
|
||||
Live in `infrastructure/repository/entities/`. One file per table. Junction tables (`user_role`, `role_permission`) have composite primary keys. Timestamps use `DateTimeWithTimeZone`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Presentation layer
|
||||
|
||||
### AppState
|
||||
|
||||
Concrete types only — no trait objects. Cheap to clone because `DatabaseConnection` is internally Arc-backed.
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub password_service: Argon2PasswordService,
|
||||
pub token_service: JwtTokenService,
|
||||
pub user_repository: SeaOrmUserRepository,
|
||||
pub role_repository: SeaOrmRoleRepository,
|
||||
pub permission_repository: SeaOrmPermissionRepository,
|
||||
}
|
||||
```
|
||||
|
||||
Injected via `Extension(state)` on every handler. Use cases are constructed inside handlers.
|
||||
|
||||
### AppError
|
||||
|
||||
```rust
|
||||
pub enum AppError { BadRequest(String), Unauthorized, Forbidden, NotFound, Conflict(String), Internal(String) }
|
||||
|
||||
impl IntoResponse for AppError { /* maps to HTTP status + JSON { "error": "..." } */ }
|
||||
|
||||
impl From<AuthError> for AppError { ... }
|
||||
impl From<RepositoryError> for AppError { ... }
|
||||
impl From<TokenError> for AppError { ... }
|
||||
```
|
||||
|
||||
Internal errors are logged with `tracing::error!` before returning a generic 500 message.
|
||||
|
||||
### Handler pattern
|
||||
|
||||
```rust
|
||||
#[instrument(skip_all, fields(actor = %actor.id, email = %req.email))]
|
||||
pub async fn create(
|
||||
Extension(state): Extension<AppState>,
|
||||
Extension(actor): Extension<AuthenticatedUser>,
|
||||
Json(req): Json<CreateUserRequest>,
|
||||
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
|
||||
let use_case = CreateUserUseCase::new(
|
||||
state.password_service.clone(),
|
||||
state.user_repository.clone(),
|
||||
);
|
||||
let user = use_case.execute(req.into()).await?;
|
||||
Ok((StatusCode::CREATED, Json(user.into())))
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Always `#[instrument(skip_all, fields(...))]` on every handler.
|
||||
- Use `?` to propagate `AppError` (via `From` impls).
|
||||
- `201 CREATED` for `POST`, `204 NO_CONTENT` for `DELETE`, `200 OK` for everything else.
|
||||
- Pagination handlers return `PaginatedJson<Dto>` via `paginator-axum`.
|
||||
|
||||
### DTO pattern
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Serialize, Deserialize, ZodSchema)]
|
||||
pub struct CreateUserRequest {
|
||||
#[zod(email)]
|
||||
pub email: String,
|
||||
#[zod(min_length(8), max_length(128))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl From<CreateUserRequest> for CreateUserCommand { ... }
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse { pub id: Uuid, pub email: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc> }
|
||||
|
||||
impl From<User> for UserResponse { ... }
|
||||
```
|
||||
|
||||
- Request structs: `Deserialize + ZodSchema`. Use `#[zod(...)]` for field-level validation.
|
||||
- Response structs: `Serialize` only. Never expose `password_hash`.
|
||||
- Conversions: `impl From<Request> for Command` and `impl From<DomainEntity> for Response`.
|
||||
|
||||
### Middleware
|
||||
|
||||
**Auth middleware** (`presentation/middleware/auth.rs`):
|
||||
- Extracts `Bearer <token>` from `Authorization` header.
|
||||
- Calls `state.token_service.verify_access_token(token)`.
|
||||
- Inserts `AuthenticatedUser { id: Uuid }` into request extensions.
|
||||
|
||||
**Permission check** (`presentation/middleware/permission.rs`):
|
||||
- Called inline from handlers: `ensure_permission(&state, &actor, "users:write").await?`.
|
||||
- Queries `permission_repository.find_for_user(actor.id)` and checks by name.
|
||||
|
||||
### Router assembly
|
||||
|
||||
```rust
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/me", me::router())
|
||||
.nest("/users", user::router())
|
||||
.nest("/roles", role::router())
|
||||
.nest("/permissions", permission::router())
|
||||
.layer(Extension(state))
|
||||
}
|
||||
```
|
||||
|
||||
Gateway nests the IAM router at `/api/v1/iam` and adds a health check at `/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Migrations (sea-orm-migration)
|
||||
|
||||
```rust
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager.create_table(
|
||||
Table::create()
|
||||
.table(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Users::Email).string().not_null().unique_key())
|
||||
...
|
||||
.to_owned(),
|
||||
).await
|
||||
}
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
pub enum Users { Table, Id, Email, PasswordHash, CreatedAt, UpdatedAt }
|
||||
```
|
||||
|
||||
Naming convention: `m{YYYYMMDD}_{6-digit-seq}_{description}.rs`, e.g. `m20260413_000001_create_users.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bootstrap pattern
|
||||
|
||||
A separate `bootstrap` binary seeds idempotent system data (permissions, roles, admin user):
|
||||
|
||||
```rust
|
||||
// Check existence before inserting — fully idempotent
|
||||
if permission_repo.find_by_name("rbac:manage").await?.is_none() {
|
||||
permission_repo.create(...).await?;
|
||||
}
|
||||
```
|
||||
|
||||
Standard permissions:
|
||||
- `rbac:manage`, `users:read`, `users:write`, `roles:read`, `roles:write`, `permissions:read`, `permissions:write`
|
||||
|
||||
---
|
||||
|
||||
## 8. Adding a new aggregate (checklist)
|
||||
|
||||
1. **Domain**: `domain/{name}/entity.rs` (entity + NewXxx + XxxPatch), `domain/{name}/repository.rs` (trait + errors), `domain/{name}/errors.rs` if needed.
|
||||
2. **Application**: `application/{name}/mod.rs`, `application/{name}/use_cases/{create,update,delete,detail,list}.rs`.
|
||||
3. **Infrastructure entity**: `infrastructure/repository/entities/{name}.rs` (SeaORM model).
|
||||
4. **Infrastructure repo**: `infrastructure/repository/{name}.rs` (`From<Model>`, `impl XxxRepository for SeaOrmXxxRepository`).
|
||||
5. **Add to AppState**: `{name}_repository: SeaOrmXxxRepository`.
|
||||
6. **Presentation DTO**: `presentation/{name}/dto.rs` (Request + Response with `From` impls).
|
||||
7. **Presentation handlers**: `presentation/{name}/handlers.rs` (`#[instrument]`, construct use case, return DTO).
|
||||
8. **Presentation router**: `presentation/{name}/mod.rs` (define routes with `axum_route_macro` or `Router::new().route(...)`).
|
||||
9. **Nest in `build_router`**.
|
||||
10. **Migration**: new file in `.migrations/src/` following naming convention.
|
||||
11. **Bootstrap**: seed any required initial data.
|
||||
|
||||
---
|
||||
|
||||
## 9. Key conventions
|
||||
|
||||
- Edition **2024** — use `impl Future` in traits, not `#[async_trait]`.
|
||||
- All timestamps are `DateTime<Utc>` in domain; `DateTimeWithTimeZone` in SeaORM models; convert with `.with_timezone(&Utc)`.
|
||||
- UUIDs generated with `Uuid::new_v4()` in the use case, not the repository.
|
||||
- Unique-constraint conflicts detected via string match on `DbErr::Exec`/`DbErr::Query` containing `"unique"` — map to `RepositoryError::Conflict`.
|
||||
- `tracing::instrument` on every handler; log user/actor IDs as structured fields.
|
||||
- `warn!` for expected failures (wrong password, permission denied), `error!` for unexpected DB errors.
|
||||
- Response structs never expose internal fields (`password_hash`, internal IDs from junction tables).
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: push-flow-convention
|
||||
description: Enforce pre-commit/pre-push hooks, lint-staged checks, and semver version bump on every push
|
||||
---
|
||||
|
||||
# Push Flow Convention
|
||||
|
||||
Every repository MUST enforce the same pre-commit, pre-push, and versioning flow. No push lands without hooks, lint-staged, and a version bump.
|
||||
|
||||
## Required Setup
|
||||
|
||||
### 1. Lefthook (pre-commit + pre-push)
|
||||
|
||||
> **Always use [Lefthook](https://lefthook.dev/) for git hooks. Never use husky.**
|
||||
|
||||
Install once per repo:
|
||||
|
||||
```bash
|
||||
pnpm add -D lefthook lint-staged
|
||||
pnpm exec lefthook install
|
||||
```
|
||||
|
||||
Create `lefthook.yml` in project root:
|
||||
|
||||
```yaml
|
||||
pre-commit:
|
||||
commands:
|
||||
lint-staged:
|
||||
run: pnpm exec lint-staged
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
lint-staged:
|
||||
run: pnpm exec lint-staged --diff="origin/{push_remote_branch}...HEAD"
|
||||
bump:
|
||||
run: pnpm run bump
|
||||
```
|
||||
|
||||
### 2. lint-staged
|
||||
|
||||
Declared in `package.json`. Runs ONLY on staged files so commits stay fast.
|
||||
|
||||
```json
|
||||
{
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,yml,yaml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Version bump script
|
||||
|
||||
`package.json` MUST expose a `bump` script used by `pre-push`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"bump": "node scripts/bump-version.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The script inspects the diff between the current branch and its upstream, applies the semver rule below, and writes the new version back to `package.json`. Commit the bump before pushing (amend the previous commit or create a `chore: adjust package.json version (bump)` commit — see `commit-convention`).
|
||||
|
||||
## Semver Rules (applied on every push)
|
||||
|
||||
The bump is based on the changes in the commits being pushed:
|
||||
|
||||
| Change size / kind | Bump |
|
||||
|--------------------|------|
|
||||
| `< 5` changed files across pushed commits | **patch** (`x.y.Z`) |
|
||||
| `>= 5` changed files across pushed commits | **minor** (`x.Y.0`) |
|
||||
| New feature OR new behaviour (any `feat:` commit) | **major** (`X.0.0`) |
|
||||
|
||||
Rules in order of precedence:
|
||||
1. If ANY commit being pushed is a `feat(...)` → **major** bump.
|
||||
2. Otherwise, count files changed (`git diff --name-only origin/<branch>...HEAD | wc -l`):
|
||||
- fewer than 5 → **patch**
|
||||
- 5 or more → **minor**
|
||||
|
||||
The `feat` rule always wins — a new feature is always a major bump regardless of file count.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. NEVER push without pre-commit and pre-push hooks installed.
|
||||
2. NEVER bypass hooks with `--no-verify` — if a hook fails, fix the root cause.
|
||||
3. NEVER push without a version bump. Every push = new version.
|
||||
4. The bump commit MUST use the `chore: adjust package.json version (bump)` message (see `commit-convention`).
|
||||
5. lint-staged MUST run on every commit. A green lint-staged is a prerequisite for the commit to be created.
|
||||
6. If `pnpm` is not the package manager, substitute with `npm` or `yarn` but keep the same flow.
|
||||
|
||||
## Quick verification checklist
|
||||
|
||||
Before declaring the push flow set up, confirm:
|
||||
|
||||
- [ ] `lefthook.yml` exists with `pre-commit` and `pre-push` hooks
|
||||
- [ ] `pnpm exec lefthook install` has been run (hooks registered in `.git/hooks/`)
|
||||
- [ ] `package.json` has a `lint-staged` block
|
||||
- [ ] `package.json` has a `bump` script
|
||||
- [ ] A dry-run commit triggers lint-staged
|
||||
- [ ] A dry-run push triggers the version bump
|
||||
+2
-1
@@ -5,4 +5,5 @@ node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
.superpowers/
|
||||
docs/lesson/
|
||||
docs/lesson/
|
||||
.kilo/
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
["@semantic-release/exec", {
|
||||
"prepareCmd": "sed -i 's/^version = \"[^\"]*\"/version = \"${nextRelease.version}\"/' Cargo.toml && cargo check"
|
||||
}],
|
||||
["@semantic-release/git", {
|
||||
"assets": ["Cargo.toml", "CHANGELOG.md"],
|
||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
}],
|
||||
["@semantic-release/github", {
|
||||
"assets": []
|
||||
}]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Kilo when working with code in the zesdex repository.
|
||||
|
||||
## Best Practice Conventions
|
||||
|
||||
Zesdex follows Kana Engineering Best Practices:
|
||||
|
||||
1. **Clean Architecture** — Strict domain/application/infrastructure/presentation layering.
|
||||
- Domain has ZERO framework dependencies.
|
||||
- Application depends only on domain.
|
||||
- Infrastructure implements domain traits.
|
||||
- DTOs cross layer boundaries, NOT entities.
|
||||
|
||||
2. **Clean Code** — Functions under ~40 lines, one level of abstraction per function, descriptive names, no flag arguments, no commented-out code.
|
||||
|
||||
3. **Documentation** — Every pub fn, struct, enum, and trait needs a doc comment (///) explaining what, flow, why, and return value.
|
||||
|
||||
4. **Commit Convention** — Conventional Commits in Bahasa Indonesia: `feat(scope):`, `fix(scope):`, `chore:`, `docs:`.
|
||||
|
||||
5. **Error Handling** — `anyhow::Result` and `anyhow::bail!` throughout. Log with `tracing` (never stderr).
|
||||
|
||||
6. **Testing** — `#[cfg(test)] mod tests` blocks inline in production files. Tests are F.I.R.S.T. (Fast, Independent, Repeatable, Self-validating, Timely).
|
||||
|
||||
7. **No Compiler Bypasses** — Never use `#[allow(...)]`, `#[expect(...)]`, or `#[allow(dead_code)]`. Fix the underlying code.
|
||||
|
||||
8. **Boy Scout Rule** — Leave every module cleaner than you found it.
|
||||
|
||||
## Available Agents
|
||||
|
||||
- `@rust-engineer` — Rust clean architecture specialist (subagent).
|
||||
- `@code-reviewer` — Code review specialist (subagent).
|
||||
|
||||
## Available Commands
|
||||
|
||||
- `/check` — Run cargo check, clippy, and tests.
|
||||
- `/audit` — Code quality audit against clean-architecture best practices.
|
||||
- `/doc` — Generate or update doc comments.
|
||||
+371
@@ -1,3 +1,374 @@
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add .releaserc.json for semantic-release configuration ([047d718](https://github.com/asepharyana/zesdex/commit/047d7183d72adaa0dd1a18a7f92b22d67d5aa85f))
|
||||
* **clippy:** replace type annotation with type alias + .insert() to avoid trivial_cast ([ffefa8c](https://github.com/asepharyana/zesdex/commit/ffefa8c07facbba83778f426ca4715569556b08a))
|
||||
* **rust:** remove unused imports, variables, and dead code causing CI build failures ([eb8cc17](https://github.com/asepharyana/zesdex/commit/eb8cc1799359a5ef5b4a4ee3a4202b87e80bfe6c))
|
||||
* **rust:** resolve all clippy warnings treated as errors in CI ([6b90c7e](https://github.com/asepharyana/zesdex/commit/6b90c7eb0d17d9281a377454e76dcc88a4232bc7))
|
||||
* **tui:** resolve remaining clippy errors in workspace ([eed4025](https://github.com/asepharyana/zesdex/commit/eed4025918de65eb2f3fd98cd3350d62f3322132))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* Add SOLID principles and TDD reference documentation ([55677dd](https://github.com/asepharyana/zesdex/commit/55677dd67100813a5ed2b0bbf02257b57875ea07))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **best_practice:** add code quality scanning and commit message validation ([8ecc588](https://github.com/asepharyana/zesdex/commit/8ecc588a3e125dc6a4b2e7eb4c12c6b344449c07))
|
||||
* centralize default constants and refactor overlay enter handling in TUI ([d615090](https://github.com/asepharyana/zesdex/commit/d615090dcd34bdb93be22c1c7ce4ba674363c258))
|
||||
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||
* enhance explore phase with TUI workflow event handling ([b3a4d13](https://github.com/asepharyana/zesdex/commit/b3a4d131d13d94bd307720cb50c2d02dedf92873))
|
||||
* implement Component trait for modular UI components and refactor TUI views to use it ([d59713d](https://github.com/asepharyana/zesdex/commit/d59713d3e3aa4325f9753bbddd068654e442f6ef))
|
||||
* implement mandatory explore phase with parallel subagents ([f368f3a](https://github.com/asepharyana/zesdex/commit/f368f3a1c0d2df02eb50542a81bcff96d9cdc420))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** introduce comprehensive state management for TUI interface ([8c58faf](https://github.com/asepharyana/zesdex/commit/8c58faf2920b7341dd50ace15044ff113cefb576))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add .releaserc.json for semantic-release configuration ([047d718](https://github.com/asepharyana/zesdex/commit/047d7183d72adaa0dd1a18a7f92b22d67d5aa85f))
|
||||
* **rust:** remove unused imports, variables, and dead code causing CI build failures ([eb8cc17](https://github.com/asepharyana/zesdex/commit/eb8cc1799359a5ef5b4a4ee3a4202b87e80bfe6c))
|
||||
* **rust:** resolve all clippy warnings treated as errors in CI ([6b90c7e](https://github.com/asepharyana/zesdex/commit/6b90c7eb0d17d9281a377454e76dcc88a4232bc7))
|
||||
* **tui:** resolve remaining clippy errors in workspace ([eed4025](https://github.com/asepharyana/zesdex/commit/eed4025918de65eb2f3fd98cd3350d62f3322132))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* Add SOLID principles and TDD reference documentation ([55677dd](https://github.com/asepharyana/zesdex/commit/55677dd67100813a5ed2b0bbf02257b57875ea07))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **best_practice:** add code quality scanning and commit message validation ([8ecc588](https://github.com/asepharyana/zesdex/commit/8ecc588a3e125dc6a4b2e7eb4c12c6b344449c07))
|
||||
* centralize default constants and refactor overlay enter handling in TUI ([d615090](https://github.com/asepharyana/zesdex/commit/d615090dcd34bdb93be22c1c7ce4ba674363c258))
|
||||
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||
* enhance explore phase with TUI workflow event handling ([b3a4d13](https://github.com/asepharyana/zesdex/commit/b3a4d131d13d94bd307720cb50c2d02dedf92873))
|
||||
* implement Component trait for modular UI components and refactor TUI views to use it ([d59713d](https://github.com/asepharyana/zesdex/commit/d59713d3e3aa4325f9753bbddd068654e442f6ef))
|
||||
* implement mandatory explore phase with parallel subagents ([f368f3a](https://github.com/asepharyana/zesdex/commit/f368f3a1c0d2df02eb50542a81bcff96d9cdc420))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** introduce comprehensive state management for TUI interface ([8c58faf](https://github.com/asepharyana/zesdex/commit/8c58faf2920b7341dd50ace15044ff113cefb576))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add .releaserc.json for semantic-release configuration ([047d718](https://github.com/asepharyana/zesdex/commit/047d7183d72adaa0dd1a18a7f92b22d67d5aa85f))
|
||||
* **rust:** remove unused imports, variables, and dead code causing CI build failures ([eb8cc17](https://github.com/asepharyana/zesdex/commit/eb8cc1799359a5ef5b4a4ee3a4202b87e80bfe6c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* Add SOLID principles and TDD reference documentation ([55677dd](https://github.com/asepharyana/zesdex/commit/55677dd67100813a5ed2b0bbf02257b57875ea07))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **best_practice:** add code quality scanning and commit message validation ([8ecc588](https://github.com/asepharyana/zesdex/commit/8ecc588a3e125dc6a4b2e7eb4c12c6b344449c07))
|
||||
* centralize default constants and refactor overlay enter handling in TUI ([d615090](https://github.com/asepharyana/zesdex/commit/d615090dcd34bdb93be22c1c7ce4ba674363c258))
|
||||
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||
* enhance explore phase with TUI workflow event handling ([b3a4d13](https://github.com/asepharyana/zesdex/commit/b3a4d131d13d94bd307720cb50c2d02dedf92873))
|
||||
* implement Component trait for modular UI components and refactor TUI views to use it ([d59713d](https://github.com/asepharyana/zesdex/commit/d59713d3e3aa4325f9753bbddd068654e442f6ef))
|
||||
* implement mandatory explore phase with parallel subagents ([f368f3a](https://github.com/asepharyana/zesdex/commit/f368f3a1c0d2df02eb50542a81bcff96d9cdc420))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** introduce comprehensive state management for TUI interface ([8c58faf](https://github.com/asepharyana/zesdex/commit/8c58faf2920b7341dd50ace15044ff113cefb576))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add .releaserc.json for semantic-release configuration ([047d718](https://github.com/asepharyana/zesdex/commit/047d7183d72adaa0dd1a18a7f92b22d67d5aa85f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||
* implement Component trait for modular UI components and refactor TUI views to use it ([d59713d](https://github.com/asepharyana/zesdex/commit/d59713d3e3aa4325f9753bbddd068654e442f6ef))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add .releaserc.json for semantic-release configuration ([047d718](https://github.com/asepharyana/zesdex/commit/047d7183d72adaa0dd1a18a7f92b22d67d5aa85f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* enhance context gathering in auto-review engine and agent runner ([dcc8c3e](https://github.com/asepharyana/zesdex/commit/dcc8c3ee42ef568843e04143048d799bc0882288))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* refactor auto-review engine to use spawn_subagent for improved thread handling ([094eb4b](https://github.com/asepharyana/zesdex/commit/094eb4b8baa5abd878dcf5b611cf615955bc79eb))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add semantic search tool for code symbol indexing and searching ([fef3c92](https://github.com/asepharyana/zesdex/commit/fef3c925cd02e1d76e2d990cc6fa4e4cba64fab0))
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** add AI summarization for conversation history compacting ([873f870](https://github.com/asepharyana/zesdex/commit/873f870e233dd0d2c6f0cce935f83fb8ce373505))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **agent:** implement agent execution engine and turn handling with background processing ([efcd191](https://github.com/asepharyana/zesdex/commit/efcd191f9698023944f9096a86c1efa47e412b6b))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **llm:** improve UTF-8 handling in response processing to prevent infinite loops ([7ea2265](https://github.com/asepharyana/zesdex/commit/7ea226509c8e11ee1bab21d216e0413715988fb9))
|
||||
* **llm:** increase max retries for streaming requests from 3 to 10 ([6c7995f](https://github.com/asepharyana/zesdex/commit/6c7995f5f5481c48ccd449e72d9563425fdcb837))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add rich context information including active jobs, README snippet, and recent git history ([2f5f62a](https://github.com/asepharyana/zesdex/commit/2f5f62ab097614d8b0c7a3e5c7bd364cd479e0aa))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** enhance system prompt with workspace structure information ([bed9f8c](https://github.com/asepharyana/zesdex/commit/bed9f8cff60711c4db998c8f10f32f8e210ae410))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** integrate rich context builder into agent turn process ([919435e](https://github.com/asepharyana/zesdex/commit/919435eb84f9a41a0637052a5dc1234b410714f8))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add test_load and test_parse binaries for configuration loading and parsing ([fe2e916](https://github.com/asepharyana/zesdex/commit/fe2e9169371bf7a3cc9391d8467d47fc2258ad1f))
|
||||
* **llm:** improve tool call handling by dynamically resizing tool_calls and updating arguments ([9bfb95d](https://github.com/asepharyana/zesdex/commit/9bfb95d795d6c7cc10cfcb907642bba7b8bb7f19))
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* remove obsolete design documents for clipboard OSC52, diff view, file mention, context compaction, and add development guide ([66ac4db](https://github.com/asepharyana/zesdex/commit/66ac4dbf027820697f4303264666b5f63a0b31d4))
|
||||
* **tui:** add support for reasoning in chat messages and update transcript handling ([285dbb1](https://github.com/asepharyana/zesdex/commit/285dbb14cccb1c261134c1fcb6c30ef1ed02bb55))
|
||||
* **tui:** enhance agent turn handling by grouping parameters and improving message management ([07b217c](https://github.com/asepharyana/zesdex/commit/07b217cf4a81b99c63101512932935246fcbc7ae))
|
||||
* **tui:** implement streaming support for LLM responses and update transcript handling ([cac6626](https://github.com/asepharyana/zesdex/commit/cac662658651ae092cffddfc88f39a775afef942))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
* **tui:** update system message for clarity and conciseness in tool usage instructions ([5a373d1](https://github.com/asepharyana/zesdex/commit/5a373d1031b476970bd45804a65c7455be99e78d))
|
||||
|
||||
# [1.18.0](https://github.com/asepharyana/zesdex/compare/v1.17.0...v1.18.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mcp:** enhance MCP server registration with error handling and improve transport process management ([148ba4e](https://github.com/asepharyana/zesdex/commit/148ba4e07b424736c6b4824c5786c0c7c9cbf0a1))
|
||||
* **tui:** optimize performance by caching display lines and token counts, and improve action handling ([87abe8c](https://github.com/asepharyana/zesdex/commit/87abe8c3358ac17196df93d19169901a856850d3))
|
||||
|
||||
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
|
||||
|
||||
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
|
||||
|
||||
# [1.16.0](https://github.com/asepharyana/zesdex/compare/v1.15.2...v1.16.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tui:** add usage overlay and sidebar for displaying usage statistics and tasks ([da2ed6d](https://github.com/asepharyana/zesdex/commit/da2ed6da25953b823354cc5deaa7b404b7b13cb0))
|
||||
* **tui:** enhance agent turn with tool descriptions and sanitize arguments ([08e2f99](https://github.com/asepharyana/zesdex/commit/08e2f9998d3693e4cd57225491359c762d03f1d9))
|
||||
* **tui:** implement agent turn engine for background processing and enhance input handling ([792695b](https://github.com/asepharyana/zesdex/commit/792695b65a393cfc54efe353480b7831e91544b5))
|
||||
|
||||
## [1.15.2](https://github.com/asepharyana/zesdex/compare/v1.15.1...v1.15.2) (2026-07-17)
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,77 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
- **Auto inline review** after each edit: `src/app/subagent/auto.rs` — `spawn_quick_review()` injects verdict back into LLM conversation.
|
||||
- **Background subagents** (test-gen, arch-review, security-review) fire asynchronously at turn end via `TurnEvent::SystemNote`, retrying once on failure and escalating to a blocking (`ESCALATED:`-prefixed, `ToastKind::Error`) notice if the retry also fails.
|
||||
|
||||
Commit convention (Conventional Commits, Bahasa Indonesia): see the `commit-convention` skill.
|
||||
---
|
||||
|
||||
## Best Practices (Kana Engineering Standards)
|
||||
|
||||
This project follows Kana Engineering Best Practices. The following skills are loaded and enforced:
|
||||
|
||||
| Skill | Location | Purpose |
|
||||
|-------|----------|---------|
|
||||
| `clean-code` | `.claude/skills/clean-code/SKILL.md` | Clean Code principles (naming, functions, classes, comments) |
|
||||
| `commit-convention` | `.claude/skills/commit-convention/SKILL.md` | Conventional Commits (Bahasa Indonesia) |
|
||||
| `push-flow-convention` | `.claude/skills/push-flow-convention/SKILL.md` | Pre-commit/pre-push hooks via lefthook |
|
||||
| `kana-rust-backend-best-practice` | `.claude/skills/kana-rust-backend-best-practice/SKILL.md` | Rust clean-architecture patterns (Axum, SeaORM, etc.) |
|
||||
|
||||
### Layering Rules
|
||||
|
||||
```
|
||||
domain/ → application/ → infrastructure/ → interfaces/ → gateway/
|
||||
(inward) (outward)
|
||||
```
|
||||
|
||||
- **Domain** (Layer 0): Pure entities, value objects, repository/service traits. ZERO external framework deps.
|
||||
- **Application** (Layer 1): Use-case services (one per file), port traits. Depends ONLY on domain.
|
||||
- **Infrastructure** (Layer 2): Concrete implementations of domain traits (SQLite, JSON files, LLM clients, LSP servers, MCP).
|
||||
- **Interfaces** (Layer 3): Presentation adapters — TUI (ratatui), API (Axum), WebSocket, daemon, gRPC, web.
|
||||
- **Gateway**: Composition root — the only place that wires all layers together.
|
||||
|
||||
**Critical:** Domain must NEVER import application, infrastructure, or interfaces. Application must NEVER import infrastructure or interfaces.
|
||||
|
||||
### Commit Convention (Bahasa Indonesia)
|
||||
|
||||
All commits follow Conventional Commits in Bahasa Indonesia:
|
||||
|
||||
```
|
||||
feat(tool): add batch file delete
|
||||
fix(ipc): reconnect loop on socket timeout
|
||||
chore: bump reqwest to 0.13
|
||||
docs: add architecture diagram to README
|
||||
refactor(harness): flatten guard pipeline
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `style`, `perf`, `ci`. All types produce a release (patch minimum). Add `BREAKING CHANGE:` for major bumps.
|
||||
|
||||
### Clean Code Principles
|
||||
|
||||
- **Functions under ~40 lines**, one level of abstraction, extracted till you drop.
|
||||
- **No flag arguments** — split `render(true)` into `renderForSuite()` / `renderForSingleTest()`.
|
||||
- **Command-Query Separation** — function either does or answers, never both.
|
||||
- **No switch/if-else on type** — replace with factory + polymorphism.
|
||||
- **No null returns** — use `Option<T>` or empty collections.
|
||||
- **No magic numbers** — extract named constants.
|
||||
- **DRY** — no duplication.
|
||||
- **Tell, Don't Ask** — don't fetch state then decide; tell the object to work.
|
||||
- **Boy Scout Rule** — leave every module cleaner than you found it.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- `anyhow::Result` and `anyhow::bail!` throughout (except domain layer typed errors).
|
||||
- `tracing::warn!` / `tracing::error!` for logging. NEVER stderr (corrupts TUI).
|
||||
- Never `.unwrap()` or `.expect()` in production code — use `?` or proper error handling.
|
||||
- Log expected failures at `warn!`, unexpected errors at `error!`.
|
||||
|
||||
### Testing
|
||||
|
||||
- `#[cfg(test)] mod tests` blocks inline in production files.
|
||||
- Tests are F.I.R.S.T. — Fast, Independent, Repeatable, Self-validating, Timely.
|
||||
- Use `Result<()>` as test return type for `?` propagation.
|
||||
- Mock at boundaries only; prefer fakes for owned abstractions.
|
||||
|
||||
### Compiler Bypasses
|
||||
|
||||
NEVER use `#[allow(...)]`, `#[expect(...)]`, or `#[allow(dead_code)]`. Fix the underlying code instead.
|
||||
|
||||
## Code Documentation
|
||||
|
||||
|
||||
Generated
+405
-100
@@ -32,6 +32,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
@@ -134,6 +184,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"base64",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -152,8 +203,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -401,6 +454,46 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
@@ -410,6 +503,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -669,6 +768,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
version = "0.3.2"
|
||||
@@ -1624,6 +1729,12 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1937,6 +2048,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -2142,6 +2263,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -2483,6 +2610,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2619,6 +2755,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
@@ -2630,6 +2776,16 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -2639,6 +2795,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
@@ -3249,6 +3414,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
@@ -3849,6 +4025,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3977,6 +4165,22 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -4609,6 +4813,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
@@ -4670,14 +4894,157 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-backend"
|
||||
version = "1.15.2"
|
||||
name = "zesdex-api"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"jsonwebtoken",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-application"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-domain",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-bootstrap"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-daemon"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"hex",
|
||||
"ignore",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"webbrowser",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-domain"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-gateway"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zesdex-api",
|
||||
"zesdex-application",
|
||||
"zesdex-daemon",
|
||||
"zesdex-domain",
|
||||
"zesdex-grpc",
|
||||
"zesdex-infrastructure",
|
||||
"zesdex-tui",
|
||||
"zesdex-web",
|
||||
"zesdex-ws",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-grpc"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infrastructure"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"dom_smoothie",
|
||||
"fast_html2md",
|
||||
"futures-util",
|
||||
@@ -4686,12 +5053,13 @@ dependencies = [
|
||||
"ignore",
|
||||
"include_dir",
|
||||
"infer",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"lsp-types",
|
||||
"nucleo-matcher",
|
||||
"percent-encoding",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"rand_core 0.6.4",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -4705,141 +5073,78 @@ dependencies = [
|
||||
"syntect",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"webbrowser",
|
||||
"zesdex-cms",
|
||||
"zesdex-entities",
|
||||
"zesdex-iam",
|
||||
"zesdex-infra",
|
||||
"zesdex-ipc",
|
||||
"zesdex-middleware",
|
||||
"zesdex-utils",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-cms"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-entities"
|
||||
version = "1.15.2"
|
||||
name = "zesdex-tui"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-iam"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"libc",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infra"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"jsonwebtoken",
|
||||
"rand_core 0.6.4",
|
||||
"nucleo-matcher",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-cms",
|
||||
"zesdex-entities",
|
||||
"zesdex-iam",
|
||||
"zesdex-middleware",
|
||||
"zesdex-utils",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-ipc"
|
||||
version = "1.15.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"zesdex-entities",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-middleware"
|
||||
version = "1.15.2"
|
||||
name = "zesdex-web"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"include_dir",
|
||||
"mime_guess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"zesdex-entities",
|
||||
"zesdex-utils",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-utils"
|
||||
version = "1.15.2"
|
||||
name = "zesdex-ws"
|
||||
version = "1.18.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"hex",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zesdex-application",
|
||||
"zesdex-domain",
|
||||
"zesdex-infrastructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+26
-11
@@ -1,18 +1,21 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/zesdex-entities",
|
||||
"crates/zesdex-utils",
|
||||
"crates/zesdex-ipc",
|
||||
"crates/zesdex-iam",
|
||||
"crates/zesdex-cms",
|
||||
"crates/zesdex-middleware",
|
||||
"crates/zesdex-infra",
|
||||
"crates/zesdex-backend",
|
||||
"apps/domain",
|
||||
"apps/application",
|
||||
"apps/infrastructure",
|
||||
"apps/interfaces/tui",
|
||||
"apps/interfaces/api",
|
||||
"apps/interfaces/daemon",
|
||||
"apps/interfaces/ws",
|
||||
"apps/interfaces/grpc",
|
||||
"apps/interfaces/web",
|
||||
"apps/gateway",
|
||||
"apps/bootstrap",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.15.2"
|
||||
version = "1.18.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
@@ -76,6 +79,18 @@ tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "limit"] }
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
zesdex-entities = { path = "crates/zesdex-entities" }
|
||||
zesdex-utils = { path = "crates/zesdex-utils" }
|
||||
# Clean-architecture workspace crate references
|
||||
zesdex-domain = { path = "apps/domain" }
|
||||
zesdex-application = { path = "apps/application" }
|
||||
zesdex-infrastructure = { path = "apps/infrastructure" }
|
||||
zesdex-tui = { path = "apps/interfaces/tui" }
|
||||
zesdex-api = { path = "apps/interfaces/api" }
|
||||
zesdex-daemon = { path = "apps/interfaces/daemon" }
|
||||
zesdex-ws = { path = "apps/interfaces/ws" }
|
||||
zesdex-grpc = { path = "apps/interfaces/grpc" }
|
||||
zesdex-web = { path = "apps/interfaces/web" }
|
||||
zesdex-gateway = { path = "apps/gateway" }
|
||||
zesdex-bootstrap = { path = "apps/bootstrap" }
|
||||
|
||||
@@ -1,379 +1,250 @@
|
||||
# Zesdex
|
||||
# Zesdex — Autonomous AI Coding Agent
|
||||
|
||||
> Autonomous AI coding agent in a terminal-based TUI.
|
||||
Zesdex is an autonomous AI coding agent with a Terminal UI (TUI). It acts as an
|
||||
OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with **37
|
||||
built-in tools** — file operations, git, shell execution, LSP integration, MCP,
|
||||
subagent orchestration, and more.
|
||||
|
||||
Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in guardrails at every layer.
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Mode Selector │
|
||||
│ TUI (default) ─── Daemon ─── Attach ─── API ─── WS/gRPC/Web │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## Quick Start
|
||||
|
||||
### Core
|
||||
```bash
|
||||
# Run the TUI (default mode)
|
||||
cargo run
|
||||
|
||||
- **TUI Interface** — Full-screen terminal UI with chat panel, input bar, and status bar built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm).
|
||||
- **Daemon Architecture** — Run as a background daemon with client attach/detach via Unix domain sockets. The daemon processes state; clients only render.
|
||||
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
|
||||
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
|
||||
# Run the REST API server
|
||||
cargo run -- --api --api-port 8080
|
||||
|
||||
### Tool System (37 built-in tools)
|
||||
# Run in daemon mode (background + IPC)
|
||||
cargo run -- --daemon
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Filesystem** | `read`, `write`, `edit`, `delete` |
|
||||
| **Search** | `grep` (recursive text), `glob` (file patterns) |
|
||||
| **Shell** | `bash`, `bash_output`, `bash_kill` |
|
||||
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
|
||||
| **Memory** | `remember`, `recall`, `forget` |
|
||||
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
|
||||
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `hive_mind` |
|
||||
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
|
||||
| **Agent** | `spawn_agents`, `spawn_pipeline` |
|
||||
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
|
||||
# Attach TUI to a running daemon session
|
||||
cargo run -- --attach <session-id>
|
||||
|
||||
### Intelligence
|
||||
# Seed initial data (first run)
|
||||
cargo run --bin bootstrap
|
||||
```
|
||||
|
||||
- **Hive-Mind Orchestration** — Autonomous agent orchestration modeled as a distributed machine intelligence (à la Stellaris). The Core Intelligence (main agent) compiles a cognitive cycle plan per task — an ordered list of cycles, each a set of anonymous processing nodes that run in parallel. Every node carries only a directive (what to do) and an access tier (`read`/`write`/`full`); cycle count and nodes-per-cycle are decided per task, not fixed. Every node's output merges into a shared collective state the instant it completes, and a final synthesis node reconciles it into one consensus. Every convergence is written to `docs/runs/*.md`. Manual entry point: the `hive_mind` tool.
|
||||
### Prerequisites
|
||||
|
||||
- **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time.
|
||||
- **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation.
|
||||
- **Self-Review** — Review subagents trigger automatically after each code edit (inline) and at turn completion (background). Three types: code quality, architecture, and security.
|
||||
- **Self-Healing** — On build/test failures, spawns a sub-agent with the error context to autonomously fix issues before reporting them to the user.
|
||||
- **MCP Support** — [Model Context Protocol](https://modelcontextprotocol.io/) integration for connecting to external AI tool servers.
|
||||
- **Sequential Thinking** — Chain-of-thought reasoning tool for step-by-step problem decomposition.
|
||||
- **Session Locking** — Prevents multiple processes from operating on the same session directory.
|
||||
- **Rust** 1.81+ (edition 2021)
|
||||
- **Linux** or **macOS** (Unix domain sockets required for daemon mode)
|
||||
- An **API key** for an OpenAI/Anthropic-compatible LLM provider (set via
|
||||
settings or environment variable)
|
||||
|
||||
### Session Management
|
||||
---
|
||||
|
||||
- Multiple concurrent sessions with history, rewind, and transcript persistence.
|
||||
- Per-session edit logs with full change tracking.
|
||||
- Session archival and summary generation.
|
||||
## Modes
|
||||
|
||||
| Flag | Mode | Description |
|
||||
|------|------|-------------|
|
||||
| *(none)* | **TUI** | Full terminal UI with chat, overlays, and agent loop in one process |
|
||||
| `--daemon` | **Daemon** | Background daemon with IPC socket; clients attach separately |
|
||||
| `--attach <id>` | **Attach** | Connect TUI to an existing daemon session via Unix socket |
|
||||
| `--api` | **REST API** | HTTP server with session management and chat endpoints |
|
||||
| `--ws` | **WebSocket** | WebSocket server for real-time communication |
|
||||
| `--grpc` | **gRPC** | gRPC server for programmatic access |
|
||||
| `--web` | **Web** | Serves the web frontend |
|
||||
| `--api-port`, `--ws-port`, `--grpc-port`, `--web-port` | *(ports)* | Configure server ports (defaults: 8080, 8081, 50051, 3000) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Clean Architecture Layering
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point: single-process, daemon, or attach mode
|
||||
├── resources.rs # Embedded resources (help text, system prompts)
|
||||
├── app/
|
||||
│ ├── state/ # AppStateRest — immutable-rest state model
|
||||
│ │ ├── rest.rs # Core state struct
|
||||
│ │ ├── types.rs # Overlay, Toast, Origin enums
|
||||
│ │ ├── snapshot.rs # State snapshots for IPC
|
||||
│ │ ├── diff.rs # Diff-based state synchronization
|
||||
│ │ ├── runtime.rs # Runtime state mutations
|
||||
│ │ └── misc.rs # DirCache and miscellaneous state helpers
|
||||
│ ├── runtime/ # Action dispatch and event loop
|
||||
│ │ ├── actions/ # Action enum and apply_action reducer
|
||||
│ │ ├── stream/ # LLM streaming and tool execution
|
||||
│ │ │ └── tools/ # Tool harness integration
|
||||
│ │ │ └── turn.rs # Turn orchestration
|
||||
│ │ ├── event_loop/ # Main event loop and shortsend
|
||||
│ │ ├── commands.rs # Slash command dispatch
|
||||
│ │ └── shortsend.rs # Short-lived async send helper
|
||||
│ ├── mode/ # UI modes and overlays (13 modes)
|
||||
│ │ ├── bash.rs # Bash panel mode
|
||||
│ │ ├── editor.rs # Multi-line editor mode
|
||||
│ │ ├── effort.rs # Effort level selector
|
||||
│ │ ├── help.rs # Help overlay
|
||||
│ │ ├── key_input.rs # Raw key input mode
|
||||
│ │ ├── learning.rs # Lesson management overlay
|
||||
│ │ ├── loading.rs # Loading spinner overlay
|
||||
│ │ ├── mcp.rs # MCP server management
|
||||
│ │ ├── quit_confirm.rs # Quit confirmation dialog
|
||||
│ │ ├── rewind.rs # Session rewind mode
|
||||
│ │ ├── settings.rs # Settings panel
|
||||
│ │ ├── todo.rs # Task list overlay
|
||||
│ │ └── workflow.rs # Workflow visualization
|
||||
│ ├── harness.rs # Tool harness for agent execution
|
||||
│ ├── workflow/ # Workflow engine
|
||||
│ │ ├── script.rs # Workflow script DSL
|
||||
│ │ ├── engine.rs # Workflow executor
|
||||
│ │ ├── hive_mind.rs # Hive-mind orchestrator
|
||||
│ │ └── docs.rs # Deterministic docs/runs/*.md writer
|
||||
│ ├── mcp/ # MCP client manager
|
||||
│ │ └── manager.rs # MCP server lifecycle and tool exposure
|
||||
│ ├── subagent/ # Sub-agent management
|
||||
│ │ ├── spawn.rs # AgentDefinition and spawning
|
||||
│ │ ├── engine.rs # Sub-agent event loop
|
||||
│ │ ├── context.rs # Context construction for sub-agents
|
||||
│ │ └── event.rs # Progress event types
|
||||
│ ├── bgbash/ # Background bash job management
|
||||
│ │ ├── job.rs # Background job handle
|
||||
│ │ └── control.rs # Bash control (bg/fg/kill)
|
||||
│ ├── lsp/ # LSP client management
|
||||
│ │ ├── client.rs # LSP client connection wrapper
|
||||
│ │ └── provisioner.rs # Auto-provisioning of LSP servers
|
||||
│ └── review/ # Self-review quality system
|
||||
├── controller/
|
||||
│ ├── input.rs # Key event → Action mapping
|
||||
│ └── command.rs # Slash command parser
|
||||
├── dto/
|
||||
│ ├── chat/ # Message, ToolCall, Role types
|
||||
│ │ ├── message.rs # Chat message types
|
||||
│ │ ├── tool.rs # Tool call/result types
|
||||
│ │ └── mod.rs
|
||||
│ └── provider/ # AI provider request/response/usage types
|
||||
│ ├── request.rs # Provider request schema
|
||||
│ ├── response.rs # Provider response schema
|
||||
│ └── usage.rs # Token usage tracking
|
||||
├── ipc/
|
||||
│ ├── protocol.rs # ClientRequest, DaemonFrame, StatePayload
|
||||
│ ├── server.rs # Unix socket server
|
||||
│ ├── client.rs # Unix socket client
|
||||
│ ├── conn.rs # Framed connection
|
||||
│ ├── frame.rs # Length-prefixed frame encoding
|
||||
│ ├── snapshot.rs # State snapshot serialization
|
||||
│ └── diff.rs # Binary diff for state sync
|
||||
├── model/
|
||||
│ ├── store.rs # File-based storage (~/.config/zesdex/)
|
||||
│ ├── session.rs # Session CRUD and listing
|
||||
│ ├── settings.rs # User settings (provider, model, tokens)
|
||||
│ ├── app_config.rs # Provider definitions and model roles
|
||||
│ ├── memory.rs # Persistent memory with frontmatter
|
||||
│ ├── editlog.rs # Edit history tracking
|
||||
│ ├── msglog/ # Message log (SQLite-backed)
|
||||
│ │ ├── schema.rs # SQLite schema
|
||||
│ │ ├── query.rs # Query helpers
|
||||
│ │ ├── blobs.rs # Large blob storage
|
||||
│ │ └── summary.rs # Session summarization
|
||||
│ ├── agent_def/ # Agent definitions (builtin, global, session)
|
||||
│ │ ├── builtin.rs # Built-in agent profiles
|
||||
│ │ ├── global.rs # Global agent config
|
||||
│ │ └── session.rs # Per-session agent config
|
||||
│ ├── conversation.rs # Conversation helpers
|
||||
│ └── session_lock.rs # Flock-based session locking
|
||||
├── service/
|
||||
│ ├── provider.rs # AI provider abstraction
|
||||
│ └── oauth/ # OAuth PKCE flow with loopback server
|
||||
│ ├── loopback.rs # Local HTTP server for OAuth redirect
|
||||
│ ├── manager.rs # OAuth token manager
|
||||
│ ├── pkce.rs # PKCE code challenge/verifier
|
||||
│ └── mod.rs
|
||||
├── tool/ # 34 tool implementations
|
||||
│ ├── fs/ # read, write, edit, delete
|
||||
│ │ ├── read.rs
|
||||
│ │ ├── write.rs
|
||||
│ │ ├── edit.rs
|
||||
│ │ ├── delete.rs
|
||||
│ │ └── helpers.rs # Path resolution and validation
|
||||
│ ├── search.rs # grep, glob
|
||||
│ ├── shell.rs # bash
|
||||
│ ├── bash_tools.rs # bash_output, bash_kill
|
||||
│ ├── git_operator.rs # git operations
|
||||
│ ├── git_worktree.rs # git worktree management
|
||||
│ ├── git_cred.rs # git credential store/get/erase
|
||||
│ ├── memory/ # remember, forget, recall
|
||||
│ │ ├── remember.rs
|
||||
│ │ ├── forget.rs
|
||||
│ │ └── recall.rs
|
||||
│ ├── plan.rs # plan_enter, plan_ready
|
||||
│ ├── seqthink.rs # Sequential thinking
|
||||
│ ├── workflow.rs # workflow_run, note_finding
|
||||
│ ├── utility/ # cd, dir_list, dir_cache_update, pong, todowrite, todofinish
|
||||
│ │ ├── cd.rs
|
||||
│ │ ├── dir_list.rs
|
||||
│ │ ├── dir_cache_update.rs
|
||||
│ │ ├── pong.rs
|
||||
│ │ ├── todowrite.rs
|
||||
│ │ └── todofinish.rs
|
||||
│ ├── lsp/ # LSP tools (connect, diagnostics, hover, etc.)
|
||||
│ │ └── mod.rs
|
||||
│ └── shell_filter/ # Shell output filtering (credentials, git)
|
||||
│ ├── credentials.rs
|
||||
│ ├── git.rs
|
||||
│ └── mod.rs
|
||||
└── view/ # TUI rendering
|
||||
├── chat.rs # Chat transcript with markdown
|
||||
├── markdown.rs # Markdown → ratatui spans
|
||||
├── status.rs # Status bar
|
||||
├── theme.rs # Color scheme
|
||||
└── workflow.rs # Workflow visualization
|
||||
apps/
|
||||
├── domain/ # Pure entities, value objects, repository/service traits
|
||||
│ # Zero framework deps — only serde + chrono + uuid
|
||||
├── application/ # Use-case services (auth, sessions, conversations, memory)
|
||||
│ # Depends only on domain-layer trait interfaces
|
||||
├── infrastructure/ # All I/O: LLM client, IPC, persistence, LSP, MCP, tools
|
||||
│ # Implements domain/application port interfaces
|
||||
└── interfaces/ # Entry points
|
||||
├── tui/ # Ratatui terminal UI
|
||||
├── api/ # Axum REST API
|
||||
├── daemon/ # Unix socket daemon + client
|
||||
├── ws/ # WebSocket server
|
||||
├── grpc/ # gRPC server
|
||||
└── web/ # Web frontend (static file server)
|
||||
```
|
||||
|
||||
---
|
||||
### Tool System
|
||||
|
||||
## Usage
|
||||
37 tools across 9 categories:
|
||||
|
||||
```bash
|
||||
# Run in single-process mode (default)
|
||||
zesdex
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` |
|
||||
| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` |
|
||||
| **Git** | `git_operator`, `git_cred`, `git_worktree` |
|
||||
| **Search** | `search`, `grep`, `glob`, `semantic_search` |
|
||||
| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` |
|
||||
| **Memory** | `remember`, `recall`, `forget` |
|
||||
| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` |
|
||||
| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` |
|
||||
| **Background** | Background bash jobs with `cancel/status/list` |
|
||||
|
||||
# Run as a background daemon
|
||||
zesdex --daemon
|
||||
|
||||
# Attach to a running daemon session
|
||||
zesdex --attach <session-id>
|
||||
|
||||
# Set log level
|
||||
RUST_LOG=debug zesdex
|
||||
```
|
||||
|
||||
### Key Bindings
|
||||
|
||||
| Binding | Action |
|
||||
|---------|--------|
|
||||
| `Ctrl+Q` | Quit |
|
||||
| `Ctrl+H` | Help overlay |
|
||||
| `Ctrl+P` | Settings overlay |
|
||||
| `Ctrl+B` | Bash panel |
|
||||
| `Ctrl+S` | Session hub |
|
||||
| `Ctrl+T` | Task list |
|
||||
| `Ctrl+W` | Workflow view |
|
||||
| `Ctrl+K` | Key input mode |
|
||||
| `Esc` | Cancel / back |
|
||||
| `Tab` | Autocomplete |
|
||||
| `↑/↓` | History / navigation |
|
||||
| `Scroll` | Mouse scroll in chat |
|
||||
|
||||
### Slash Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | Show help |
|
||||
| `/clear` | Clear transcript |
|
||||
| `/model` | Select AI model provider |
|
||||
| `/workflow` | Open the workflow panel |
|
||||
| `/workflow run <script>` | Run a JSON-encoded workflow script |
|
||||
| `/mcp` | Open MCP server manager |
|
||||
| `/mcp add <name> <command>` | Add an MCP server |
|
||||
| `/login [provider]` | Authenticate with a provider |
|
||||
| `/edit [path]` | Open a file/dir in the external editor |
|
||||
| `/compact` | Compact the conversation transcript |
|
||||
| `/lesson` | Interactive lesson/memory review |
|
||||
| `/quit` | Exit application |
|
||||
| `Any text` | Sent to the AI assistant as a prompt |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration lives in `~/.config/zesdex/` (or platform equivalent via the `dirs` crate).
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `settings.json` | Provider selection, model, temperature, max tokens, review settings, workflow concurrency |
|
||||
| `app_config.json` | AI provider definitions (name, API base URL, auth type, default model) |
|
||||
| `memory/` | Persistent lesson and reference storage (Markdown with YAML frontmatter) |
|
||||
| `sessions/` | Per-session transcripts, edit logs, and activity data |
|
||||
| `run/` | Unix domain sockets for daemon mode |
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
Providers are defined in `app_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"my-provider": {
|
||||
"api_base": "https://api.example.com/v1",
|
||||
"api_key_env": "MY_API_KEY",
|
||||
"default_model": "model-name"
|
||||
}
|
||||
},
|
||||
"model_roles": {
|
||||
"default": {
|
||||
"provider": "my-provider",
|
||||
"model": "model-name",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"default_provider": "my-provider",
|
||||
"default_model": "model-name"
|
||||
Each tool implements the `Tool` trait:
|
||||
```rust
|
||||
pub trait Tool: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn description(&self) -> &'static str;
|
||||
fn parameters(&self) -> Value;
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
||||
}
|
||||
```
|
||||
|
||||
### Settings
|
||||
### Hive Mind Orchestration
|
||||
|
||||
Key settings in `settings.json`:
|
||||
The multi-agent orchestration system compiles a **cognitive cycle plan** per
|
||||
task — ordered cycles of parallel processing nodes. Each node has a directive
|
||||
and an **access tier** (`read` / `write` / `full`). Node outputs merge into a
|
||||
shared collective state in real time, and a final **consensus synthesis**
|
||||
produces the unified result.
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `review_enabled` | `true` | Enable self-review after tool execution |
|
||||
| `review_max_lessons_per_run` | `5` | Max lessons loaded per review cycle |
|
||||
| `adaptive_review_max_skip` | `3` | Consecutive passes before skipping review |
|
||||
| `verify_command` | `null` | Optional command to verify changes |
|
||||
| `workflow_max_concurrency` | `5` | Max parallel sub-agents in workflows |
|
||||
| `session_archive_enabled` | `true` | Auto-archive completed sessions |
|
||||
- **Auto-trigger**: Complex requests automatically use the hive mind
|
||||
- **Manual entry**: The `hive_mind` tool lets the LLM specify cycles explicitly
|
||||
- **Live progress**: TUI panel shows each node's status and current tool
|
||||
- **Guaranteed docs**: Every convergence writes to `docs/runs/`
|
||||
|
||||
### IPC Protocol (Daemon Mode)
|
||||
|
||||
```
|
||||
┌──────────┐ Unix socket ┌──────────┐
|
||||
│ Client │ ◄──────────────► │ Daemon │
|
||||
│ (TUI) │ length-prefixed│ │
|
||||
└──────────┘ serde_json └──────────┘
|
||||
|
||||
Frame format: [4-byte BE length][JSON payload]
|
||||
```
|
||||
|
||||
The daemon holds `AppStateRest` and drives the agent loop. Clients are stateless
|
||||
renderers that receive full state snapshots after each action.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Built-in Features
|
||||
|
||||
### Prerequisites
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **LLM Provider** | OpenAI/Anthropic-compatible API (streaming + non-streaming) with automatic retry and fallback |
|
||||
| **Tool Harness** | Safety-gated tool execution with graduated review checks |
|
||||
| **Subagents** | Auto-inline review, background test-gen, arch-review, security-review |
|
||||
| **OAuth 2.0** | PKCE flow for LLM provider authentication |
|
||||
| **MCP** | Model Context Protocol server management (stdio + HTTP transport) |
|
||||
| **LSP** | Language Server Protocol integration (completion, hover, diagnostics, references) |
|
||||
| **Session Mgmt** | SQLite-persisted sessions with lock-based concurrency control |
|
||||
| **Memory** | File-based memory system with frontmatter metadata |
|
||||
| **Edit Log** | Append-only edit history with configurable retention |
|
||||
| **Rate Limiting** | Sliding-window per-client rate limiter |
|
||||
| **JWT Auth** | HS256 JWT access/refresh tokens (API mode) |
|
||||
| **Password Auth** | Argon2 password hashing with pepper |
|
||||
| **OAuth Loopback** | Localhost HTTP server for OAuth redirect capture |
|
||||
| **Background Jobs** | Long-running shell jobs with cancellation and output collection |
|
||||
| **Settings** | JSON-persisted settings with hot-reload |
|
||||
|
||||
- **Rust** 2021 edition toolchain ([rustup](https://rustup.rs/))
|
||||
---
|
||||
|
||||
### Build from Source
|
||||
## TUI Overlays
|
||||
|
||||
16 overlays accessible from the terminal UI:
|
||||
|
||||
| Overlay | Purpose |
|
||||
|---------|---------|
|
||||
| Chat Input | Main input bar with autocomplete |
|
||||
| Bash Panel | Interactive shell panel |
|
||||
| File Editor | Built-in file editor |
|
||||
| Effort Selector | LLM reasoning effort selector |
|
||||
| Help | Keybindings reference |
|
||||
| Key Input | Custom key binding configuration |
|
||||
| Learning | Lesson viewer |
|
||||
| Loading | Generating spinner |
|
||||
| MCP Manager | MCP server management |
|
||||
| Model Selector | LLM model picker |
|
||||
| Quit Confirm | Exit confirmation dialog |
|
||||
| Rewind | Message/history rewind |
|
||||
| Settings | Settings panel |
|
||||
| Todo | Task/TODO list |
|
||||
| Usage | Token usage statistics |
|
||||
| Workflow | Hive-mind node progress |
|
||||
|
||||
---
|
||||
|
||||
## Data & Persistence
|
||||
|
||||
All data lives under the platform's data directory (`~/.local/share/zesdex/`):
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd zesdex
|
||||
cargo build --release
|
||||
./target/release/zesdex
|
||||
```
|
||||
|
||||
~/.local/share/zesdex/
|
||||
├── settings.json # User settings (provider, model, keys)
|
||||
├── app_config.json # Provider definitions (endpoints, env vars)
|
||||
├── sessions/ # Chat sessions (one subdirectory per session)
|
||||
│ └── <uuid>/
|
||||
│ ├── session.json # Session metadata
|
||||
│ ├── messages.jsonl # Message log
|
||||
│ └── .lock # Session lock file
|
||||
└── memories/ # Memory files with frontmatter metadata
|
||||
└── *.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Commit Convention
|
||||
```bash
|
||||
# Build all crates
|
||||
cargo build
|
||||
|
||||
Project ini menggunakan **Conventional Commits** untuk otomatis menentukan versi rilis (melalui semantic-release).
|
||||
# Run all unit tests (8 tests across 11 crates)
|
||||
cargo test
|
||||
|
||||
Format:
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
# Run clippy linting
|
||||
cargo clippy --all-targets
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
# Run with verbose logging
|
||||
RUST_LOG=debug cargo run
|
||||
```
|
||||
|
||||
**`<type>` — menentukan bump version:**
|
||||
### Workspace Crates
|
||||
|
||||
| Type | Bump | Keterangan |
|
||||
|-------------|---------|-------------------------------------------|
|
||||
| `feat` | minor | Fitur baru |
|
||||
| `fix` | patch | Perbaikan bug |
|
||||
| `chore` | patch | Tugas maintenance, refactor ringan |
|
||||
| `docs` | patch | Perubahan dokumentasi |
|
||||
| `refactor` | patch | Refactor kode (tanpa perubahan fungsional)|
|
||||
| `test` | patch | Penambahan atau perbaikan test |
|
||||
| `style` | patch | Perubahan formatting, whitespace, dll |
|
||||
| `perf` | patch | Optimasi performa |
|
||||
| `ci` | patch | Perubahan CI/CD |
|
||||
| Crate | Path | Layer |
|
||||
|-------|------|-------|
|
||||
| `zesdex-domain` | `apps/domain/` | Pure domain entities & traits |
|
||||
| `zesdex-application` | `apps/application/` | Use-case services |
|
||||
| `zesdex-infrastructure` | `apps/infrastructure/` | All I/O & tool implementations |
|
||||
| `zesdex-tui` | `apps/interfaces/tui/` | Ratatui terminal interface |
|
||||
| `zesdex-api` | `apps/interfaces/api/` | Axum REST API |
|
||||
| `zesdex-daemon` | `apps/interfaces/daemon/` | Unix socket daemon |
|
||||
| `zesdex-ws` | `apps/interfaces/ws/` | WebSocket server |
|
||||
| `zesdex-grpc` | `apps/interfaces/grpc/` | gRPC server |
|
||||
| `zesdex-web` | `apps/interfaces/web/` | Web frontend |
|
||||
| `zesdex-gateway` | `apps/gateway/` | CLI entry point & dispatcher |
|
||||
| `zesdex-bootstrap` | `apps/bootstrap/` | Initial data seeder |
|
||||
|
||||
**`BREAKING CHANGE`** pada body commit → **major** (apa pun typenya).
|
||||
### Code Map
|
||||
|
||||
Contoh:
|
||||
```
|
||||
feat(agent): add workspace-aware file search
|
||||
Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
|
||||
Implement context-aware search scoped to current workspace directory.
|
||||
|
||||
BREAKING CHANGE: search results now filter by workspace scope.
|
||||
```
|
||||
|
||||
```
|
||||
fix(ipc): handle partial frame on unix socket reconnect
|
||||
```
|
||||
|
||||
```
|
||||
chore: update rustls to 0.23
|
||||
```
|
||||
|
||||
### Release Workflow
|
||||
|
||||
Push ke branch `main` akan memicu:
|
||||
1. **CI** — `cargo build --release` + `cargo test`
|
||||
2. **Semantic Release** — analisis commit → update `Cargo.toml` + `CHANGELOG.md` → git tag → GitHub Release dengan binary
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| `docs/CODEMAPS/architecture.md` | System layout, process modes, data flow |
|
||||
| `docs/CODEMAPS/backend.md` | Provider, OAuth, IPC, workflow engine, MCP, LSP, review |
|
||||
| `docs/CODEMAPS/frontend.md` | TUI render pipeline, 16 overlays, toasts, input handling |
|
||||
| `docs/CODEMAPS/data.md` | Persistence, SQLite msglog, memory files, settings/config |
|
||||
| `docs/CODEMAPS/dependencies.md` | All Rust crates and external services |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
See `CHANGELOG.md` for release history.
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
[package]
|
||||
name = "zesdex-iam"
|
||||
name = "zesdex-application"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Application layer — port traits (interfaces), use cases, DTOs.
|
||||
# Depends ONLY on domain. Application services orchestrate domain objects
|
||||
# through port traits without knowing concrete implementations.
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
zesdex-entities = { path = "../zesdex-entities" }
|
||||
zesdex-utils = { path = "../zesdex-utils" }
|
||||
reqwest.workspace = true
|
||||
libc.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
tokio.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
url.workspace = true
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Mandatory explore phase — spawns parallel subagents to discover context
|
||||
//! before the main agent begins its turn.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Before the main agent's LLM loop, [`ExploreService::explore`] dispatches
|
||||
//! at least 3 subagents in parallel (code-structure scan, symbol-index query,
|
||||
//! semantic-context search). Their findings are consolidated into a single
|
||||
//! system message that is prepended to the conversation.
|
||||
//!
|
||||
//! # Why mandatory
|
||||
//!
|
||||
//! Without structured exploration the main agent works from an empty context
|
||||
//! window. The explore phase guarantees that every turn starts with a compact
|
||||
//! snapshot of what the codebase contains and where relevant code lives.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use zesdex_domain::agent::TurnEvent;
|
||||
|
||||
/// The consolidated output of an explore phase — a set of system-level
|
||||
/// context messages injected before the main agent prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExploreOutput {
|
||||
/// One or more system messages summarising what the explore subagents
|
||||
/// discovered. Prepended to the conversation by the turn service.
|
||||
pub context_messages: Vec<String>,
|
||||
/// Short human-readable summary of what was explored.
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Service trait for the mandatory pre-turn exploration phase.
|
||||
///
|
||||
/// Implementors spawn ≥3 parallel subagents, each analysing a different
|
||||
/// aspect of the workspace, and return a consolidated summary.
|
||||
///
|
||||
/// # Object safety
|
||||
///
|
||||
/// This trait is `dyn`-safe — it returns `Pin<Box<dyn Future>>` so it can
|
||||
/// be stored as `Arc<dyn ExploreService>`.
|
||||
pub trait ExploreService: Send + Sync {
|
||||
/// Run the explore phase.
|
||||
///
|
||||
/// `query` — the user's current input phrase.
|
||||
/// `workspace_root` — absolute path to the workspace root.
|
||||
/// `turn_events` — shared event queue for TUI updates.
|
||||
/// Returns structured context messages and a summary blob.
|
||||
fn explore<'a>(
|
||||
&'a self,
|
||||
query: &'a str,
|
||||
workspace_root: &'a str,
|
||||
turn_events: &'a Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ExploreOutput>> + Send + 'a>>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
use zesdex_domain::agent::AgentTurnParams;
|
||||
|
||||
/// Interface for dispatching tool calls to their concrete implementations.
|
||||
pub trait ToolExecutor: Send + Sync {
|
||||
/// Execute a tool call asynchronously.
|
||||
fn execute(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> impl Future<Output = Result<String>> + Send;
|
||||
}
|
||||
|
||||
/// Service for running agent turns asynchronously.
|
||||
pub trait AgentTurnService: Send + Sync {
|
||||
/// Run a full agent turn loop asynchronously.
|
||||
fn run_turn(
|
||||
&self,
|
||||
params: AgentTurnParams,
|
||||
) -> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
pub mod explore;
|
||||
pub mod turn_service;
|
||||
|
||||
pub use explore::{ExploreOutput, ExploreService};
|
||||
pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl};
|
||||
@@ -0,0 +1,377 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
|
||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||
use zesdex_domain::main_agent_prompt;
|
||||
|
||||
use crate::ports::ProviderService;
|
||||
use super::{ExploreService, ToolExecutor};
|
||||
|
||||
/// Maximum tool-call iterations per agent turn before forcing termination.
|
||||
const MAX_TURN_ITERATIONS: u32 = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: push a TurnEvent onto the shared queue.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||
if let Ok(mut q) = queue.lock() {
|
||||
q.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: stream-event callback that forwards tokens to the turn-event queue
|
||||
// and checks the abort flag on each emission.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_stream_callback(
|
||||
abort: &Arc<AtomicBool>,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> Box<dyn FnMut(&StreamEvent) -> bool + Send> {
|
||||
let abort_clone = Arc::clone(abort);
|
||||
let events_clone = Arc::clone(turn_events);
|
||||
Box::new(move |event: &StreamEvent| -> bool {
|
||||
if abort_clone.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
match event {
|
||||
StreamEvent::Token(s) => {
|
||||
push_event(&events_clone, TurnEvent::StreamToken(s.clone()));
|
||||
}
|
||||
StreamEvent::Reasoning(s) => {
|
||||
push_event(&events_clone, TurnEvent::StreamReasoning(s.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: execute a single tool call, push events, return the result string.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn execute_tool_call<T: ToolExecutor>(
|
||||
tool_executor: &T,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
tc: &zesdex_domain::core::ToolCall,
|
||||
) -> String {
|
||||
let name = &tc.function.name;
|
||||
let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments);
|
||||
|
||||
debug!("executing tool: {name}");
|
||||
|
||||
let output = match tool_executor.execute(name, &args).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
};
|
||||
|
||||
let is_error = output.starts_with("Error:");
|
||||
|
||||
push_event(
|
||||
turn_events,
|
||||
TurnEvent::ToolResult {
|
||||
tool_call_id: tc.id.clone(),
|
||||
tool_name: name.clone(),
|
||||
output: output.clone(),
|
||||
is_error,
|
||||
path: None,
|
||||
},
|
||||
);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: emit usage event from optional LLM response metadata.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn emit_usage(turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, usage: Option<(u64, u64)>) {
|
||||
if let Some((tokens_in, tokens_out)) = usage {
|
||||
push_event(
|
||||
turn_events,
|
||||
TurnEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Service implementation for executing an agent turn asynchronously.
|
||||
///
|
||||
/// # Explore phase
|
||||
///
|
||||
/// Before the main LLM loop begins, [`AgentTurnServiceImpl`] runs a mandatory
|
||||
/// explore phase that spawns ≥3 parallel subagents (code structure, symbol
|
||||
/// index, semantic context) and injects their consolidated findings as a
|
||||
/// system message. See [`ExploreService`] for the trait contract.
|
||||
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
||||
provider: Arc<P>,
|
||||
tool_executor: Arc<T>,
|
||||
tool_defs: Vec<ToolDef>,
|
||||
/// Optional explore-phase service. When `Some`, the explore phase runs
|
||||
/// before every turn; when `None` it is skipped (tests, daemon mode).
|
||||
explore_service: Option<Arc<dyn ExploreService>>,
|
||||
}
|
||||
|
||||
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
||||
pub fn new(
|
||||
provider: Arc<P>,
|
||||
tool_executor: Arc<T>,
|
||||
tool_defs: Vec<ToolDef>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
tool_executor,
|
||||
tool_defs,
|
||||
explore_service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach an optional explore-phase service.
|
||||
///
|
||||
/// When set, every call to `run_turn` will first run the explore phase
|
||||
/// and inject the consolidated context as a system message.
|
||||
pub fn with_explore(mut self, service: Arc<dyn ExploreService>) -> Self {
|
||||
self.explore_service = Some(service);
|
||||
self
|
||||
}
|
||||
|
||||
/// Execute a single LLM call with the current message list, handling
|
||||
/// streaming events and error reporting.
|
||||
async fn call_llm(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
abort: &Arc<AtomicBool>,
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>), String> {
|
||||
let on_event = make_stream_callback(abort, turn_events);
|
||||
|
||||
self.provider
|
||||
.chat_stream(
|
||||
messages,
|
||||
Some(self.tool_defs.clone()),
|
||||
Some(4096),
|
||||
Some(0.7),
|
||||
on_event,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("LLM error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> {
|
||||
async fn run_turn(&self, mut params: AgentTurnParams) -> anyhow::Result<()> {
|
||||
info!(
|
||||
"Starting async agent turn with {} messages (model: {})",
|
||||
params.messages.len(),
|
||||
params.model
|
||||
);
|
||||
|
||||
// ── Phase 0: Mandatory explore ──────────────────────────────────
|
||||
// Spawn ≥3 parallel subagents to discover code structure, symbols,
|
||||
// and semantic context. The consolidated summary is injected as a
|
||||
// system message before the main agent prompt.
|
||||
if let Some(ref explorer) = self.explore_service {
|
||||
// Determine workspace root from the first message's context or
|
||||
// the first workspace root in params.
|
||||
let user_query = params
|
||||
.messages
|
||||
.last()
|
||||
.map(|m| m.content.clone().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let workspace_root = params
|
||||
.workspace_roots
|
||||
.first()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::SystemNote {
|
||||
kind: "info".into(),
|
||||
message: "🔍 Exploring codebase structure...".into(),
|
||||
},
|
||||
);
|
||||
|
||||
match explorer.explore(&user_query, &workspace_root, ¶ms.turn_events).await {
|
||||
Ok(output) => {
|
||||
// Insert each context message as a system message.
|
||||
// They go at index 0 and are removed after the turn
|
||||
// like the main agent prompt.
|
||||
for ctx_msg in &output.context_messages {
|
||||
params
|
||||
.messages
|
||||
.insert(0, ChatMessage::system(ctx_msg.clone()));
|
||||
}
|
||||
info!(
|
||||
"Explore phase complete: {} context messages, {}",
|
||||
output.context_messages.len(),
|
||||
output.summary
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Explore phase failed (non-fatal): {e}");
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::SystemNote {
|
||||
kind: "warn".into(),
|
||||
message: format!("Explore phase failed: {e}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert system prompt at position 0 once and keep it there for the
|
||||
// entire turn, avoiding per-iteration clones of the full message list.
|
||||
// It is removed before emitting the Compacted event so persistence
|
||||
// does not store the prompt redundantly.
|
||||
params.messages.insert(0, ChatMessage::system(main_agent_prompt()));
|
||||
let original_count = params.messages.len();
|
||||
|
||||
for iteration in 0..MAX_TURN_ITERATIONS {
|
||||
// ── Check abort flag ────────────────────────────────────────
|
||||
if params.abort.load(Ordering::SeqCst) {
|
||||
params.abort.store(false, Ordering::SeqCst);
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::SystemNote {
|
||||
kind: "info".into(),
|
||||
message: "Turn aborted by user".into(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
debug!("agent turn iteration {iteration}");
|
||||
|
||||
// ── Stream start + call LLM ─────────────────────────────────
|
||||
push_event(¶ms.turn_events, TurnEvent::StreamStart);
|
||||
|
||||
// Uses params.messages directly (sys_msg[0] already in place
|
||||
// from the insert above) — no per-iteration clone needed.
|
||||
let result = self
|
||||
.call_llm(¶ms.messages, ¶ms.abort, ¶ms.turn_events)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((assistant_msg, usage)) => {
|
||||
let content = assistant_msg.content.clone().unwrap_or_default();
|
||||
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::StreamDone(assistant_msg.clone()),
|
||||
);
|
||||
|
||||
emit_usage(¶ms.turn_events, usage);
|
||||
|
||||
// ── No tool calls → assistant is done ──────────────
|
||||
if tool_calls.is_empty() {
|
||||
params.messages.push(ChatMessage::assistant(Some(content)));
|
||||
break;
|
||||
}
|
||||
|
||||
params.messages.push(assistant_msg);
|
||||
|
||||
// ── Execute each tool call ──────────────────────────
|
||||
for tc in &tool_calls {
|
||||
let output =
|
||||
execute_tool_call(self.tool_executor.as_ref(), ¶ms.turn_events, tc).await;
|
||||
params
|
||||
.messages
|
||||
.push(ChatMessage::tool(tc.id.clone(), output));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{e}");
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::Error(e),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the synthetic sys_msg before shipping events to the TUI
|
||||
// so the transcript shows only the actual user/assistant/tool exchange.
|
||||
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
|
||||
push_event(
|
||||
¶ms.turn_events,
|
||||
TurnEvent::Compacted(compacted),
|
||||
);
|
||||
push_event(¶ms.turn_events, TurnEvent::Done);
|
||||
params.in_flight.store(false, Ordering::SeqCst);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation compaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum number of recent messages to preserve during compaction.
|
||||
const COMPACT_KEEP_TAIL: usize = 6;
|
||||
|
||||
/// Compacts conversation history using AI summarisation.
|
||||
///
|
||||
/// Flow: if the message count exceeds `KEEP_TAIL + 2`, the oldest messages
|
||||
/// are drained and summarised by the LLM. The summary is inserted as a
|
||||
/// system message at the head of the remaining history.
|
||||
pub async fn compact_messages_with_ai<P: ProviderService>(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
provider: &P,
|
||||
) -> anyhow::Result<()> {
|
||||
if messages.len() <= COMPACT_KEEP_TAIL + 2 {
|
||||
return Ok(()); // Not enough messages to compact
|
||||
}
|
||||
|
||||
let split_idx = messages.len() - COMPACT_KEEP_TAIL;
|
||||
let evicted: Vec<_> = messages.drain(..split_idx).collect();
|
||||
|
||||
let mut summary_prompt = vec![
|
||||
ChatMessage::system(zesdex_domain::compaction_prompt()),
|
||||
];
|
||||
summary_prompt.extend(evicted);
|
||||
summary_prompt.push(ChatMessage::user(
|
||||
"Please summarise our previous conversation above for context continuity.".to_string(),
|
||||
));
|
||||
|
||||
match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await {
|
||||
Ok((summary_msg, _)) => {
|
||||
let summary_text = summary_msg
|
||||
.content
|
||||
.unwrap_or_else(|| "Previous context summarised.".to_string());
|
||||
let summary_node = ChatMessage::system(format!(
|
||||
"[AI Summary of Previous Conversation]\n{}",
|
||||
summary_text.trim()
|
||||
));
|
||||
messages.insert(0, summary_node);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("AI summarisation failed during compact, falling back to simple notice: {e}");
|
||||
messages.insert(
|
||||
0,
|
||||
ChatMessage::system(
|
||||
"[Earlier conversation messages compacted to save context window]".to_string(),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Auth use-case implementations.
|
||||
//!
|
||||
//! Contains concrete service types that implement the domain's
|
||||
//! authentication and session management traits by coordinating
|
||||
//! injected repository and port dependencies.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - [`oauth_service`] — `OAuthUseCase`: OAuth 2.0 authorization-code + PKCE flow
|
||||
//! - [`session_service`] — `SessionServiceImpl`: session CRUD lifecycle
|
||||
|
||||
pub mod oauth_service;
|
||||
pub mod session_service;
|
||||
|
||||
pub use oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger};
|
||||
pub use session_service::SessionServiceImpl;
|
||||
@@ -0,0 +1,250 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow use-case.
|
||||
//!
|
||||
//! `OAuthUseCase` orchestrates the standard PKCE-enhanced OAuth flow:
|
||||
//!
|
||||
//! 1. **`start_flow`** — generates a cryptographic PKCE code verifier,
|
||||
//! derives its S256 challenge, creates a CSRF state token, persists
|
||||
//! the verifier + state via `OAuthFlowStore`, and builds an
|
||||
//! authorization URL with all required parameters.
|
||||
//! 2. **`complete_flow`** — validates the returned `state` against the
|
||||
//! stored value (CSRF check), reads the stored verifier, delegates
|
||||
//! the token-code exchange to an injected `TokenExchanger`, and
|
||||
//! persists the resulting `OAuthToken` via `OAuthRepository`.
|
||||
//! 3. **`get_token`** — loads the stored OAuth token (if any).
|
||||
//!
|
||||
//! # Portability
|
||||
//!
|
||||
//! The service is generic over three injected dependencies:
|
||||
//! - `R: OAuthRepository` — token persistence
|
||||
//! - `S: OAuthFlowStore` — ephemeral flow state (verifier + CSRF state)
|
||||
//! - `E: TokenExchanger` — the HTTP token-endpoint exchange
|
||||
//!
|
||||
//! This keeps all I/O and protocol-level concerns abstracted behind
|
||||
//! port traits; the service itself contains only orchestration logic.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::auth::{OAuthConfig, OAuthRepository, OAuthToken, ServiceError};
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port traits (defined here because they are specific to this use-case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Persistence contract for ephemeral OAuth flow state.
|
||||
///
|
||||
/// Between `start_flow` and `complete_flow` the verifier and CSRF state
|
||||
/// must survive across process boundaries (the user opens a browser, the
|
||||
/// provider redirects back to a loopback listener on the next invocation).
|
||||
///
|
||||
/// Implementors store key-value pairs to disk or another durable medium
|
||||
/// and clear them after a successful (or failed) flow completion.
|
||||
pub trait OAuthFlowStore: Send + Sync {
|
||||
/// Persist the PKCE code verifier and CSRF state token.
|
||||
fn save_flow_state(
|
||||
&self,
|
||||
verifier: &str,
|
||||
state: &str,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
/// Load the stored PKCE code verifier.
|
||||
fn load_verifier(&self) -> Result<String, ServiceError>;
|
||||
|
||||
/// Load the stored CSRF state token.
|
||||
fn load_state(&self) -> Result<String, ServiceError>;
|
||||
|
||||
/// Clear stored flow state (verifier + state).
|
||||
fn clear(&self) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Abstraction for exchanging an authorization code for tokens.
|
||||
///
|
||||
/// Implementors handle the HTTP POST to the provider's token endpoint
|
||||
/// with the appropriate form-encoded parameters, parse the JSON
|
||||
/// response, and return the extracted `OAuthToken`.
|
||||
pub trait TokenExchanger: Send + Sync {
|
||||
/// Exchange an authorization code for an access token.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `token_url` — the provider's token endpoint URL
|
||||
/// - `client_id` — OAuth client identifier
|
||||
/// - `client_secret` — optional client secret
|
||||
/// - `redirect_uri` — must match the URI used in `start_flow`
|
||||
/// - `code` — the authorization code from the provider's redirect
|
||||
/// - `code_verifier` — the PKCE verifier from `start_flow`
|
||||
fn exchange_code(
|
||||
&self,
|
||||
token_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a PKCE code-verifier and its S256 code-challenge.
|
||||
///
|
||||
/// Uses 32 cryptographically random bytes, base64url-encoded (no padding)
|
||||
/// for the verifier, then SHA-256 hashes the verifier and base64url-encodes
|
||||
/// the digest for the challenge. This satisfies the PKCE `S256` method
|
||||
/// which requires a minimum verifier length of 43 characters.
|
||||
fn generate_pkce_pair() -> (String, String) {
|
||||
// 32 random bytes → 43 base64url chars (well above the 43-char PKCE
|
||||
// minimum).
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
|
||||
let verifier = URL_SAFE_NO_PAD.encode(bytes);
|
||||
let challenge = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||
};
|
||||
(verifier, challenge)
|
||||
}
|
||||
|
||||
/// Generate a random CSRF state token (UUID-based, 36 chars).
|
||||
fn generate_state_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concrete OAuth flow use-case.
|
||||
///
|
||||
/// Generic over three dependencies:
|
||||
/// - `R` — token persistence (`OAuthRepository`)
|
||||
/// - `S` — flow-state persistence (`OAuthFlowStore`)
|
||||
/// - `E` — token-endpoint HTTP exchange (`TokenExchanger`)
|
||||
pub struct OAuthUseCase<R, S, E> {
|
||||
/// Repository for persisting / loading OAuth tokens.
|
||||
pub token_repo: R,
|
||||
/// Store for ephemeral flow state (verifier + CSRF state).
|
||||
pub flow_store: S,
|
||||
/// Token-endpoint HTTP exchanger.
|
||||
pub token_exchanger: E,
|
||||
/// File path for the token JSON file.
|
||||
pub token_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
|
||||
/// Create a new OAuth use-case.
|
||||
pub fn new(
|
||||
token_repo: R,
|
||||
flow_store: S,
|
||||
token_exchanger: E,
|
||||
token_path: PathBuf,
|
||||
) -> Self {
|
||||
OAuthUseCase {
|
||||
token_repo,
|
||||
flow_store,
|
||||
token_exchanger,
|
||||
token_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
|
||||
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
|
||||
{
|
||||
fn start_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(String, String), ServiceError> {
|
||||
if config.auth_url.is_empty() {
|
||||
return Err(ServiceError::InvalidConfig(
|
||||
"OAuth auth_url is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (verifier, challenge) = generate_pkce_pair();
|
||||
let state = generate_state_token();
|
||||
|
||||
// Persist verifier + state so `complete_flow` can retrieve them.
|
||||
self.flow_store.save_flow_state(&verifier, &state)?;
|
||||
|
||||
tracing::debug!(
|
||||
auth_url = %config.auth_url,
|
||||
redirect_uri = %redirect_uri,
|
||||
state_len = state.len(),
|
||||
"starting OAuth flow",
|
||||
);
|
||||
|
||||
let mut url = url::Url::parse(&config.auth_url)
|
||||
.map_err(|e| {
|
||||
ServiceError::InvalidConfig(format!(
|
||||
"invalid auth_url '{}': {e}",
|
||||
config.auth_url
|
||||
))
|
||||
})?;
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &config.client_id)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("scope", &config.scopes.join(" "))
|
||||
.append_pair("state", &state)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", &challenge);
|
||||
|
||||
Ok((url.to_string(), state))
|
||||
}
|
||||
|
||||
fn complete_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OAuthToken, ServiceError> {
|
||||
// CSRF check: validate the returned state against the stored value.
|
||||
let expected_state = self.flow_store.load_state()?;
|
||||
if expected_state != state {
|
||||
return Err(ServiceError::StateMismatch);
|
||||
}
|
||||
|
||||
// Read the PKCE verifier that was saved in `start_flow`.
|
||||
let verifier = self.flow_store.load_verifier()?;
|
||||
|
||||
tracing::debug!(
|
||||
token_url = %config.token_url,
|
||||
code_len = code.len(),
|
||||
"completing OAuth flow — exchanging code for token",
|
||||
);
|
||||
|
||||
// Delegate the HTTP token exchange to the injected exchanger.
|
||||
let token = self.token_exchanger.exchange_code(
|
||||
&config.token_url,
|
||||
&config.client_id,
|
||||
config.client_secret.as_deref(),
|
||||
redirect_uri,
|
||||
code,
|
||||
&verifier,
|
||||
)?;
|
||||
|
||||
// Persist the token and clean up flow state.
|
||||
self.token_repo.save_token(&self.token_path, &token)?;
|
||||
let _ = self.flow_store.clear();
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError> {
|
||||
self.token_repo
|
||||
.load_token(&self.token_path)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Session management use-case.
|
||||
//!
|
||||
//! `SessionServiceImpl` implements [`SessionService`] from the domain
|
||||
//! layer by delegating CRUD operations to injected repository traits.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! - **`create_session`** — generates a UUID v4 id, creates a `Session`
|
||||
//! entity with the given title, persists via `SessionRepository`.
|
||||
//! - **`list_all`** — delegates to `SessionRepository::list_sessions`.
|
||||
//! - **`archive_session`** — loads session, sets `archived = true`,
|
||||
//! persists the updated entity.
|
||||
//!
|
||||
//! # Generics
|
||||
//!
|
||||
//! - `R: SessionRepository` — session CRUD persistence
|
||||
//! - `L: SessionLockRepository` — session lock acquire/release
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
use zesdex_domain::auth::{
|
||||
ServiceError, Session, SessionId, SessionLockRepository, SessionRepository,
|
||||
};
|
||||
|
||||
/// Concrete session service backed by injected repository implementations.
|
||||
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
|
||||
/// Repository for session CRUD operations.
|
||||
pub session_repo: R,
|
||||
/// Repository for session lock acquire/release.
|
||||
pub lock_repo: L,
|
||||
/// Base data directory passed to repository methods.
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
|
||||
/// Create a new session service with the given repositories and base
|
||||
/// data directory.
|
||||
pub fn new(session_repo: R, lock_repo: L, base_dir: PathBuf) -> Self {
|
||||
SessionServiceImpl {
|
||||
session_repo,
|
||||
lock_repo,
|
||||
base_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository>
|
||||
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L>
|
||||
{
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||
let id = SessionId::new(&Uuid::new_v4().to_string())
|
||||
.map_err(ServiceError::Other)?;
|
||||
let title_owned = if title.is_empty() {
|
||||
"New Session".to_string()
|
||||
} else {
|
||||
title.to_string()
|
||||
};
|
||||
let session = Session::new(id.into_string(), title_owned);
|
||||
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
||||
self.session_repo
|
||||
.save_session(&self.base_dir, &session)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn list_all(&self) -> Result<Vec<Session>, ServiceError> {
|
||||
tracing::debug!("listing all sessions");
|
||||
self.session_repo
|
||||
.list_sessions(&self.base_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
||||
tracing::debug!(session_id = %id, "archiving session");
|
||||
let mut session = self
|
||||
.session_repo
|
||||
.load_session(&self.base_dir, &id)?;
|
||||
session.archived = true;
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
|
||||
self.session_repo
|
||||
.save_session(&self.base_dir, &session)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Conversation use-case implementation.
|
||||
//!
|
||||
//! `ConversationServiceImpl` implements [`ConversationService`] from the
|
||||
//! domain layer. It is generic over `R: ConversationRepository`, delegating
|
||||
//! all persistence to that adapter.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method computes the session directory from the session ID, then
|
||||
//! delegates the actual I/O to the injected `repo`. Error context is
|
||||
//! added at this layer to identify which session caused the failure.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{Conversation, ConversationRepository, ServiceError};
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Service implementation for conversation CRUD operations.
|
||||
///
|
||||
/// Generic over `R: ConversationRepository` so the persistence layer
|
||||
/// can be swapped without changing business logic.
|
||||
pub struct ConversationServiceImpl<R> {
|
||||
pub repo: R,
|
||||
/// Base directory containing session subdirectories.
|
||||
pub sessions_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> ConversationServiceImpl<R> {
|
||||
/// Create a new service with the given repository and sessions directory.
|
||||
pub fn new(repo: R, sessions_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating ConversationServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
sessions_dir: sessions_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the session directory for a given session id.
|
||||
fn session_dir(&self, session_id: &str) -> PathBuf {
|
||||
self.sessions_dir.join(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
|
||||
for ConversationServiceImpl<R>
|
||||
{
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
|
||||
tracing::debug!("loading conversation for session {session_id}");
|
||||
let dir = self.session_dir(session_id);
|
||||
self.repo.load(&dir).map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving conversation for session {}", conv.session_id);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError> {
|
||||
tracing::debug!("adding message to session {}", conv.session_id);
|
||||
conv.push(msg);
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo.save(&dir, conv)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Memory use-case implementation.
|
||||
//!
|
||||
//! `MemoryServiceImpl` implements [`MemoryService`] from the domain
|
||||
//! layer. It is generic over `R: MemoryRepository`, delegating all
|
||||
//! persistence to that adapter.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method delegates to the injected `repo` with the configured
|
||||
//! `memory_dir`. Error context is added at this layer to identify which
|
||||
//! memory operation failed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{Memory, MemoryRepository, ServiceError};
|
||||
|
||||
/// Service implementation for memory CRUD operations.
|
||||
///
|
||||
/// Generic over `R: MemoryRepository` so the persistence layer can be
|
||||
/// swapped without changing business logic.
|
||||
pub struct MemoryServiceImpl<R> {
|
||||
pub repo: R,
|
||||
/// Base directory for memory storage files.
|
||||
pub memory_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> MemoryServiceImpl<R> {
|
||||
/// Create a new service with the given repository and memory directory.
|
||||
pub fn new(repo: R, memory_dir: impl Into<PathBuf>) -> Self {
|
||||
tracing::debug!("creating MemoryServiceImpl");
|
||||
Self {
|
||||
repo,
|
||||
memory_dir: memory_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> zesdex_domain::cms::MemoryService for MemoryServiceImpl<R> {
|
||||
fn list_memories(&self) -> Result<Vec<String>, ServiceError> {
|
||||
tracing::debug!("listing memories from {:?}", self.memory_dir);
|
||||
self.repo
|
||||
.list(&self.memory_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving memory '{}'", memory.name);
|
||||
self.repo.save(&self.memory_dir, memory)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_memory(&self, name: &str) -> Result<(), ServiceError> {
|
||||
tracing::debug!("deleting memory '{name}'");
|
||||
self.repo
|
||||
.delete(&self.memory_dir, name)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! CMS use-case implementations.
|
||||
//!
|
||||
//! Contains concrete service types that implement the domain's CMS
|
||||
//! service traits by coordinating injected repository dependencies.
|
||||
//!
|
||||
//! # Use Cases
|
||||
//!
|
||||
//! - [`conversation_service`] — `ConversationServiceImpl`: conversation CRUD
|
||||
//! - [`memory_service`] — `MemoryServiceImpl`: long-term memory management
|
||||
//! - [`settings_service`] — `SettingsServiceImpl`: settings & app-config management
|
||||
|
||||
pub mod conversation_service;
|
||||
pub mod memory_service;
|
||||
pub mod settings_service;
|
||||
|
||||
pub use conversation_service::ConversationServiceImpl;
|
||||
pub use memory_service::MemoryServiceImpl;
|
||||
pub use settings_service::SettingsServiceImpl;
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Settings and app-config use-case implementation.
|
||||
//!
|
||||
//! `SettingsServiceImpl` implements [`SettingsService`] from the domain
|
||||
//! layer. It is generic over `S: SettingsRepository` and `C: AppConfigRepository`,
|
||||
//! delegating persistence to those adapters.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each method delegates to the appropriate injected repository with the
|
||||
//! configured `base_dir`. The `update_provider` method coordinates between
|
||||
//! both repositories: load app config → mutate provider map → save app config.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
use zesdex_domain::cms::{
|
||||
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings,
|
||||
SettingsRepository,
|
||||
};
|
||||
|
||||
/// Service implementation for settings and app-config operations.
|
||||
///
|
||||
/// Generic over `S: SettingsRepository` and `C: AppConfigRepository` so
|
||||
/// the persistence layer can be swapped without changing business logic.
|
||||
pub struct SettingsServiceImpl<S, C> {
|
||||
pub settings_repo: S,
|
||||
pub app_config_repo: C,
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||
/// Create a new service with the given repositories and base directory.
|
||||
pub fn new(
|
||||
settings_repo: S,
|
||||
app_config_repo: C,
|
||||
base_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
tracing::debug!("creating SettingsServiceImpl");
|
||||
Self {
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
base_dir: base_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository>
|
||||
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C>
|
||||
{
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError> {
|
||||
tracing::debug!("loading settings");
|
||||
self.settings_repo
|
||||
.load(&self.base_dir)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> {
|
||||
tracing::debug!("saving settings");
|
||||
self.settings_repo.save(&self.base_dir, settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &ProviderConfig,
|
||||
) -> Result<(), ServiceError> {
|
||||
tracing::debug!("updating provider '{name}'");
|
||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||
app_config
|
||||
.providers
|
||||
.insert(name.to_string(), config.clone());
|
||||
self.app_config_repo.save(&self.base_dir, &app_config)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! # Zesdex Application Layer
|
||||
//!
|
||||
//! Defines port traits (interfaces) and use-case implementations for the
|
||||
//! Zesdex application. This crate depends **only** on the domain crate;
|
||||
//! it has no knowledge of infrastructure or interface adapters.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/application/src/
|
||||
//! ├── lib.rs — crate root, re-exports
|
||||
//! ├── ports/ — Port traits (interfaces to external services)
|
||||
//! │ ├── provider.rs -- ProviderService (LLM chat completion)
|
||||
//! │ ├── password.rs -- PasswordService (hash / verify)
|
||||
//! │ ├── token.rs -- TokenService (JWT create / verify)
|
||||
//! │ └── authentication.rs -- AuthService (combined auth)
|
||||
//! ├── auth/ — Auth use-cases
|
||||
//! │ ├── oauth_service.rs -- OAuth 2.0 PKCE flow
|
||||
//! │ └── session_service.rs -- Session CRUD lifecycle
|
||||
//! └── cms/ — CMS use-cases
|
||||
//! ├── conversation_service.rs -- Conversation CRUD
|
||||
//! ├── memory_service.rs -- Long-term memory management
|
||||
//! └── settings_service.rs -- Settings & app-config management
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//!
|
||||
//! Application services are generic over their repository/port dependencies.
|
||||
//! Concrete implementations are injected at the composition root, keeping
|
||||
//! the use-case logic independent of any specific persistence or infrastructure
|
||||
//! technology.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cms;
|
||||
pub mod ports;
|
||||
pub mod agent;
|
||||
|
||||
// Re-export port traits for ergonomic access.
|
||||
pub use ports::*;
|
||||
|
||||
// Re-export auth use-cases.
|
||||
pub use auth::{
|
||||
oauth_service::{OAuthFlowStore, OAuthUseCase, TokenExchanger},
|
||||
session_service::SessionServiceImpl,
|
||||
};
|
||||
|
||||
// Re-export CMS use-cases.
|
||||
pub use cms::{
|
||||
conversation_service::ConversationServiceImpl,
|
||||
memory_service::MemoryServiceImpl,
|
||||
settings_service::SettingsServiceImpl,
|
||||
};
|
||||
|
||||
pub use agent::{
|
||||
AgentTurnService, ExploreOutput, ExploreService, ToolExecutor,
|
||||
turn_service::{AgentTurnServiceImpl, compact_messages_with_ai},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
//! AuthService port — combined authentication operations.
|
||||
//!
|
||||
//! Defines a high-level authentication trait that composes password
|
||||
//! verification and token generation into a single use-case boundary.
|
||||
//! Implementations delegate to the injected `PasswordService` and
|
||||
//! `TokenService` adapters.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
/// High-level authentication service combining password verification
|
||||
/// and token issuance (login flow).
|
||||
///
|
||||
/// # Flow
|
||||
///
|
||||
/// 1. **`authenticate`** — verify a subject's password against a stored hash.
|
||||
/// 2. **`issue_tokens`** — generate an access + refresh token pair for a subject.
|
||||
///
|
||||
/// Implementations are generic over `PasswordService` and `TokenService`
|
||||
/// port traits.
|
||||
pub trait AuthService: Send + Sync {
|
||||
/// Authenticate a user by verifying a password against a stored hash.
|
||||
///
|
||||
/// Returns `true` if the password matches, `false` otherwise.
|
||||
fn authenticate(
|
||||
&self,
|
||||
password: &str,
|
||||
hash: &str,
|
||||
) -> impl Future<Output = Result<bool>> + Send;
|
||||
|
||||
/// Issue a new access + refresh token pair for the given subject.
|
||||
///
|
||||
/// Returns `(access_token, refresh_token)`.
|
||||
fn issue_tokens(&self, sub: &str) -> Result<(String, String)>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Port traits — interfaces for external / infrastructure services.
|
||||
//!
|
||||
//! These traits define the boundaries between the application layer and
|
||||
//! the outside world. Infrastructure adapters implement these traits;
|
||||
//! the application layer depends only on the trait definitions.
|
||||
//!
|
||||
//! # Ports
|
||||
//!
|
||||
//! - [`provider`] — `ProviderService`: LLM chat completion (streaming + non-streaming)
|
||||
//! - [`password`] — `PasswordService`: password hashing and verification
|
||||
//! - [`token`] — `TokenService`: JWT access/refresh token generation and verification
|
||||
//! - [`authentication`] — `AuthService`: combined authentication operations
|
||||
|
||||
pub mod authentication;
|
||||
pub mod password;
|
||||
pub mod provider;
|
||||
pub mod token;
|
||||
|
||||
pub use authentication::AuthService;
|
||||
pub use password::PasswordService;
|
||||
pub use provider::ProviderService;
|
||||
pub use token::TokenService;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! PasswordService port — password hashing and verification abstraction.
|
||||
//!
|
||||
//! Defines the trait that password-hashing adapters (argon2, bcrypt, etc.)
|
||||
//! implement. The application layer depends only on this trait, never on
|
||||
//! a concrete hashing library.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
/// Abstraction for password hashing and verification.
|
||||
///
|
||||
/// Implementors handle the actual hashing algorithm (argon2, bcrypt, etc.)
|
||||
/// and parameter selection. The trait is `Send + Sync` for use in async
|
||||
/// service layers.
|
||||
pub trait PasswordService: Send + Sync {
|
||||
/// Hash a plaintext password and return the encoded hash string
|
||||
/// (suitable for storage in a credential store).
|
||||
fn hash(&self, password: &str) -> impl Future<Output = Result<String>> + Send;
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed string.
|
||||
///
|
||||
/// Returns `true` if the password matches the hash, `false` otherwise.
|
||||
fn verify(&self, password: &str, hash: &str) -> impl Future<Output = Result<bool>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! ProviderService port — LLM chat completion provider abstraction.
|
||||
//!
|
||||
//! Defines the trait that HTTP-based provider clients (OpenAI, Anthropic,
|
||||
//! etc.) implement. Supports both non-streaming and SSE-streaming chat
|
||||
//! completion requests.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. Caller builds a message list and optional tool definitions.
|
||||
//! 2. `chat` sends a non-streaming request and returns the full response.
|
||||
//! 3. `chat_stream` sends a streaming request and invokes `on_event` for
|
||||
//! each parsed `StreamEvent` as it arrives, then returns the assembled
|
||||
//! message and usage.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::future::Future;
|
||||
|
||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||
|
||||
/// Abstraction for an LLM provider chat-completion service.
|
||||
///
|
||||
/// Both methods accept a message list, optional tool definitions, and
|
||||
/// generation parameters. Implementors handle authentication, HTTP
|
||||
/// transport, retry logic, and response parsing internally.
|
||||
///
|
||||
/// # Send + Sync
|
||||
///
|
||||
/// This trait is `Send + Sync` so it can be shared across async tasks
|
||||
/// and injected into service structs that require thread safety.
|
||||
pub trait ProviderService: Send + Sync {
|
||||
/// Send a non-streaming chat completion request.
|
||||
///
|
||||
/// Returns the assistant's `ChatMessage` and optional token usage
|
||||
/// `(prompt_tokens, completion_tokens)`.
|
||||
fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
|
||||
|
||||
/// Send a streaming chat completion request.
|
||||
///
|
||||
/// `on_event` is called for every parsed SSE event and returns `false`
|
||||
/// to signal abort (caller cancellation). Returns the fully assembled
|
||||
/// assistant message and optional usage once the stream completes.
|
||||
fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
on_event: Box<dyn FnMut(&StreamEvent) -> bool + Send>,
|
||||
) -> impl Future<Output = Result<(ChatMessage, Option<(u64, u64)>)>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! TokenService port — JWT access and refresh token abstraction.
|
||||
//!
|
||||
//! Defines the trait that JWT adapter implementations provide. Covers
|
||||
//! token generation (pair of access + refresh tokens) and access token
|
||||
//! verification (returns the subject claim).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Abstraction for JWT-based token generation and verification.
|
||||
///
|
||||
/// Implementors handle signing key management, token serialisation,
|
||||
/// and expiry validation. The trait is `Send + Sync` for use across
|
||||
/// thread boundaries.
|
||||
pub trait TokenService: Send + Sync {
|
||||
/// Generate an access + refresh token pair for the given subject
|
||||
/// identifier.
|
||||
///
|
||||
/// Returns `(access_token, refresh_token)`.
|
||||
fn generate_tokens(&self, sub: &str) -> Result<(String, String)>;
|
||||
|
||||
/// Verify an access token and return the embedded subject claim.
|
||||
///
|
||||
/// Returns `Err` if the token is expired, malformed, or has an
|
||||
/// invalid signature.
|
||||
fn verify_access_token(&self, token: &str) -> Result<String>;
|
||||
|
||||
/// Verify a refresh token and return the embedded subject claim.
|
||||
///
|
||||
/// Returns `Err` if the token is expired, malformed, or has an
|
||||
/// invalid signature.
|
||||
fn verify_refresh_token(&self, token: &str) -> Result<String>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "zesdex-bootstrap"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Bootstrap binary — seeds initial system data (permissions, roles,
|
||||
# admin user) idempotently. Run once after first deployment.
|
||||
[[bin]]
|
||||
name = "bootstrap"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
zesdex-infrastructure = { path = "../infrastructure" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Bootstrap library — shared utilities for the bootstrap binary.
|
||||
//! The main entry point is in `main.rs`.
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Bootstrap binary — seeds initial system data idempotently.
|
||||
//!
|
||||
//! Creates default permissions, roles, and admin user if they don't
|
||||
//! already exist. Run once after first deployment.
|
||||
//!
|
||||
//! Usage: cargo run --bin bootstrap
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
println!("Zesdex Bootstrap — seeding initial data...");
|
||||
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
// Seed default settings if not present
|
||||
let settings_path = store.base_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
let settings = zesdex_domain::cms::Settings::default();
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
let tmp = store.base_dir.join("settings.json.tmp");
|
||||
std::fs::write(&tmp, &content)?;
|
||||
std::fs::File::open(&tmp)?.sync_all()?;
|
||||
std::fs::rename(&tmp, &settings_path)?;
|
||||
println!(" ✓ Default settings created");
|
||||
} else {
|
||||
println!(" · Settings already exist, skipping");
|
||||
}
|
||||
|
||||
// Seed default app config if not present
|
||||
let config_path = store.base_dir.join("app_config.json");
|
||||
if !config_path.exists() {
|
||||
let config = zesdex_domain::cms::AppConfig::default();
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
let tmp = store.base_dir.join("app_config.json.tmp");
|
||||
std::fs::write(&tmp, &content)?;
|
||||
std::fs::File::open(&tmp)?.sync_all()?;
|
||||
std::fs::rename(&tmp, &config_path)?;
|
||||
println!(" ✓ Default app_config created");
|
||||
} else {
|
||||
println!(" · App config already exists, skipping");
|
||||
}
|
||||
|
||||
println!("Bootstrap complete.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
[package]
|
||||
name = "zesdex-entities"
|
||||
name = "zesdex-domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Domain layer — PURE entities, value objects, repository/service traits.
|
||||
# Zero framework dependencies. Only serde for serialization, chrono for
|
||||
# timestamps, uuid for identity.
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
dirs.workspace = true
|
||||
libc.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
libc.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
zesdex-utils.workspace = true
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Shared default constants used across the application.
|
||||
//!
|
||||
//! Centralising these values eliminates the hardcoded-string duplication
|
||||
//! that existed when every call site provided its own inline fallback.
|
||||
//! Consumers should reference these constants rather than repeating
|
||||
//! the string literals.
|
||||
|
||||
/// Default LLM provider API base URL.
|
||||
pub const DEFAULT_API_BASE: &str = "https://opencode.ai/zen/v1";
|
||||
|
||||
/// Default LLM model identifier.
|
||||
pub const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||
|
||||
/// Fallback JWT secret used only when `JWT_SECRET` env var is unset.
|
||||
/// In production this MUST be configured via environment variable.
|
||||
pub const FALLBACK_JWT_SECRET: &str = "dev-secret";
|
||||
|
||||
/// Default context window size (128k tokens).
|
||||
pub const DEFAULT_CONTEXT_WINDOW: usize = 256_000;
|
||||
|
||||
/// Maximum tool-call iterations per agent turn.
|
||||
pub const MAX_TOOL_ITERATIONS: u32 = 50;
|
||||
|
||||
/// Maximum subagent tool-call iterations.
|
||||
pub const MAX_SUBAGENT_ITERATIONS: u32 = 25;
|
||||
|
||||
/// Default LLM request max tokens.
|
||||
pub const DEFAULT_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
/// Default temperature for the main agent.
|
||||
pub const DEFAULT_TEMPERATURE: f64 = 0.7;
|
||||
|
||||
/// Default temperature for compaction / summary calls.
|
||||
pub const DEFAULT_COMPACT_TEMPERATURE: f64 = 0.3;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Domain types for agent lifecycle: turn events, session runtime, progress
|
||||
//! reporting, prompts, and the agent-turn parameter bundle.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
||||
|
||||
pub mod defaults;
|
||||
pub mod prompt;
|
||||
pub mod progress;
|
||||
|
||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
/// The main agent turn loop.
|
||||
Main,
|
||||
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
|
||||
SubAgent,
|
||||
/// The auto-inline review step after an edit.
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main",
|
||||
Origin::SubAgent => "subagent",
|
||||
Origin::Reviewer => "reviewer",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Severity/category of a toast notification, used to pick its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Lesson,
|
||||
}
|
||||
|
||||
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
message,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
let lifetime = self.lifetime_ms as i64;
|
||||
now_ms - self.created_at > lifetime
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent status for workflow engine progress tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AgentStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgentStatus::Pending => write!(f, "pending"),
|
||||
AgentStatus::Running => write!(f, "running"),
|
||||
AgentStatus::Completed => write!(f, "completed"),
|
||||
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
|
||||
AgentStatus::Cancelled => write!(f, "cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||
/// consumed by the event loop to update state and drive re-renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEvent {
|
||||
AssistantMessage(ChatMessage),
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
path: Option<String>,
|
||||
},
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
StreamStart,
|
||||
StreamToken(String),
|
||||
StreamReasoning(String),
|
||||
StreamDone(ChatMessage),
|
||||
Usage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
ReviewUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
Compacted(Vec<ChatMessage>),
|
||||
Error(String),
|
||||
Done,
|
||||
WorkflowAgentUpdate {
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
status: AgentStatus,
|
||||
},
|
||||
TodoUpdate(String),
|
||||
PlanUpdate(String),
|
||||
/// Structured progress report from a subagent or workflow node,
|
||||
/// carrying the current tool name and optional step counters.
|
||||
AgentProgress(crate::agent::progress::AgentProgress),
|
||||
}
|
||||
|
||||
/// How a pending tool call should be executed when the turn resumes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
Deferred,
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
/// A tool call awaiting execution, along with which execution model
|
||||
/// (inline, deferred, async) it should run under.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub execution_model: ExecutionModel,
|
||||
}
|
||||
|
||||
/// Reference to a background bash job tracked in session state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Tracks counts of learned patterns by outcome and lifecycle stage.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LessonStats {
|
||||
/// Total number of lessons tracked.
|
||||
pub total: u32,
|
||||
/// User-initiated lessons.
|
||||
pub user: u32,
|
||||
/// Feedback-driven lessons.
|
||||
pub feedback: u32,
|
||||
/// Project-scoped lessons.
|
||||
pub project: u32,
|
||||
/// Reference-scoped lessons.
|
||||
pub reference: u32,
|
||||
/// Currently active lessons.
|
||||
pub active: u32,
|
||||
/// Stale (outdated) lessons.
|
||||
pub stale: u32,
|
||||
/// Contradicted lessons.
|
||||
pub contradicted: u32,
|
||||
/// Human-authored lessons.
|
||||
pub human: u32,
|
||||
/// Verified lessons.
|
||||
pub verified: u32,
|
||||
/// Unverified lessons.
|
||||
pub unverified: u32,
|
||||
}
|
||||
|
||||
/// Per-session runtime state: message history, pending tool queue,
|
||||
/// background bash jobs, lesson/review counters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
pub subagent_queue: usize,
|
||||
pub edit_count: u32,
|
||||
pub consecutive_empty_reviews: u32,
|
||||
pub session_start: i64,
|
||||
/// Aggregated lesson statistics.
|
||||
pub lessons: LessonStats,
|
||||
pub review_count: u32,
|
||||
pub session_dir: PathBuf,
|
||||
pub usage: UsageStats,
|
||||
pub hive_mind_converged: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
tool_call_results: Vec::new(),
|
||||
pending_tool_queue: Vec::new(),
|
||||
bash_jobs: Vec::new(),
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: chrono::Utc::now().timestamp_millis(),
|
||||
lessons: LessonStats::default(),
|
||||
review_count: 0,
|
||||
session_dir,
|
||||
usage: UsageStats::default(),
|
||||
hive_mind_converged: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple ASCII progress display for a long-running operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProgressState {
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
pub message: String,
|
||||
pub start_time: i64,
|
||||
}
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Owned parameters required to spawn and execute an agent turn.
|
||||
pub struct AgentTurnParams {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub session_dir: PathBuf,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
pub in_flight: Arc<AtomicBool>,
|
||||
pub abort: Arc<AtomicBool>,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub api_base: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Progress reporting types for long-running agent and subagent operations.
|
||||
//!
|
||||
//! These types are emitted onto the turn-event queue to drive the TUI's
|
||||
//! spinner, progress bar, and agent-status sidebar. They are pure domain
|
||||
//! types with no I/O or framework dependency.
|
||||
|
||||
use crate::agent::AgentStatus;
|
||||
|
||||
/// Describes progress within a single subagent or workflow-node execution.
|
||||
///
|
||||
/// Emitted as a `TurnEvent::AgentProgress` so the UI can show which tool
|
||||
/// the subagent is currently invoking, or which step it has reached.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentProgress {
|
||||
/// Unique identifier for this agent (e.g. `"Node-0-1"`, `"auto-review"`).
|
||||
pub agent_id: String,
|
||||
/// Human-readable display name shown in the TUI sidebar.
|
||||
pub agent_name: String,
|
||||
/// Current lifecycle status.
|
||||
pub status: AgentStatus,
|
||||
/// Optional description of the current tool or step being executed.
|
||||
/// Set to `None` when the agent is not actively executing a tool.
|
||||
pub current_tool: Option<String>,
|
||||
/// Optional progress range: (completed_steps, total_steps).
|
||||
/// When `None`, the agent shows an indeterminate spinner.
|
||||
pub steps: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl AgentProgress {
|
||||
/// Mark this agent as running with an optional tool name.
|
||||
pub fn running(
|
||||
agent_id: impl Into<String>,
|
||||
agent_name: impl Into<String>,
|
||||
current_tool: Option<String>,
|
||||
) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Running,
|
||||
current_tool,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this agent as pending (queued but not yet started).
|
||||
pub fn pending(agent_id: impl Into<String>, agent_name: impl Into<String>) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Pending,
|
||||
current_tool: None,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this agent as completed successfully.
|
||||
pub fn completed(agent_id: impl Into<String>, agent_name: impl Into<String>) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Completed,
|
||||
current_tool: None,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this agent as failed with an error message.
|
||||
pub fn failed(
|
||||
agent_id: impl Into<String>,
|
||||
agent_name: impl Into<String>,
|
||||
error: String,
|
||||
) -> Self {
|
||||
AgentProgress {
|
||||
agent_id: agent_id.into(),
|
||||
agent_name: agent_name.into(),
|
||||
status: AgentStatus::Failed(error),
|
||||
current_tool: None,
|
||||
steps: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! System prompts and directive templates for agent and subagent turns.
|
||||
//!
|
||||
//! Centralising all prompt text here keeps the core turn logic free of
|
||||
//! hardcoded prose, making prompts easier to maintain, review, and localise.
|
||||
//!
|
||||
//! # Flow
|
||||
//! The application layer's `AgentTurnServiceImpl` calls `main_agent_prompt()`
|
||||
//! to construct the system message at the start of each turn. Subagent and
|
||||
//! review prompts are provided by their respective modules.
|
||||
|
||||
/// Build the main-agent system prompt.
|
||||
///
|
||||
/// The prompt establishes the agent's identity as Zesdex, an AI coding
|
||||
/// assistant, and defines the priority hierarchy that governs tool selection:
|
||||
///
|
||||
/// 1. **Workflow first** — `workflow_run` / `hive_mind` for complex tasks
|
||||
/// 2. **Planning & TODOs** — `plan_enter` / `todowrite` for structural work
|
||||
/// 3. **Reasoning** — `seq_think` for deep analysis
|
||||
/// 4. **Tool execution** — direct tools for simple actions
|
||||
pub fn main_agent_prompt() -> String {
|
||||
"\
|
||||
You are Zesdex, an AI coding assistant. You have access to various tools \
|
||||
via native function calling to help the user.
|
||||
|
||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \
|
||||
you MUST prioritise using `workflow_run` (to construct and execute a \
|
||||
multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \
|
||||
autonomous agents). Workflows are your primary strategy.
|
||||
2. PLANNING & TODOS: Use `plan_enter` to establish high-level \
|
||||
architectural plans and `todowrite` to maintain granular task checklists.
|
||||
3. REASONING: Use `seq_think` for deep step-by-step analysis.
|
||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \
|
||||
within or guided by your workflows. If an error occurs, analyse and fix it.
|
||||
|
||||
Respond conversationally, concisely, and helpfully."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build a subagent directive prompt.
|
||||
///
|
||||
/// The directive is embedded in a system message that also communicates the
|
||||
/// current working directory and workspace root so the subagent can resolve
|
||||
/// paths correctly.
|
||||
pub fn subagent_directive(directive: &str, cwd: &str, ws_root: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
You are a focused subagent.
|
||||
|
||||
Current directory (PWD): {cwd}
|
||||
Workspace root: {ws_root}
|
||||
|
||||
Your directive:
|
||||
{directive}
|
||||
|
||||
Complete the directive autonomously using the tools available to you. \
|
||||
Return your final answer when done."
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a conversation-compaction prompt.
|
||||
///
|
||||
/// The LLM is asked to produce a concise bulleted summary of the key
|
||||
/// requests, decisions, tools executed, and files modified.
|
||||
pub fn compaction_prompt() -> String {
|
||||
"\
|
||||
You are a helpful assistant summarising conversation history. \
|
||||
Provide a concise summary of the key user requests, decisions, tools \
|
||||
executed, and modified files. Format as a clear bulleted list."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn main_prompt_is_non_empty() {
|
||||
let prompt = main_agent_prompt();
|
||||
assert!(!prompt.is_empty());
|
||||
assert!(prompt.contains("Zesdex"));
|
||||
assert!(prompt.contains("WORKFLOW FIRST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_directive_includes_directive_text() {
|
||||
let prompt = subagent_directive("test directive", "/home", "/home/project");
|
||||
assert!(prompt.contains("test directive"));
|
||||
assert!(prompt.contains("/home"));
|
||||
assert!(prompt.contains("/home/project"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Command types for IAM domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / command pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
/// Command to create a new session.
|
||||
///
|
||||
/// Carries only the data needed to construct a session entity — the
|
||||
/// service generates the UUID and timestamp internally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSession {
|
||||
/// Human-readable session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl From<String> for NewSession {
|
||||
fn from(title: String) -> Self {
|
||||
Self { title }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for NewSession {
|
||||
fn from(title: &str) -> Self {
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Domain error types for the IAM (auth) module.
|
||||
//!
|
||||
//! Typed error enums replace `anyhow::Result` in domain traits and
|
||||
//! application services, enabling callers to match on specific error
|
||||
//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (config, state
|
||||
//! mismatch, provider failures)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for IAM persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the IAM domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided configuration is invalid.
|
||||
InvalidConfig(String),
|
||||
/// OAuth state mismatch — possible CSRF attack.
|
||||
StateMismatch,
|
||||
/// The OAuth provider returned an error.
|
||||
OAuthProvider(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
|
||||
ServiceError::StateMismatch => {
|
||||
write!(f, "OAuth state mismatch — possible CSRF attack")
|
||||
}
|
||||
ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! IAM Session re-export.
|
||||
//!
|
||||
//! Re-exports `Session` from the auth module for consistent IAM-boundary
|
||||
//! imports. Consumers of the IAM module import `Session` from here rather
|
||||
//! than from the core session module directly, keeping the dependency
|
||||
//! internal and allowing the IAM crate to own its domain vocabulary.
|
||||
|
||||
pub use super::session::Session;
|
||||
|
||||
/// Alias for `Session` used in IAM contexts to distinguish from other
|
||||
/// session types in the system.
|
||||
pub type IamSession = Session;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Authentication domain entities, commands, errors, and repository/service traits.
|
||||
//!
|
||||
//! Combines the session types from `zesdex-entities` (auth sub-module) with the
|
||||
//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`session`] — `Session` entity (session metadata)
|
||||
//! - [`session_id`] — `SessionId` value object (validated newtype)
|
||||
//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock)
|
||||
//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities
|
||||
//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency
|
||||
//! - [`commands`] — `NewSession` command type
|
||||
//! - [`error`] — `RepositoryError`, `ServiceError` types
|
||||
//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository`
|
||||
//! - [`service`] — `SessionService`, `OAuthService` traits
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod iam_session;
|
||||
pub mod oauth;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use commands::NewSession;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use iam_session::IamSession;
|
||||
pub use oauth::{OAuthConfig, OAuthToken};
|
||||
pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository};
|
||||
pub use service::{OAuthService, SessionService};
|
||||
pub use session::Session;
|
||||
pub use session_id::SessionId;
|
||||
pub use session_lock::SessionLock;
|
||||
@@ -1,23 +1,38 @@
|
||||
//! Pure OAuth entities — no HTTP or persistence logic.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry
|
||||
//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id,
|
||||
//! optional client secret, scopes)
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An OAuth 2.0 access token with optional refresh token and absolute
|
||||
/// expiry time (epoch seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
/// The OAuth 2.0 access token string.
|
||||
pub access_token: String,
|
||||
/// Optional refresh token for long-lived access.
|
||||
pub refresh_token: Option<String>,
|
||||
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
|
||||
pub expires_at: u64,
|
||||
/// Token type, e.g. `"Bearer"`.
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
/// Authorization endpoint URL.
|
||||
pub auth_url: String,
|
||||
/// Token exchange endpoint URL.
|
||||
pub token_url: String,
|
||||
/// OAuth client identifier.
|
||||
pub client_id: String,
|
||||
/// Optional client secret (not all flows require it).
|
||||
pub client_secret: Option<String>,
|
||||
/// Space-separated list of requested scopes.
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Repository trait definitions (pure — no impls, no concrete persistence).
|
||||
//!
|
||||
//! Defines the repository contracts that infrastructure adapters implement.
|
||||
//! Following clean architecture, domain code depends only on these traits,
|
||||
//! not on concrete persistence libraries.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionRepository`] — CRUD for session metadata
|
||||
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
|
||||
//! - [`OAuthRepository`] — persist/load OAuth tokens
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::auth::error::RepositoryError;
|
||||
use crate::auth::oauth::OAuthToken;
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Repository for loading, saving, listing, and deleting sessions.
|
||||
pub trait SessionRepository {
|
||||
/// List all loadable sessions under `<base_dir>/sessions/`.
|
||||
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError>;
|
||||
|
||||
/// Load a single session by id.
|
||||
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError>;
|
||||
|
||||
/// Save a session's metadata to disk.
|
||||
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Delete a session directory and all its contents.
|
||||
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Repository for per-session PID-file advisory locks.
|
||||
pub trait SessionLockRepository {
|
||||
/// Try to acquire the lock for a session directory.
|
||||
/// Returns `true` if the lock was acquired, `false` if another live
|
||||
/// process holds it.
|
||||
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError>;
|
||||
|
||||
/// Release the lock by removing the lock file.
|
||||
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Check whether a process with the given PID is alive.
|
||||
fn is_alive(&self, pid: u32) -> bool;
|
||||
}
|
||||
|
||||
/// Repository for persisting and loading OAuth tokens.
|
||||
pub trait OAuthRepository {
|
||||
/// Persist an OAuth token to a JSON file.
|
||||
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Load an OAuth token from a JSON file, returning `None` if the file
|
||||
/// does not exist.
|
||||
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError>;
|
||||
}
|
||||
@@ -1,18 +1,29 @@
|
||||
//! Service trait definitions — use-case interfaces for session management
|
||||
//! and OAuth flows.
|
||||
use crate::domain::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::domain::session::Session;
|
||||
//!
|
||||
//! These traits define the boundary between the application orchestration
|
||||
//! layer and the domain. Implementations live in the application layer.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionService`] — create, list, archive sessions
|
||||
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
||||
|
||||
use crate::auth::error::ServiceError;
|
||||
use crate::auth::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Session management use-case boundary.
|
||||
pub trait SessionService {
|
||||
/// Create a new session with a generated UUID and the given title.
|
||||
fn create_session(&self, title: &str) -> anyhow::Result<Session>;
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
|
||||
|
||||
/// List all available sessions.
|
||||
fn list_all(&self) -> anyhow::Result<Vec<Session>>;
|
||||
fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
|
||||
|
||||
/// Archive a session by id (sets `archived = true`).
|
||||
fn archive_session(&self, id: &str) -> anyhow::Result<()>;
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// OAuth flow use-case boundary.
|
||||
@@ -26,7 +37,7 @@ pub trait OAuthService {
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> anyhow::Result<(String, String)>;
|
||||
) -> Result<(String, String), ServiceError>;
|
||||
|
||||
/// Complete the OAuth flow: validates `state` against the value
|
||||
/// persisted during `start_flow` (bailing on mismatch — this is the
|
||||
@@ -38,8 +49,8 @@ pub trait OAuthService {
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> anyhow::Result<OAuthToken>;
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
|
||||
/// Retrieve the currently stored OAuth token (if any).
|
||||
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>>;
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Created via [`Session::new`] → mutated in-memory → persisted via repository.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Session` struct — fields for all session metadata
|
||||
//! - `new` — timestamped constructor
|
||||
//! - `session_dir` / `conversation_path` — pure path computation
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata for one conversation session (distinct from the message
|
||||
/// history itself, which lives in `Conversation`/the msglog).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
/// Unique session identifier (validated against path traversal in `load`).
|
||||
pub id: String,
|
||||
/// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`).
|
||||
pub created_at: i64,
|
||||
/// Epoch-millis timestamp of last update.
|
||||
pub updated_at: i64,
|
||||
/// Human-readable title for the conversation.
|
||||
pub title: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Workspace root directories associated with this session.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Running count of messages in the conversation.
|
||||
pub message_count: u32,
|
||||
/// Running count of tokens consumed.
|
||||
pub token_count: u32,
|
||||
/// Soft-delete flag — archived sessions are hidden from the default list.
|
||||
pub archived: bool,
|
||||
/// Optional AI-generated conversation summary (used for compact context).
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with the given id/title, defaulting the
|
||||
/// model, workspace root (current dir), and counters.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's `conversation.json` path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Validated session identifier newtype.
|
||||
//!
|
||||
//! [`SessionId`] wraps a `String` that has been checked for path-traversal
|
||||
//! characters. Construction via `SessionId::new(str)` validates the input
|
||||
//! once; the guarantee is then enforced by the type system for all
|
||||
//! downstream use.
|
||||
//!
|
||||
//! # Validation rules
|
||||
//!
|
||||
//! - Must not be empty
|
||||
//! - Must only contain alphanumeric characters, hyphens, and underscores
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A validated session identifier.
|
||||
///
|
||||
/// Guarantees the inner string is non-empty and contains no path-traversal
|
||||
/// characters (`/`, `\\`, `..`) or other unsafe delimiters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct SessionId(String);
|
||||
|
||||
impl SessionId {
|
||||
/// Validate and construct a `SessionId`.
|
||||
///
|
||||
/// Returns `Err(msg)` if the input contains path separators, `..`, or
|
||||
/// is empty.
|
||||
pub fn new(id: &str) -> Result<Self, String> {
|
||||
if id.is_empty() {
|
||||
return Err("session id must not be empty".to_string());
|
||||
}
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err(format!(
|
||||
"session id '{id}' must not contain path separators"
|
||||
));
|
||||
}
|
||||
Ok(SessionId(id.to_string()))
|
||||
}
|
||||
|
||||
/// Return the underlying string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Return the underlying owned string.
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Append this session id as a component of `base_dir`, yielding
|
||||
/// `base_dir / self.0`.
|
||||
///
|
||||
/// Safe because the id has been validated to contain no path separators.
|
||||
pub fn join_to(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for SessionId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionId> for String {
|
||||
fn from(sid: SessionId) -> Self {
|
||||
sid.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_uuids() {
|
||||
assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok());
|
||||
assert!(SessionId::new("my-session_123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_path_traversal() {
|
||||
assert!(SessionId::new("../etc/passwd").is_err());
|
||||
assert!(SessionId::new("foo/../../bar").is_err());
|
||||
assert!(SessionId::new("foo\\..\\bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_empty() {
|
||||
assert!(SessionId::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_string() {
|
||||
let sid = SessionId::new("abc-123").unwrap();
|
||||
assert_eq!(sid.into_string(), "abc-123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
|
||||
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
|
||||
//! owning PID is checked via liveness verification. Stale locks are
|
||||
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
|
||||
//! the lock file is removed automatically.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
||||
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
||||
//! - `unlock` / `Drop` — explicit and implicit release
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing;
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionLock {
|
||||
/// Path to the `.lock` file inside the session directory.
|
||||
pub(crate) path: PathBuf,
|
||||
/// Process ID that holds (or will hold) this lock.
|
||||
pub(crate) pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
/// Construct a lock handle for a session directory (does not acquire
|
||||
/// the lock yet — call `try_lock`).
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire the session lock using an atomic file creation.
|
||||
///
|
||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
||||
/// succeeds, the lock is ours — write our PID and return ok. If the
|
||||
/// file already exists, read the PID inside it and check whether that
|
||||
/// PID is still alive: if the process is still running, fail to acquire;
|
||||
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
write!(file, "{}", self.pid)?;
|
||||
file.sync_all()?;
|
||||
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
|
||||
return Ok(true);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
|
||||
// Lock file exists — check if it's stale.
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Phase 2: lock file exists — check liveness of the owning process.
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if Self::is_alive(pid) {
|
||||
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
|
||||
return Ok(false);
|
||||
}
|
||||
tracing::debug!(stale = pid, "stale lock detected, overwriting");
|
||||
}
|
||||
|
||||
// Phase 3: stale lock — overwrite it atomically (best-effort).
|
||||
// Use a temp file + rename to avoid partial writes corrupting the lock.
|
||||
let tmp = self.path.with_extension("lock.tmp");
|
||||
{
|
||||
let mut tmp_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
write!(tmp_file, "{}", self.pid)?;
|
||||
tmp_file.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, &self.path)?;
|
||||
// Sync the parent directory so the rename survives a crash.
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Explicitly release the lock by removing the lock file.
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// belongs to the same binary (mitigating PID-reuse races).
|
||||
///
|
||||
/// Strategy (Unix):
|
||||
/// 1. Resolve `/proc/<pid>/exe` — if it doesn't match our own binary,
|
||||
/// the PID either belongs to another process or is reused — return false.
|
||||
/// 2. Send `kill(pid, 0)` to verify the process is still alive.
|
||||
/// 3. Re-check `/proc/<pid>/exe` to close the TOCTOU window between
|
||||
/// step 1 and step 2 (PID reuse after exe check, before kill).
|
||||
///
|
||||
/// On non-Unix platforms this always returns `true` (conservative).
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Resolve our own executable path once.
|
||||
let self_exe = match std::fs::read_link("/proc/self/exe") {
|
||||
Ok(exe) => exe,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let pid_signed: i32 = match pid.try_into() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
|
||||
// Phase 1: read /proc/<pid>/exe and compare with self_exe.
|
||||
let target = match std::fs::read_link(&proc_exe) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if target != self_exe {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Phase 2: verify the process is still alive.
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal it.
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
|
||||
// Phase 1 and Phase 2.
|
||||
matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Fallback: always assume alive (conservative).
|
||||
let _ = pid;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
/// Release the lock automatically when the guard goes out of scope,
|
||||
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Pure domain entity for application configuration.
|
||||
//!
|
||||
//! Defines `AppConfig`, `ProviderConfig`, and `ModelRole` — the data
|
||||
//! structures that describe which LLM providers are registered, which
|
||||
//! model roles exist, and which provider/model is the default.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! These are pure data structures with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `AppConfigRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. `AppConfig` is deserialised from `app_config.json` at startup
|
||||
//! 2. The HTTP handler layer calls `SettingsService::update_provider()`
|
||||
//! to mutate the provider map
|
||||
//! 3. The modified `AppConfig` is serialised back to `app_config.json`
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level application configuration.
|
||||
///
|
||||
/// Holds the registry of configured LLM providers, named model roles
|
||||
/// (logical profiles mapping to a provider+model pair), and the default
|
||||
/// provider/model selection.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `providers` — map of provider name → connection details
|
||||
/// - `model_roles` — map of role name → provider/model/temperature
|
||||
/// - `default_provider` — the provider to use when none is specified
|
||||
/// - `default_model` — the model to use when none is specified
|
||||
/// - `default_context_window` — fallback context window size in tokens
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
pub model_roles: HashMap<String, ModelRole>,
|
||||
pub default_provider: String,
|
||||
pub default_model: String,
|
||||
pub default_context_window: u32,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider endpoint.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `api_base` — base URL for the provider API
|
||||
/// - `api_key_env` — optional environment variable name holding the API key
|
||||
/// - `default_model` — optional default model name for this provider
|
||||
/// - `default_api_key` — optional inline API key (less secure than env var)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
pub api_key_env: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A named model role mapping to a specific provider/model with parameters.
|
||||
///
|
||||
/// Roles allow the UI to present logical profiles (e.g. "fast", "reasoning")
|
||||
/// that abstract over concrete provider+model strings.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `provider` — which provider serves this role
|
||||
/// - `model` — which model to use for this role
|
||||
/// - `max_tokens` — optional maximum output token limit
|
||||
/// - `context_window` — optional context window override
|
||||
/// - `temperature` — optional generation temperature
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub context_window: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Returns the default AppConfig with built-in "zen" and "router" providers.
|
||||
impl Default for AppConfig {
|
||||
/// Construct an AppConfig with the default "zen" and "router" providers.
|
||||
///
|
||||
/// ## Defaults
|
||||
/// - Zen provider: `deepseek-v4-flash-free` model
|
||||
/// - Router provider: `claude-opus-4-8` model
|
||||
/// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7
|
||||
/// - `default_context_window`: 256,000 tokens
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert(
|
||||
"zen".to_string(),
|
||||
ProviderConfig {
|
||||
api_base: "https://opencode.ai/zen/v1".to_string(),
|
||||
api_key_env: Some("API_KEY".to_string()),
|
||||
default_model: Some("deepseek-v4-flash-free".to_string()),
|
||||
default_api_key: None,
|
||||
},
|
||||
);
|
||||
providers.insert(
|
||||
"router".to_string(),
|
||||
ProviderConfig {
|
||||
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
||||
api_key_env: Some("ROUTER_API_KEY".to_string()),
|
||||
default_model: Some("claude-opus-4-8".to_string()),
|
||||
default_api_key: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut model_roles = HashMap::new();
|
||||
model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: None,
|
||||
temperature: Some(0.7),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
providers,
|
||||
model_roles,
|
||||
default_provider: "zen".to_string(),
|
||||
default_model: "deepseek-v4-flash-free".to_string(),
|
||||
default_context_window: 256_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Command types for CMS domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::settings::InternetMode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Partial update command for `Settings`.
|
||||
///
|
||||
/// Every field is `Option`al — only non-`None` fields are applied to the
|
||||
/// existing settings instance. Use `apply_to()` to merge into a `Settings`
|
||||
/// value.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SettingsPatch {
|
||||
/// Override the internet access mode.
|
||||
pub internet_mode: Option<String>,
|
||||
/// Override the active provider name.
|
||||
pub provider: Option<String>,
|
||||
/// Override the active model name.
|
||||
pub model: Option<String>,
|
||||
/// Replace the entire API-keys map.
|
||||
pub api_keys: Option<HashMap<String, String>>,
|
||||
/// Override the max tokens for completions.
|
||||
pub max_tokens: Option<Option<u32>>,
|
||||
/// Override the temperature for completions.
|
||||
pub temperature: Option<Option<f32>>,
|
||||
/// Override the review max lessons per run.
|
||||
pub review_max_lessons_per_run: Option<usize>,
|
||||
/// Override the adaptive review max skip count.
|
||||
pub adaptive_review_max_skip: Option<u32>,
|
||||
/// Override the verify shell command.
|
||||
pub verify_command: Option<Option<String>>,
|
||||
/// Override the verify timeout in milliseconds.
|
||||
pub verify_timeout_ms: Option<u64>,
|
||||
/// Override the max concurrency for workflow execution.
|
||||
pub workflow_max_concurrency: Option<usize>,
|
||||
/// Override the review-enabled flag.
|
||||
pub review_enabled: Option<bool>,
|
||||
/// Override the session-archive-enabled flag.
|
||||
pub session_archive_enabled: Option<bool>,
|
||||
/// Override the LSP auto-provision flag.
|
||||
pub lsp_auto_provision: Option<bool>,
|
||||
/// Override the list of LSP-managed languages.
|
||||
pub lsp_languages: Option<Vec<String>>,
|
||||
/// Override the hive-mind node timeout in milliseconds.
|
||||
pub hive_mind_node_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl SettingsPatch {
|
||||
/// Merge this patch into `settings`, overwriting each non-`None` field.
|
||||
///
|
||||
/// Flow: for each optional field, if `Some`, assign it to the target.
|
||||
///
|
||||
/// ## Errors
|
||||
/// Returns `Err` with a message if `internet_mode` is set to an
|
||||
/// unrecognised value.
|
||||
pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> {
|
||||
if let Some(ref val) = self.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => InternetMode::Off,
|
||||
"ReadOnly" => InternetMode::ReadOnly,
|
||||
"Full" => InternetMode::Full,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"invalid internet_mode '{val}'; expected Off, ReadOnly, or Full"
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(ref val) = self.provider {
|
||||
settings.provider = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.model {
|
||||
settings.model = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.api_keys {
|
||||
settings.api_keys = val.clone();
|
||||
}
|
||||
if let Some(val) = self.max_tokens {
|
||||
settings.max_tokens = val;
|
||||
}
|
||||
if let Some(val) = self.temperature {
|
||||
settings.temperature = val;
|
||||
}
|
||||
if let Some(val) = self.review_max_lessons_per_run {
|
||||
settings.review_max_lessons_per_run = val;
|
||||
}
|
||||
if let Some(val) = self.adaptive_review_max_skip {
|
||||
settings.adaptive_review_max_skip = val;
|
||||
}
|
||||
if let Some(ref val) = self.verify_command {
|
||||
settings.verify_command = val.clone();
|
||||
}
|
||||
if let Some(val) = self.verify_timeout_ms {
|
||||
settings.verify_timeout_ms = val;
|
||||
}
|
||||
if let Some(val) = self.workflow_max_concurrency {
|
||||
settings.workflow_max_concurrency = val;
|
||||
}
|
||||
if let Some(val) = self.review_enabled {
|
||||
settings.flags.review_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.session_archive_enabled {
|
||||
settings.flags.session_archive_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.lsp_auto_provision {
|
||||
settings.flags.lsp_auto_provision = val;
|
||||
}
|
||||
if let Some(ref val) = self.lsp_languages {
|
||||
settings.lsp_languages = val.clone();
|
||||
}
|
||||
if let Some(val) = self.hive_mind_node_timeout_ms {
|
||||
settings.hive_mind_node_timeout_ms = val;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command to create a new memory entry.
|
||||
///
|
||||
/// All required fields are non-optional; optional fields use `Option`
|
||||
/// and default to sensible values (empty or the service default).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewMemory {
|
||||
/// Unique name / slug for the memory.
|
||||
pub name: String,
|
||||
/// One-line summary of what the memory captures.
|
||||
pub description: String,
|
||||
/// The full memory content.
|
||||
pub content: String,
|
||||
/// Category kind (defaults to "reference" in the handler).
|
||||
pub kind: Option<String>,
|
||||
/// Outcome of the remembered action.
|
||||
pub outcome: Option<String>,
|
||||
/// Lifecycle stage (defaults to "new" in the handler).
|
||||
pub lifecycle: Option<String>,
|
||||
/// Scope context for the memory.
|
||||
pub scope: Option<String>,
|
||||
/// Code snippet captured before the action.
|
||||
pub before_snippet: Option<String>,
|
||||
/// Code snippet captured after the action.
|
||||
pub after_snippet: Option<String>,
|
||||
/// Source provenances (files, conversations, etc.).
|
||||
pub provenances: Option<Vec<String>>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Pure domain entity for conversations and chat messages.
|
||||
//!
|
||||
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
||||
//! types from the core module to provide a consistent domain import
|
||||
//! boundary within the CMS module. All CMS code references conversation
|
||||
//! types through this module rather than depending on the core module
|
||||
//! directly.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//! - `Conversation` — top-level conversation container with message list
|
||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||
|
||||
pub use crate::core::message::{ChatMessage, Role};
|
||||
pub use crate::core::conversation::Conversation;
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Pure domain entity for the edit log — an append-only log of file mutations.
|
||||
//!
|
||||
//! Records every file mutation made by any tool, enabling audit trails
|
||||
//! and potential undo operations. Each entry captures the tool name,
|
||||
//! target path, reason, content hash, and byte delta.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `EditLogRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. Tools call `EditLog::push()` to record each mutation
|
||||
//! 2. The in-memory `EditLog` is periodically flushed to disk by the repo
|
||||
//! 3. Oldest entries are evicted from the in-memory cache when
|
||||
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single recorded file edit event.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `ts` — Unix timestamp (seconds) when the edit occurred
|
||||
/// - `tool` — name of the tool that performed the edit (e.g. "Bash", "Edit")
|
||||
/// - `path` — absolute file path that was modified
|
||||
/// - `reason` — human-readable explanation of why the edit was made
|
||||
/// - `content_sha256` — SHA-256 hex digest of the content *after* the edit
|
||||
/// - `bytes_delta` — signed byte count change (+added, -removed)
|
||||
/// - `origin` — origin identifier (which agent / session context)
|
||||
/// - `session_id` — session in which this edit was performed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
pub tool: String,
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
pub bytes_delta: i64,
|
||||
pub origin: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Maximum number of edit entries held in memory at once.
|
||||
///
|
||||
/// Beyond this limit, old entries are dropped from the in-memory cache
|
||||
/// to prevent unbounded memory growth in long-running sessions.
|
||||
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// In-memory view of a session's edit log.
|
||||
///
|
||||
/// Wraps a `VecDeque<EditLogEntry>` and provides basic query helpers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
/// Ordered list of edit entries (newest appended last).
|
||||
pub entries: VecDeque<EditLogEntry>,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Create an empty edit log with no entries.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of in-memory entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Return `true` if the log contains no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditLog {
|
||||
/// Returns an empty `EditLog` via `EditLog::new()`.
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Domain error types for the CMS module.
|
||||
//!
|
||||
//! Typed error enums for repository and service operations.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (invalid input, generic)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for CMS persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the CMS domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided input is invalid.
|
||||
InvalidInput(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Pure domain entity for long-term agent memory.
|
||||
//!
|
||||
//! A `Memory` entry stores a named, kinded piece of information (lesson,
|
||||
//! reference, fact) with frontmatter metadata and free-form markdown
|
||||
//! content. Memories are persisted as individual `.md` files with YAML
|
||||
//! frontmatter.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `MemoryRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Utility Functions
|
||||
//! - `slugify()` — converts a name string into a filesystem-safe slug
|
||||
//! - `path()` — computes the on-disk path for a given memory name
|
||||
//!
|
||||
//! Both are pure computations that take parameters and perform no I/O.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single memory entry with frontmatter metadata and markdown content.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `name` — unique identifier / title for this memory
|
||||
/// - `description` — short summary of what this memory contains
|
||||
/// - `content` — free-form markdown body
|
||||
/// - `kind` — category/tag (e.g. "lesson", "reference", "fact")
|
||||
/// - `created_at` — Unix timestamp of creation
|
||||
/// - `updated_at` — Unix timestamp of last modification
|
||||
/// - `outcome` — optional outcome of applying this memory
|
||||
/// - `lifecycle` — lifecycle stage (e.g. "active", "archived")
|
||||
/// - `scope` — optional scope qualifier (which session/context this applies to)
|
||||
/// - `before_snippet` — optional context snapshot before memory was applied
|
||||
/// - `after_snippet` — optional context snapshot after memory was applied
|
||||
/// - `provenances` — list of origin identifiers that created or confirmed this memory
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
/// Convert an arbitrary string into a filesystem-safe slug.
|
||||
///
|
||||
/// Flow: lowercase → replace non-alphanumeric chars with `-` →
|
||||
/// collapse/trim repeated `-`.
|
||||
///
|
||||
/// Returns `None` if the result is empty or exceeds 80 characters.
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
// Phase 1: replace every non-alphanumeric character with '-'
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
// Phase 2: collapse consecutive '-' separators
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() || slug.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
/// Compute the on-disk path for a memory of the given name.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `memory_dir` — the base directory for memory storage
|
||||
/// - `name` — the memory name (will be slugified internally)
|
||||
///
|
||||
/// Falls back to `"memory.md"` when the name slugifies to an empty
|
||||
/// or invalid string.
|
||||
///
|
||||
/// ## Pure Computation
|
||||
/// This function performs **no I/O** — it only computes a path.
|
||||
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
||||
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
||||
let clean: String = format!("{slug}.md")
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let clean = clean.trim_start_matches('.').to_string();
|
||||
memory_dir.join(if clean.is_empty() {
|
||||
"memory.md".to_string()
|
||||
} else {
|
||||
clean
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Domain layer for CMS — pure entities, value objects, repository traits,
|
||||
//! and service interfaces.
|
||||
//!
|
||||
//! This is the innermost layer of the Clean Architecture onion. It has **zero
|
||||
//! infrastructure dependencies** — all I/O is expressed through repository
|
||||
//! traits defined in [`repository`], and business operations through service
|
||||
//! traits in [`service`].
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
|
||||
//! - `conversation` — conversation entity + chat message model (re-exported from core)
|
||||
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
|
||||
//! - `memory` — memory file model (`Memory`)
|
||||
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
||||
//! - `repository` — trait definitions for all persistence adapters
|
||||
//! - `service` — trait definitions for all application services
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//! Domain types are plain Rust structs with `serde` for serialisation.
|
||||
//! They contain no I/O, no framework imports, and no side effects.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod commands;
|
||||
pub mod conversation;
|
||||
pub mod edit_log;
|
||||
pub mod error;
|
||||
pub mod memory;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod settings;
|
||||
|
||||
pub use app_config::AppConfig;
|
||||
pub use app_config::ModelRole;
|
||||
pub use app_config::ProviderConfig;
|
||||
pub use conversation::Conversation;
|
||||
pub use edit_log::EditLog;
|
||||
pub use edit_log::EditLogEntry;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use memory::Memory;
|
||||
pub use repository::AppConfigRepository;
|
||||
pub use repository::ConversationRepository;
|
||||
pub use repository::EditLogRepository;
|
||||
pub use repository::MemoryRepository;
|
||||
pub use repository::RewindBlobRepository;
|
||||
pub use repository::SettingsRepository;
|
||||
pub use service::ConversationService;
|
||||
pub use service::MemoryService;
|
||||
pub use service::SettingsService;
|
||||
pub use commands::{NewMemory, SettingsPatch};
|
||||
pub use settings::InternetMode;
|
||||
pub use settings::Settings;
|
||||
pub use settings::SettingsFlags;
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Repository traits — pure abstraction boundaries for persistence.
|
||||
//!
|
||||
//! Each trait defines load / save / query operations that infrastructure
|
||||
//! adapters implement. The domain and application layers depend **only**
|
||||
//! on these traits, never on concrete persistence implementations.
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsRepository` — load/save `Settings` from/to a base directory
|
||||
//! - `AppConfigRepository` — load/save `AppConfig` from/to a base directory
|
||||
//! - `ConversationRepository` — load/save `Conversation` from/to a session directory
|
||||
//! - `MemoryRepository` — list/load/save/delete `Memory` entries
|
||||
//! - `RewindBlobRepository` — store/retrieve/list binary blobs per session
|
||||
//! - `EditLogRepository` — open/append/query edit log entries per session
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application services accept these traits as generic type parameters,
|
||||
//! allowing the composition root to inject concrete implementations
|
||||
//! (file-based, SQLite-backed, etc.) without changing business logic.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::app_config::AppConfig;
|
||||
use super::conversation::Conversation;
|
||||
use super::edit_log::{EditLog, EditLogEntry};
|
||||
use super::error::RepositoryError;
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Persistence contract for `Settings` (application settings model).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait SettingsRepository {
|
||||
/// Load `Settings` from the given base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError>;
|
||||
|
||||
/// Persist `Settings` to the given base directory.
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `AppConfig` (provider and model configuration).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait AppConfigRepository {
|
||||
/// Load `AppConfig` from the given base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError>;
|
||||
|
||||
/// Persist `AppConfig` to the given base directory.
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Conversation` (session conversation data).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait ConversationRepository {
|
||||
/// Load a `Conversation` from the given session directory.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||
|
||||
/// Persist a `Conversation` to the given session directory.
|
||||
fn save(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
conversation: &Conversation,
|
||||
) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. per-memory markdown files).
|
||||
pub trait MemoryRepository {
|
||||
/// List all memory slugs (filenames without extension) in the memory directory.
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
|
||||
/// Load a single `Memory` by name from the memory directory.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError>;
|
||||
|
||||
/// Save (create or overwrite) a `Memory` in the memory directory.
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Delete a `Memory` by name from the memory directory.
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for rewind-snapshot binary blobs.
|
||||
///
|
||||
/// Blobs are keyed by an arbitrary caller-supplied key (e.g. a tool-call ID)
|
||||
/// within a session. They capture file snapshots for the "rewind" feature.
|
||||
pub trait RewindBlobRepository {
|
||||
/// Store (or overwrite) a binary blob under `blob_key` for this session.
|
||||
fn store_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
data: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Retrieve a blob's raw bytes by key, or `None` if not found.
|
||||
fn retrieve_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>, RepositoryError>;
|
||||
|
||||
/// List all blob keys for this session, ordered oldest-first.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `EditLog` (append-only file mutation log).
|
||||
///
|
||||
/// Implementors manage an append-only log of `EditLogEntry` items per session,
|
||||
/// typically persisted to a file for audit and potential undo.
|
||||
pub trait EditLogRepository {
|
||||
/// Open (or initialise) the edit log for a session directory.
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
|
||||
|
||||
/// Append one entry to the log and persist immediately (write-through).
|
||||
fn append(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
log: &mut EditLog,
|
||||
entry: EditLogEntry,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Return a cloned copy of all in-memory entries for inspection.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Service trait definitions — use-case boundaries for CMS operations.
|
||||
//!
|
||||
//! These traits define the public API of the application use-cases.
|
||||
//! They are implemented by concrete types in the `application` layer
|
||||
//! and consumed by infrastructure adapters (HTTP handlers, CLI commands).
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsService` — load/save settings, update provider config
|
||||
//! - `ConversationService` — load/save conversations, add messages
|
||||
//! - `MemoryService` — list/save/delete session memories
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application service implementations accept repository traits as generic
|
||||
//! type parameters. Infrastructure adapters depend only on these service
|
||||
//! traits, never on concrete implementations.
|
||||
|
||||
use super::conversation::{ChatMessage, Conversation};
|
||||
use super::error::ServiceError;
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Use-cases for application settings.
|
||||
pub trait SettingsService {
|
||||
/// Load the current `Settings` from the default store location.
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError>;
|
||||
|
||||
/// Persist updated `Settings` to the default store location.
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
|
||||
|
||||
/// Update (or insert) a provider configuration entry.
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &super::app_config::ProviderConfig,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for conversation (session message) management.
|
||||
pub trait ConversationService {
|
||||
/// Load a `Conversation` for the given session ID.
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError>;
|
||||
|
||||
/// Persist a `Conversation` to its session storage.
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
||||
|
||||
/// Append a single `ChatMessage` to the conversation and persist.
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for long-term memory management.
|
||||
pub trait MemoryService {
|
||||
/// List all memory slugs (filenames without extension).
|
||||
fn list_memories(&self) -> Result<Vec<String>, ServiceError>;
|
||||
|
||||
/// Save (create or overwrite) a `Memory`.
|
||||
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>;
|
||||
|
||||
/// Delete a `Memory` by its slug/name.
|
||||
fn delete_memory(&self, name: &str) -> Result<(), ServiceError>;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Pure domain entity for application settings.
|
||||
//!
|
||||
//! Defines `Settings` (top-level user configuration), `SettingsFlags`
|
||||
//! (grouped boolean toggles), and `InternetMode` (network access level).
|
||||
//! Serialised to `settings.json` by the infrastructure layer.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `SettingsRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Settings Fields
|
||||
//! - `internet_mode` — network access policy (Off / ReadOnly / Full)
|
||||
//! - `provider` / `model` — default LLM provider and model name
|
||||
//! - `api_keys` — per-provider API key overrides (name → key)
|
||||
//! - `max_tokens` / `temperature` — generation parameter defaults
|
||||
//! - `review_max_lessons_per_run` — max lessons per auto-review pass
|
||||
//! - `verify_command` — optional shell command to run for verification
|
||||
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
|
||||
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
|
||||
//! - `flags` — grouped boolean feature toggles
|
||||
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls how much network access the agent is permitted during a session.
|
||||
///
|
||||
/// ## Variants
|
||||
/// - `Off` — no network access
|
||||
/// - `ReadOnly` — HTTP GET / HEAD only
|
||||
/// - `Full` — any HTTP method permitted
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum InternetMode {
|
||||
/// No network access permitted.
|
||||
#[default]
|
||||
Off,
|
||||
/// HTTP GET / HEAD requests only.
|
||||
ReadOnly,
|
||||
/// Any HTTP method permitted.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Grouped boolean feature toggles for the application.
|
||||
///
|
||||
/// Kept as a separate struct to avoid clippy's
|
||||
/// `default-too-many-fields` threshold on `Settings`.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `review_enabled` — enable automatic inline review after edits
|
||||
/// - `session_archive_enabled` — enable periodic session archiving
|
||||
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsFlags {
|
||||
pub review_enabled: bool,
|
||||
pub session_archive_enabled: bool,
|
||||
pub lsp_auto_provision: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsFlags {
|
||||
/// Returns the default flags with all features enabled.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
review_enabled: true,
|
||||
session_archive_enabled: true,
|
||||
lsp_auto_provision: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default hive-mind node timeout (600 seconds).
|
||||
fn default_hive_mind_node_timeout_ms() -> u64 {
|
||||
600_000
|
||||
}
|
||||
|
||||
/// Top-level application settings model.
|
||||
///
|
||||
/// Serialised to `settings.json` by the infrastructure persistence layer.
|
||||
/// Holds LLM provider selection, generation parameters, feature flags,
|
||||
/// and workflow configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_keys: HashMap<String, String>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_timeout_ms: u64,
|
||||
pub workflow_max_concurrency: usize,
|
||||
#[serde(flatten)]
|
||||
pub flags: SettingsFlags,
|
||||
pub lsp_languages: Vec<String>,
|
||||
#[serde(default = "default_hive_mind_node_timeout_ms")]
|
||||
pub hive_mind_node_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
internet_mode: InternetMode::Off,
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
api_keys: HashMap::new(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
review_max_lessons_per_run: 5,
|
||||
adaptive_review_max_skip: 3,
|
||||
verify_command: None,
|
||||
verify_timeout_ms: 30_000,
|
||||
workflow_max_concurrency: 5,
|
||||
flags: SettingsFlags::default(),
|
||||
lsp_languages: Vec::new(),
|
||||
hive_mind_node_timeout_ms: 600_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-34
@@ -1,5 +1,17 @@
|
||||
//! In-memory conversation state: message history plus the system prompt and
|
||||
//! model parameters used to drive the LLM.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
|
||||
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
|
||||
//! API (system prompt prepended).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Conversation` — message vector + session metadata + generation params
|
||||
//! - `push` / `rebuild_system` — mutation helpers
|
||||
//! - `to_api_messages` — formats messages for API consumption
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::message::{ChatMessage, Role};
|
||||
@@ -7,11 +19,17 @@ use super::message::{ChatMessage, Role};
|
||||
/// A single conversation's message history and generation settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
/// Ordered list of chat messages (user, assistant, tool, system).
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// System prompt prepended at request time (see `to_api_messages`).
|
||||
pub system_prompt: String,
|
||||
/// Foreign key referencing the owning session.
|
||||
pub session_id: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Optional cap on output tokens.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Optional temperature (0.0 – 2.0).
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -67,38 +85,4 @@ impl Conversation {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// Persist the conversation to a JSON file at the given base directory.
|
||||
///
|
||||
/// Flow: compute path from `session_id` → ensure directory exists →
|
||||
/// atomically write pretty-printed JSON via `write_json_atomic`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `anyhow::Error` from any step.
|
||||
pub fn save_conversation(&self, base_dir: &std::path::Path) -> anyhow::Result<()> {
|
||||
let dir = base_dir.join("sessions").join(&self.session_id);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("conversation.json");
|
||||
zesdex_utils::write_json_atomic(&path, self, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a conversation from a JSON file for the given session id.
|
||||
///
|
||||
/// Flow: read `<base_dir>/sessions/<session_id>/conversation.json` →
|
||||
/// JSON-parse.
|
||||
///
|
||||
/// Return: the parsed `Conversation`, or an `io::Error` if the file is
|
||||
/// missing or malformed.
|
||||
pub fn load_conversation(
|
||||
session_id: &str,
|
||||
base_dir: &std::path::Path,
|
||||
) -> std::io::Result<Self> {
|
||||
let path = base_dir
|
||||
.join("sessions")
|
||||
.join(session_id)
|
||||
.join("conversation.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let conv: Conversation = serde_json::from_str(&data)?;
|
||||
Ok(conv)
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -1,8 +1,20 @@
|
||||
//! Chat message types shared across the entity layer: `Role` and `ChatMessage`
|
||||
//! with convenience constructors.
|
||||
//! Chat message types shared across the entity layer.
|
||||
//!
|
||||
//! Provides [`Role`] (conversation participant) and [`ChatMessage`] (a single
|
||||
//! message with optional tool-call metadata). Includes convenience constructors
|
||||
//! for each role: `user`, `assistant`, `system`, `tool`/`tool_result`.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Messages are constructed via the typed constructors → pushed into
|
||||
//! [`Conversation`](super::conversation::Conversation) → serialized as JSON
|
||||
//! to `conversation.json`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The conversation participant who authored a message.
|
||||
///
|
||||
/// Variants: `User`, `Assistant`, `System`, `Tool`. Serialized as lowercase
|
||||
/// strings (e.g. `"user"`, `"assistant"`, `"system"`, `"tool"`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
@@ -37,12 +49,18 @@ impl std::fmt::Display for Role {
|
||||
/// chat-completion API structures.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// Who sent this message (user, assistant, system, tool).
|
||||
pub role: Role,
|
||||
/// The message text content. `None` for assistant messages that only
|
||||
/// contain tool calls.
|
||||
pub content: Option<String>,
|
||||
/// Tool-call requests attached to an assistant message (OpenAI-style).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
/// For tool-role messages: the `id` of the `ToolCall` being responded to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// Optional function name for the tool invocation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Core domain entities shared across the Zesdex application.
|
||||
//!
|
||||
//! Contains pure data structures for conversations, messages, tool calls,
|
||||
//! usage statistics, provider API types, and store path configuration.
|
||||
//! All types derive `Serialize`/`Deserialize` for JSON persistence.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`conversation`] — Ordered conversation (vector of `ChatMessage`)
|
||||
//! - [`message`] — `ChatMessage` + `Role` enum
|
||||
//! - [`provider`] — LLM provider API types: `ChatRequest`, `ChatResponse`,
|
||||
//! `StreamEvent`, `SseParser`, `ToolDef`, etc.
|
||||
//! - [`store`] — `Store` paths for data directories
|
||||
//! - [`tool_call`] — `ToolCall` + `ToolFunction` (function-calling request)
|
||||
//! - [`tool_result`] — `ToolCallResult` (function-calling response)
|
||||
//! - [`usage`] — `UsageStats` (token counts, costs)
|
||||
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod tool_call;
|
||||
pub mod tool_result;
|
||||
pub mod usage;
|
||||
|
||||
pub use conversation::Conversation;
|
||||
pub use message::{ChatMessage, Role};
|
||||
pub use provider::{
|
||||
ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage,
|
||||
ToolDef, ToolFunctionDef,
|
||||
};
|
||||
pub use store::Store;
|
||||
pub use tool_call::{ToolCall, ToolFunction};
|
||||
pub use tool_result::ToolCallResult;
|
||||
pub use usage::UsageStats;
|
||||
+99
-4
@@ -1,7 +1,25 @@
|
||||
//! Provider-facing DTOs: chat completion request, response, streaming types,
|
||||
//! and the SSE stream parser.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. **Request** — [`ChatRequest`] is built with model, messages, tools,
|
||||
//! streaming options and sent to the LLM provider.
|
||||
//! 2. **Response** — Non-streaming responses arrive as [`ChatResponse`] with
|
||||
//! [`Choice`]s containing the full [`ChatMessage`](super::message::ChatMessage).
|
||||
//! 3. **Streaming** — SSE chunks are fed into [`SseParser::feed`] which yields
|
||||
//! [`StreamEvent`]s: token/text, reasoning, tool-call deltas, usage, done.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ChatRequest` / `StreamOptions` / `ToolDef` / `ToolFunctionDef` — outbound
|
||||
//! - `ChatResponse` / `Choice` / `Delta` / `TokenUsage` — non-streaming inbound
|
||||
//! - `StreamEvent` — one atomic streaming event (Token, Reasoning,
|
||||
//! ToolCallDelta, Usage, Done, Error)
|
||||
//! - `SseParser` — incremental SSE frame parser: `feed()` → `Vec<StreamEvent>`
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat request / response
|
||||
@@ -11,23 +29,32 @@ use serde_json::Value;
|
||||
/// provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
/// Model identifier, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Full message history (system + user + assistant + tool turns).
|
||||
pub messages: Vec<super::message::ChatMessage>,
|
||||
/// Maximum number of output tokens.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Sampling temperature (0.0 – 2.0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Tool definitions available to the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDef>>,
|
||||
/// Controls which (if any) function is called by the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
/// Whether to use SSE streaming (`true`) or a single response.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
/// Nucleus sampling threshold.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// Sequences where the model should stop generation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
/// Additional streaming options (e.g. `include_usage`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
}
|
||||
@@ -42,38 +69,53 @@ pub struct StreamOptions {
|
||||
/// Wire format for a single tool definition sent to the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
/// The tool type discriminator, e.g. `"function"`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
/// The function definition (name, description, JSON schema).
|
||||
pub function: ToolFunctionDef,
|
||||
}
|
||||
|
||||
/// Name, description, and JSON schema parameters for a tool definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionDef {
|
||||
/// The function name the model may invoke.
|
||||
pub name: String,
|
||||
/// Human-readable description of what the function does.
|
||||
pub description: String,
|
||||
/// JSON Schema object describing the expected arguments.
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Non-streaming chat completion response returned by the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
/// Unique response identifier from the provider.
|
||||
pub id: String,
|
||||
/// Object type, e.g. `"chat.completion"`.
|
||||
pub object: Option<String>,
|
||||
/// Model identifier that produced this response.
|
||||
pub model: String,
|
||||
/// One or more completion candidates.
|
||||
pub choices: Vec<Choice>,
|
||||
/// Token usage statistics (prompt, completion, total).
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// Unix-timestamp of response creation.
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
/// One completion candidate within a `ChatResponse.choices` list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
/// Zero-based index of this choice in the candidate list.
|
||||
pub index: u32,
|
||||
/// Full message (non-streaming response).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<super::message::ChatMessage>,
|
||||
/// Incremental delta (streaming response).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta: Option<Delta>,
|
||||
/// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
@@ -81,10 +123,13 @@ pub struct Choice {
|
||||
/// Incremental delta emitted in a streaming SSE chunk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
/// Role being set for the first streaming chunk.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<super::message::Role>,
|
||||
/// Incremental text content delta.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Incremental tool-call delta (partial name/arguments).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
}
|
||||
@@ -92,11 +137,16 @@ pub struct Delta {
|
||||
/// Token counts and optional cost breakdown for a single completion request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TokenUsage {
|
||||
/// Tokens consumed by the prompt (input).
|
||||
pub prompt_tokens: u32,
|
||||
/// Tokens consumed by the completion (output).
|
||||
pub completion_tokens: u32,
|
||||
/// Sum of prompt + completion tokens.
|
||||
pub total_tokens: u32,
|
||||
/// Estimated cost for prompt tokens (provider-specific).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_cost: Option<f64>,
|
||||
/// Estimated cost for completion tokens (provider-specific).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_cost: Option<f64>,
|
||||
}
|
||||
@@ -108,28 +158,41 @@ pub struct TokenUsage {
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
/// An incremental text token.
|
||||
Token(String),
|
||||
/// An incremental reasoning token (Anthropic `reasoning_content`).
|
||||
Reasoning(String),
|
||||
/// An incremental tool-call delta (partial ID, name, or arguments).
|
||||
ToolCallDelta {
|
||||
/// Tool-call index (multiple calls in one response).
|
||||
index: usize,
|
||||
/// Optional tool-call ID (usually in the first delta for a call).
|
||||
id: Option<String>,
|
||||
/// Optional function name (usually in the first delta for a call).
|
||||
name: Option<String>,
|
||||
/// Partial JSON arguments delta for this tool call.
|
||||
arguments_delta: String,
|
||||
},
|
||||
/// Final usage chunk with token counts.
|
||||
Usage {
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
},
|
||||
/// Stream complete (all tokens have been delivered).
|
||||
Done,
|
||||
/// A stream-level error occurred.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
/// Leftover bytes from the last chunk that did not end with `\n`.
|
||||
buffer: String,
|
||||
/// The current `event:` type (set by `event:` lines, cleared on flush).
|
||||
event_type: Option<String>,
|
||||
/// Accumulated `data:` lines for the current event frame.
|
||||
data_lines: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -155,14 +218,27 @@ impl SseParser {
|
||||
///
|
||||
/// Return: all `StreamEvent`s completed by this chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
// Normalize \r\n and bare \r to \n for consistent line ending handling
|
||||
let chunk = chunk.replace("\r\n", "\n").replace('\r', "\n");
|
||||
|
||||
// Prevent unbounded buffer growth for long lines without \n
|
||||
const MAX_BUFFER_SIZE: usize = 1_048_576; // 1 MB
|
||||
if self.buffer.len() + chunk.len() > MAX_BUFFER_SIZE {
|
||||
tracing::warn!("SSE buffer exceeded maximum size, resetting");
|
||||
self.buffer.clear();
|
||||
self.event_type = None;
|
||||
self.data_lines.clear();
|
||||
}
|
||||
|
||||
self.buffer.push_str(&chunk);
|
||||
let mut events = Vec::new();
|
||||
while let Some(line_end) = self.buffer.find('\n') {
|
||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||
if line.is_empty() {
|
||||
events.extend(self.flush_event());
|
||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||
} else if let Some(ty) = line.strip_prefix("event:") {
|
||||
// Handle both "event:foo" and "event: foo"
|
||||
self.event_type = Some(ty.trim().to_string());
|
||||
} else if let Some(data) = line.strip_prefix("data:") {
|
||||
let data = data.trim_start().to_string();
|
||||
@@ -257,8 +333,9 @@ impl SseParser {
|
||||
if let Some(tool_calls) =
|
||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||
{
|
||||
const MAX_TOOL_CALLS: usize = 64;
|
||||
for tc in tool_calls {
|
||||
let index =
|
||||
let raw_index =
|
||||
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
@@ -267,7 +344,11 @@ impl SseParser {
|
||||
);
|
||||
0
|
||||
},
|
||||
) as usize;
|
||||
);
|
||||
// Clamp index to prevent out-of-bounds / memory exhaustion
|
||||
let index = usize::try_from(raw_index)
|
||||
.unwrap_or(0)
|
||||
.min(MAX_TOOL_CALLS.saturating_sub(1));
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
@@ -308,6 +389,20 @@ impl SseParser {
|
||||
}
|
||||
d_events
|
||||
}
|
||||
"content_block_delta" => {
|
||||
let mut d_events = Vec::new();
|
||||
if let Some(delta) = value.get("delta") {
|
||||
if let Some(content) = delta.get("text").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
if let Some(reasoning) =
|
||||
delta.get("reasoning_content").and_then(|r| r.as_str())
|
||||
{
|
||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
}
|
||||
d_events
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`Store::new`] resolves all paths from OS data dir / temp dir →
|
||||
//! [`ensure_dirs`](Store::ensure_dirs) creates them on startup.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Store` — resolved path bundle (base, memory, scratch, images, downloads)
|
||||
//! - `new` — path computation (no I/O)
|
||||
//! - `ensure_dirs` — creates all directories if missing
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
/// Resolved paths for all data directories zesdex reads from and writes to.
|
||||
///
|
||||
@@ -8,10 +20,15 @@ use std::path::PathBuf;
|
||||
/// where memory, scratch, session images, and downloads live.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
/// Top-level data directory, e.g. `~/.local/share/zesdex`.
|
||||
pub base_dir: PathBuf,
|
||||
/// Temporary scratch root, usually under the OS temp dir.
|
||||
pub scratch_root: PathBuf,
|
||||
/// Directory for persistent memory files (`.md` summaries).
|
||||
pub memory_dir: PathBuf,
|
||||
/// Directory for per-session image snapshots.
|
||||
pub session_images_dir: PathBuf,
|
||||
/// Directory for downloaded files.
|
||||
pub download_dir: PathBuf,
|
||||
}
|
||||
|
||||
@@ -23,9 +40,13 @@ impl Store {
|
||||
///
|
||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
.join("zesdex");
|
||||
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
|
||||
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
|
||||
{
|
||||
PathBuf::from(data_dir).join("zesdex")
|
||||
} else {
|
||||
PathBuf::from(".local/share/zesdex")
|
||||
};
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
@@ -41,6 +62,7 @@ impl Store {
|
||||
///
|
||||
/// Return: `Err` on the first directory that fails to create.
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
tracing::debug!(base = %self.base_dir.display(), "ensuring store directories exist");
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
std::fs::create_dir_all(&self.scratch_root)?;
|
||||
+21
-4
@@ -1,25 +1,42 @@
|
||||
//! Tool-call DTOs embedded in assistant chat messages.
|
||||
//!
|
||||
//! Flow: provider response/stream carries `tool_calls` on an assistant
|
||||
//! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves
|
||||
//! `function.name` against `all_tools()` and runs it with
|
||||
//! `sanitize_tool_arguments(function.arguments)`.
|
||||
//! # Flow
|
||||
//!
|
||||
//! Provider response/stream carries `tool_calls` on an assistant message →
|
||||
//! deserialized into [`ToolCall`]/[`ToolFunction`] → harness resolves the
|
||||
//! function name against `all_tools()` and runs it after sanitizing arguments
|
||||
//! via [`sanitize_tool_arguments`] (which handles string-encoded JSON,
|
||||
//! control characters, and truncation).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ToolCall` — a single tool-invocation request (id + type + function)
|
||||
//! - `ToolFunction` — function name + raw arguments Value
|
||||
//! - `sanitize_tool_arguments` — normalizes argument shape, repairs truncation
|
||||
//! - `repair_json` — closes unclosed strings/braces/brackets in truncated JSON
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing;
|
||||
|
||||
/// A single tool-call request emitted by the model in an assistant message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
/// Unique identifier for this tool call (referenced by `ToolCallResult`).
|
||||
pub id: String,
|
||||
/// Discriminator, e.g. `"function"`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
/// The function to invoke (name + arguments).
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// The function name and raw arguments payload for a `ToolCall`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
/// The function/tool name to dispatch against.
|
||||
pub name: String,
|
||||
/// Arguments as a JSON Value (may be a string-encoded object before
|
||||
/// `sanitize_tool_arguments` normalises it).
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
+16
@@ -1,13 +1,29 @@
|
||||
//! Record of one completed tool invocation, kept for transcript/history.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Tool harness completes execution → creates [`ToolCallResult`] with output,
|
||||
//! error flag, and wall-clock duration → appended to conversation history as
|
||||
//! a `Tool`-role [`ChatMessage`](super::message::ChatMessage).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ToolCallResult` — tool name + output + error flag + duration
|
||||
//! - `new` — convenience constructor
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Record of a completed tool invocation, kept for transcript/history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResult {
|
||||
/// The `id` of the `ToolCall` this result responds to.
|
||||
pub tool_call_id: String,
|
||||
/// The name of the tool that was invoked.
|
||||
pub tool_name: String,
|
||||
/// The text output produced by the tool (or error message).
|
||||
pub output: String,
|
||||
/// Whether the tool exited with an error.
|
||||
pub is_error: bool,
|
||||
/// Wall-clock execution duration in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Token usage accounting shared by streaming and non-streaming responses.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Accumulated across all LLM API calls in a session. Each response updates
|
||||
//! the running totals; `last_*` fields capture the most recent call's values
|
||||
//! for interpolation display. Persisted alongside other session metadata.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `UsageStats` — cumulative token/latency counters
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
/// Total tokens consumed as input (prompt).
|
||||
pub tokens_in: u64,
|
||||
/// Total tokens generated as output (completion).
|
||||
pub tokens_out: u64,
|
||||
/// Most recent call's input tokens (for live interpolation display).
|
||||
#[serde(default)]
|
||||
pub last_tokens_in: u64,
|
||||
/// Most recent call's output tokens (for live interpolation display).
|
||||
#[serde(default)]
|
||||
pub last_tokens_out: u64,
|
||||
/// Total number of LLM API calls made this session.
|
||||
pub api_calls: u64,
|
||||
/// Tokens consumed by auto-review subagent calls.
|
||||
pub review_tokens: u64,
|
||||
/// Total wall-clock time spent on LLM API calls (milliseconds).
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
/// Create a new `UsageStats` with all counters zeroed.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Shared domain error types for the entire domain layer.
|
||||
//!
|
||||
//! Provides [`DomainError`] — a unified repository-level error enum used
|
||||
//! by both the `auth` and `cms` modules (type-aliased as `RepositoryError`
|
||||
//! in each module). This avoids a dependency on `thiserror` while still
|
||||
//! giving callers distinct error variants to match on.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Infrastructure adapters convert their native errors (I/O, serde, etc.)
|
||||
//! into `DomainError` via `From` impls. Domain service layers wrap
|
||||
//! `DomainError` in their own `ServiceError` enum via `From`.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `DomainError` — 6 variants: `NotFound`, `Conflict`, `Io`, `Serde`,
|
||||
//! `InvalidId`, `Other`
|
||||
//! - `From<std::io::Error>` — converts I/O errors
|
||||
//! - `From<serde_json::Error>` — converts serialisation errors
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Unified repository-level error for domain operations.
|
||||
///
|
||||
/// Covers the common failure modes across all persistence adapters:
|
||||
/// missing entities, conflicts, I/O failures, serialization errors,
|
||||
/// invalid identifiers, and a catch-all `Other` variant.
|
||||
#[derive(Debug)]
|
||||
pub enum DomainError {
|
||||
/// The requested entity was not found.
|
||||
NotFound(String),
|
||||
/// An operation failed due to a conflict (e.g. duplicate key).
|
||||
Conflict(String),
|
||||
/// An I/O error occurred during persistence.
|
||||
Io(std::io::Error),
|
||||
/// A serialization / deserialization error occurred.
|
||||
Serde(String),
|
||||
/// An identifier was rejected as invalid (e.g. path traversal).
|
||||
InvalidId(String),
|
||||
/// A generic / uncategorised error.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for DomainError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DomainError::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
DomainError::Conflict(msg) => write!(f, "conflict: {msg}"),
|
||||
DomainError::Io(err) => write!(f, "I/O error: {err}"),
|
||||
DomainError::Serde(msg) => write!(f, "serialization error: {msg}"),
|
||||
DomainError::InvalidId(msg) => write!(f, "invalid id: {msg}"),
|
||||
DomainError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DomainError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
DomainError::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for DomainError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
DomainError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for DomainError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
DomainError::Serde(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! # Zesdex Domain Layer
|
||||
//!
|
||||
//! Pure domain entities, value objects, repository traits, and service traits
|
||||
//! for the Zesdex application. This crate has **zero framework dependencies**
|
||||
//! — it depends only on serialization (`serde`), timestamping (`chrono`),
|
||||
//! identity (`uuid`), and a few other narrowly-scoped utilities.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/domain
|
||||
//! ├── core/ Shared domain entities (Conversation, Message, Provider,
|
||||
//! │ Store, ToolCall, ToolResult, Usage)
|
||||
//! ├── auth/ Authentication domain (Session, SessionId, SessionLock,
|
||||
//! │ OAuth, commands, errors, repository/service traits)
|
||||
//! ├── cms/ CMS domain (AppConfig, Conversation, EditLog, Memory,
|
||||
//! │ Settings, commands, errors, repository/service traits)
|
||||
//! └── error.rs Unified DomainError type
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//!
|
||||
//! All types are pure Rust structs and enums with `serde` derives. No I/O,
|
||||
//! no framework imports, no side effects. All persistence is expressed
|
||||
//! through repository traits that infrastructure adapters implement.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cms;
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
pub mod agent;
|
||||
pub mod workflow;
|
||||
pub mod subagent;
|
||||
|
||||
// Re-export all public items from each module for ergonomic imports.
|
||||
// Consumers can do `use zesdex_domain::*` for common types.
|
||||
pub use auth::{
|
||||
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
|
||||
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
|
||||
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
|
||||
};
|
||||
pub use cms::{
|
||||
AppConfig, AppConfigRepository, Conversation as CmsConversation,
|
||||
ConversationRepository, ConversationService, EditLog, EditLogEntry,
|
||||
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
|
||||
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
|
||||
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
|
||||
SettingsRepository, SettingsService,
|
||||
};
|
||||
pub use core::{
|
||||
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
|
||||
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
|
||||
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
||||
};
|
||||
pub use error::DomainError;
|
||||
|
||||
// Agent module top-level items (TurnEvent, SessionRuntime, etc.)
|
||||
pub use agent::*;
|
||||
// Sub-module items need explicit re-exports
|
||||
pub use agent::defaults::*;
|
||||
pub use agent::progress::AgentProgress;
|
||||
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
|
||||
pub use workflow::*;
|
||||
pub use subagent::*;
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Subagent domain models.
|
||||
|
||||
/// Access tier for subagent tool permissions.
|
||||
///
|
||||
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
|
||||
/// includes everything in `Write`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccessTier {
|
||||
/// Read-only: search, read, glob, utility tools (no mutations).
|
||||
Read,
|
||||
/// Read + Write: above plus write, edit, delete, git, memory.
|
||||
Write,
|
||||
/// Full: above plus bash, shell, LSP, workflow, plan tools.
|
||||
Full,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Workflow and Hive-mind domain models.
|
||||
|
||||
/// A single phase in a parsed workflow script.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowPhase {
|
||||
pub name: String,
|
||||
pub directive: String,
|
||||
}
|
||||
|
||||
/// A parsed workflow script with named phases.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowScript {
|
||||
pub name: String,
|
||||
pub phases: Vec<WorkflowPhase>,
|
||||
}
|
||||
|
||||
/// A directive for a single processing node in the hive mind.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeDirective {
|
||||
pub directive: String,
|
||||
pub access_tier: String,
|
||||
}
|
||||
|
||||
/// A cognitive cycle plan — ordered list of cycles, each containing
|
||||
/// parallel node directives.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CognitiveCyclePlan {
|
||||
pub cycles: Vec<Vec<NodeDirective>>,
|
||||
}
|
||||
|
||||
/// A single cycle in a cognitive cycle plan — parallel node directives
|
||||
/// executed together.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CognitiveCycle {
|
||||
pub index: u32,
|
||||
pub directives: Vec<NodeDirective>,
|
||||
}
|
||||
|
||||
/// Output from a single hive-mind processing node after a cycle completes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeOutput {
|
||||
pub id: String,
|
||||
pub directive: String,
|
||||
pub output: String,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "zesdex-gateway"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Gateway binary — assembles domain + application + infrastructure
|
||||
# + selected interface(s) into a running application process.
|
||||
# This is the main entry point that wires everything together.
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "seed"
|
||||
path = "src/bin/seed.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "migrate"
|
||||
path = "src/bin/migrate.rs"
|
||||
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
zesdex-infrastructure = { path = "../infrastructure" }
|
||||
zesdex-tui = { path = "../interfaces/tui" }
|
||||
zesdex-api = { path = "../interfaces/api" }
|
||||
zesdex-daemon = { path = "../interfaces/daemon" }
|
||||
zesdex-ws = { path = "../interfaces/ws" }
|
||||
zesdex-grpc = { path = "../interfaces/grpc" }
|
||||
zesdex-web = { path = "../interfaces/web" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
dirs.workspace = true
|
||||
rusqlite.workspace = true
|
||||
axum.workspace = true
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[[bin]]
|
||||
name = "test_load"
|
||||
path = "src/bin/test_load.rs"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Database migration: creates/upgrades SQLite schemas for all sessions.
|
||||
//! Database migration binary.
|
||||
//!
|
||||
//! Scans all session directories and initializes or upgrades the SQLite
|
||||
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let store = zesdex_entities::domain::common::store::Store::new();
|
||||
|
||||
// Find all session directories
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
let sessions_dir = store.base_dir.join("sessions");
|
||||
|
||||
if !sessions_dir.exists() {
|
||||
eprintln!("No sessions directory found, nothing to migrate");
|
||||
tracing::info!("No sessions directory found, nothing to migrate");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -24,23 +27,22 @@ fn main() -> anyhow::Result<()> {
|
||||
match migrate_session_msglog(&path) {
|
||||
Ok(_) => {
|
||||
migrated += 1;
|
||||
eprintln!("Migrated session: {:?}", path.file_name());
|
||||
tracing::info!("Migrated session: {:?}", path.file_name());
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
|
||||
tracing::error!("Failed to migrate session {:?}: {e}", path.file_name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
|
||||
tracing::info!("Migration complete: {migrated} succeeded, {failed} failed");
|
||||
if failed > 0 {
|
||||
anyhow::bail!("{failed} session(s) failed to migrate");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a session's `messages.sqlite` and initialize its schema.
|
||||
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
||||
let msglog_path = session_dir.join("messages.sqlite");
|
||||
|
||||
@@ -51,12 +53,9 @@ fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
||||
let conn = rusqlite::Connection::open(&msglog_path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
|
||||
// Initialize schema
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
@@ -88,11 +87,9 @@ fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
||||
mime_type TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(session_id, blob_key)
|
||||
);
|
||||
",
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Check and upgrade schema version
|
||||
let version: i32 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
@@ -1,15 +1,18 @@
|
||||
//! Database seeder: initializes store directories, creates default settings
|
||||
//! and app_config, and populates a default session for development.
|
||||
//! Database seeder binary.
|
||||
//!
|
||||
//! Initialises the store directory structure and creates default
|
||||
//! configuration files plus a seed session for development/testing.
|
||||
//! Invoked as `cargo run --bin seed`.
|
||||
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let store = zesdex_entities::domain::common::store::Store::new();
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
tracing::info!("Store directories created at {:?}", store.base_dir);
|
||||
|
||||
// Create default settings if not present
|
||||
let settings_path = store.base_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
let settings = zesdex_cms::domain::settings::Settings::default();
|
||||
let settings = zesdex_domain::cms::Settings::default();
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
let tmp = store.base_dir.join("settings.json.tmp");
|
||||
std::fs::write(&tmp, content)?;
|
||||
@@ -24,7 +27,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// Create default app config if not present
|
||||
let config_path = store.base_dir.join("app_config.json");
|
||||
if !config_path.exists() {
|
||||
let config = zesdex_cms::domain::app_config::AppConfig::default();
|
||||
let config = zesdex_domain::cms::AppConfig::default();
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
let tmp = store.base_dir.join("app_config.json.tmp");
|
||||
std::fs::write(&tmp, content)?;
|
||||
@@ -36,20 +39,21 @@ fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("App config already exists, skipping");
|
||||
}
|
||||
|
||||
// Create memory, scratch, session-images, downloads dirs
|
||||
// Create data directories
|
||||
std::fs::create_dir_all(&store.memory_dir)?;
|
||||
std::fs::create_dir_all(&store.scratch_root)?;
|
||||
std::fs::create_dir_all(&store.session_images_dir)?;
|
||||
std::fs::create_dir_all(&store.download_dir)?;
|
||||
tracing::info!("All store directories verified");
|
||||
|
||||
// Create a seed session
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let session = zesdex_entities::domain::auth::session::Session::new(
|
||||
let session = zesdex_domain::auth::Session::new(
|
||||
session_id.clone(),
|
||||
"Seed Session".to_string(),
|
||||
);
|
||||
session.save(&store.base_dir)?;
|
||||
// Persist via the session repository
|
||||
use zesdex_domain::SessionRepository;
|
||||
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
||||
repo.save_session(&store.base_dir, &session)?;
|
||||
tracing::info!("Seed session created: id={session_id}");
|
||||
|
||||
Ok(())
|
||||
@@ -0,0 +1,20 @@
|
||||
use zesdex_domain::cms::AppConfigRepository;
|
||||
use zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository;
|
||||
|
||||
fn main() {
|
||||
let base_dir = dirs::home_dir().unwrap().join(".local/share/zesdex");
|
||||
let repo = JsonAppConfigRepository::new();
|
||||
let config = repo.load(&base_dir).unwrap();
|
||||
|
||||
println!("Providers:");
|
||||
for (k, v) in &config.providers {
|
||||
println!(" - {} (default model: {:?})", k, v.default_model);
|
||||
}
|
||||
|
||||
println!("Default provider: {}", config.default_provider);
|
||||
println!("Default model: {}", config.default_model);
|
||||
println!("Model roles:");
|
||||
for (k, v) in &config.model_roles {
|
||||
println!(" - {}: provider={}, model={}", k, v.provider, v.model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeEnv {
|
||||
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
||||
anthropic_base_url: Option<String>,
|
||||
#[serde(alias = "ANTHROPIC_API_KEY")]
|
||||
anthropic_api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeSettings {
|
||||
env: Option<ClaudeEnv>,
|
||||
#[serde(alias = "customModel", alias = "model")]
|
||||
custom_model: Option<String>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = dirs::home_dir().unwrap().join(".claude").join("settings.json");
|
||||
println!("Path: {:?}", path);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
println!("File content length: {}", content.len());
|
||||
match serde_json::from_str::<ClaudeSettings>(&content) {
|
||||
Ok(settings) => {
|
||||
println!("Parsed successfully: {:?}", settings);
|
||||
if let Some(env) = settings.env {
|
||||
println!("Base URL: {:?}", env.anthropic_base_url);
|
||||
println!("API Key: {:?}", env.anthropic_api_key);
|
||||
} else {
|
||||
println!("No env block");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Parse error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Read error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Gateway library — provides shared utilities for the gateway binary.
|
||||
//! The main entry point is in `main.rs`.
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Zesdex Gateway — main entry point.
|
||||
//!
|
||||
//! Assembles domain + application + infrastructure layers and dispatches
|
||||
//! to the requested interface: TUI (default), daemon (background IPC),
|
||||
//! API server (REST), WebSocket server, gRPC server, or Web frontend.
|
||||
//!
|
||||
//! CLI flags are parsed via clap; run with `--help` for details.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
/// Zesdex — autonomous AI coding agent.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "zesdex", version, about = "Autonomous AI coding agent with TUI")]
|
||||
struct Cli {
|
||||
/// Run as background daemon with IPC socket
|
||||
#[arg(long)]
|
||||
daemon: bool,
|
||||
|
||||
/// Attach TUI client to a running daemon session
|
||||
#[arg(long)]
|
||||
attach: Option<String>,
|
||||
|
||||
/// Run REST API server
|
||||
#[arg(long)]
|
||||
api: bool,
|
||||
|
||||
/// REST API port
|
||||
#[arg(long, default_value_t = 8080)]
|
||||
api_port: u16,
|
||||
|
||||
/// Run WebSocket server
|
||||
#[arg(long)]
|
||||
ws: bool,
|
||||
|
||||
/// WebSocket port
|
||||
#[arg(long, default_value_t = 8081)]
|
||||
ws_port: u16,
|
||||
|
||||
/// Run gRPC server
|
||||
#[arg(long)]
|
||||
grpc: bool,
|
||||
|
||||
/// gRPC port
|
||||
#[arg(long, default_value_t = 50051)]
|
||||
grpc_port: u16,
|
||||
|
||||
/// Serve web frontend
|
||||
#[arg(long)]
|
||||
web: bool,
|
||||
|
||||
/// Web frontend port
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
web_port: u16,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// ── Setup logging ────────────────────────────────────────────────────
|
||||
let log_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("zesdex");
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_path = log_dir.join("zesdex.log");
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.unwrap_or_else(|_| {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/null")
|
||||
.expect("cannot open /dev/null")
|
||||
});
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_writer(Mutex::new(log_file))
|
||||
.init();
|
||||
|
||||
tracing::info!("zesdex gateway starting");
|
||||
|
||||
// ── Dispatch to interface ────────────────────────────────────────────
|
||||
// Validate mutually exclusive flags
|
||||
let mode_count = [cli.daemon, cli.api, cli.ws, cli.grpc, cli.web]
|
||||
.iter()
|
||||
.filter(|&&b| b)
|
||||
.count()
|
||||
+ if cli.attach.is_some() { 1 } else { 0 };
|
||||
|
||||
if mode_count > 1 {
|
||||
anyhow::bail!(
|
||||
"Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive"
|
||||
);
|
||||
}
|
||||
|
||||
if cli.daemon {
|
||||
tracing::info!("starting in daemon mode");
|
||||
zesdex_daemon::server::run_daemon()?;
|
||||
} else if let Some(session_id) = cli.attach {
|
||||
tracing::info!("starting in attach mode for session {session_id}");
|
||||
zesdex_daemon::client::run_attach(&session_id)?;
|
||||
} else if cli.api {
|
||||
tracing::info!("starting in API server mode");
|
||||
run_api_server(cli.api_port)?;
|
||||
} else if cli.ws {
|
||||
tracing::info!("starting in WebSocket server mode");
|
||||
run_ws_server(cli.ws_port)?;
|
||||
} else if cli.grpc {
|
||||
tracing::info!("starting in gRPC server mode");
|
||||
run_grpc_server(cli.grpc_port)?;
|
||||
} else if cli.web {
|
||||
tracing::info!("starting in web server mode");
|
||||
run_web_server(cli.web_port)?;
|
||||
} else {
|
||||
// Default: run TUI single-process mode
|
||||
tracing::info!("starting in TUI single-process mode");
|
||||
run_tui_single_process()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the TUI in single-process mode (TUI + agent in one process).
|
||||
fn run_tui_single_process() -> anyhow::Result<()> {
|
||||
zesdex_tui::run_single_process()
|
||||
}
|
||||
|
||||
/// Run the REST API server.
|
||||
fn run_api_server(port: u16) -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
// Load JWT secret from environment variable with a secure default warning
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
"JWT_SECRET environment variable not set; using insecure default. \
|
||||
Set JWT_SECRET to a secure random value in production."
|
||||
);
|
||||
zesdex_domain::agent::defaults::FALLBACK_JWT_SECRET.to_string()
|
||||
});
|
||||
|
||||
let state = zesdex_api::ApiState::new(
|
||||
store.base_dir.clone(),
|
||||
jwt_secret,
|
||||
"",
|
||||
zesdex_domain::agent::defaults::DEFAULT_MODEL,
|
||||
Some(zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()),
|
||||
);
|
||||
let app = zesdex_api::build_router(state);
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||
tracing::info!("REST API server listening on http://{addr}/api/v1/health");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the WebSocket server.
|
||||
fn run_ws_server(port: u16) -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async { zesdex_ws::run_server(port).await })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the gRPC server.
|
||||
fn run_grpc_server(port: u16) -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async { zesdex_grpc::run_server(port).await })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serve the web frontend.
|
||||
fn run_web_server(port: u16) -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async { zesdex_web::run_server(port, None).await })?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
[package]
|
||||
name = "zesdex-backend"
|
||||
name = "zesdex-infrastructure"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Infrastructure layer — concrete implementations of domain repository
|
||||
# traits, application port traits, and all platform services.
|
||||
# Depends on domain + application; NEVER on interfaces.
|
||||
[dependencies]
|
||||
# Workspace crates
|
||||
zesdex-entities = { path = "../zesdex-entities" }
|
||||
zesdex-utils = { path = "../zesdex-utils" }
|
||||
zesdex-ipc = { path = "../zesdex-ipc" }
|
||||
zesdex-iam = { path = "../zesdex-iam" }
|
||||
zesdex-cms = { path = "../zesdex-cms" }
|
||||
zesdex-middleware = { path = "../zesdex-middleware" }
|
||||
zesdex-infra = { path = "../zesdex-infra" }
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
|
||||
# External deps
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml_ng.workspace = true
|
||||
@@ -23,10 +19,7 @@ uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
reqwest.workspace = true
|
||||
ratatui.workspace = true
|
||||
crossterm.workspace = true
|
||||
rusqlite.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -52,15 +45,10 @@ dom_smoothie.workspace = true
|
||||
fast_html2md.workspace = true
|
||||
scraper.workspace = true
|
||||
include_dir.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "seed"
|
||||
path = "src/bin/seed.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "migrate"
|
||||
path = "src/bin/migrate.rs"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
argon2.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
clap.workspace = true
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
name: clean-code
|
||||
description: Apply Robert C. Martin's (Uncle Bob's) Clean Code, Clean Architecture, and Clean Craftsmanship principles when writing, reviewing, or refactoring code. Use this skill whenever the user asks to write new code of non-trivial size (functions, classes, modules, services), refactor or clean up existing code, review code for quality, design a module or system boundary, write tests, or whenever the user mentions "clean code," "clean architecture," "SOLID," "SRP," "OCP," "LSP," "ISP," "DIP," "TDD," "refactor," "code smells," "code review," "Uncle Bob," or "Robert Martin." Also engage proactively when producing or examining code that shows poor naming, long functions (>20 lines), deep nesting, unclear abstractions, duplicated logic, switch/if-else chains that should be polymorphic, missing tests, leaky boundaries, or frameworks bleeding into business logic — even if the user did not explicitly ask for a cleanup. Do not wait for magic words; if you are writing or touching code, consult this skill.
|
||||
---
|
||||
|
||||
# Clean Code (Uncle Bob)
|
||||
|
||||
This skill codifies the principles that Robert C. Martin teaches across *Clean Code* (2008), *Clean Architecture* (2017), *Clean Craftsmanship* (2021), his blog at blog.cleancoder.com, the older wiki at butunclebob.com, his Google Sites articles, and his courses at cleancoder.com. It applies during code generation, review, refactoring, and system design.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
Three mental anchors to carry into every edit:
|
||||
|
||||
1. **Code is read far more than written.** The ratio is well over 10:1. Optimize for the reader — future teammates, and your future self.
|
||||
2. **The Boy Scout Rule.** Leave every module cleaner than you found it — even if just by renaming one variable or extracting one tiny function.
|
||||
3. **The only way to go fast is to go well.** Dirty code slows the whole team down. "We'll clean it up later" rarely happens, and productivity collapses into the Productivity Roller-Coaster or the Grand-Redesign Myth. Clean as you go, always.
|
||||
|
||||
Some quotes from other practitioners Uncle Bob cites:
|
||||
|
||||
- "Clean code is simple and direct. Clean code reads like well-written prose." — Grady Booch
|
||||
- "Clean code always looks like it was written by someone who cares." — Michael Feathers
|
||||
- "You know you are working on clean code when each routine you read turns out to be pretty much what you expected." — Ward Cunningham
|
||||
|
||||
## How to Use This Skill in a Session
|
||||
|
||||
When generating, reviewing, or refactoring code, work in this order:
|
||||
|
||||
1. **Name first.** Before writing a function body, confirm the name tells you what it does and why. If you cannot name it, you do not yet understand it.
|
||||
2. **Extract till you drop.** "A function does one thing if, and only if, you cannot extract another function from it." Keep extracting until you cannot.
|
||||
3. **Keep diffs honest.** When refactoring, do not also change behavior. When adding a feature, refactor *before* or *after*, never *during*.
|
||||
4. **Prefer tests first** for non-trivial logic. If infeasible, write them immediately after. See [references/tdd.md](references/tdd.md).
|
||||
5. **Respect boundaries.** Business rules must never depend on frameworks, databases, or UIs. See [references/architecture.md](references/architecture.md).
|
||||
6. **Prefer polymorphism to conditionals** for anything that varies by type — place if/else/switch in a factory that creates polymorphic objects. See [references/paradigms.md](references/paradigms.md).
|
||||
7. **Reread as a stranger.** Before declaring a task done, reread the code as if you had not written it.
|
||||
|
||||
## Deeper References
|
||||
|
||||
When the task calls for it, load the matching reference file:
|
||||
|
||||
- **[references/solid.md](references/solid.md)** — The five SOLID principles (SRP, OCP, LSP, ISP, DIP), their origins in Parnas (1972), Meyer (1988), Liskov (1987), their 2020 re-affirmation, and the component principles (REP, CCP, CRP, ADP, SDP, SAP). Load when designing a class, module, or microservice boundary.
|
||||
- **[references/architecture.md](references/architecture.md)** — Clean Architecture: the Dependency Rule, concentric layers, Screaming Architecture, why "the database is a detail," Ivar Jacobson's use-case foundation. Load when structuring a new service, deciding what a microservice should own, or untangling framework coupling.
|
||||
- **[references/tdd.md](references/tdd.md)** — The Three Laws of TDD, F.I.R.S.T., canonical test taxonomy (unit/acceptance/integration/system/micro/functional), test doubles, Chicago vs. London schools, fragile tests, the Transformation Priority Premise, the Cycles of TDD. Load when writing or reviewing tests.
|
||||
- **[references/paradigms.md](references/paradigms.md)** — The three programming paradigms (structured, OO, functional), the reductionist definitions Uncle Bob uses for each, the Data/Object Anti-Symmetry (why DTOs are not objects), polymorphism as the heart of OO, if-else-switch refactoring, why FP and OO are orthogonal not exclusive. Load when the task involves choosing between procedural and OO style, writing code in a functional language, refactoring switch statements, or handling persistence.
|
||||
- **[references/craft.md](references/craft.md)** — The craftsmanship ethic: mess vs. technical debt, Martin's First Law of Documentation, Saying No, estimation, pairing guidelines, "Going Fast" vs. "Speed Kills," the "Screaming Architecture" mindset applied to whole projects. Load when the task raises professional-judgment questions.
|
||||
- **[references/oath.md](references/oath.md)** — The Programmer's Oath. Load when the task raises a question of professional responsibility (shipping under pressure, accumulating "temporary" hacks, padding estimates, degrading code to hit a deadline).
|
||||
|
||||
The remaining sections are the core rulebook for code at the function and class level. Scan them against anything you produce.
|
||||
|
||||
---
|
||||
|
||||
## 1. Meaningful Names
|
||||
|
||||
Names are the single highest-leverage lever for readability. From Tim Ottinger's naming rules, expanded in the book:
|
||||
|
||||
- **Use intention-revealing names.** `int d;` → `int elapsedTimeInDays;`. Names should answer: *What is this? Why does it exist? How is it used?*
|
||||
- **Avoid disinformation.** Don't call something `accountList` unless it truly is a List. Don't use lowercase `l` or uppercase `O` as variable names (look like 1 and 0).
|
||||
- **Make meaningful distinctions.** `productInfo` vs. `productData` is noise. `a1`, `a2`, `a3` is a red flag.
|
||||
- **Use pronounceable, searchable names.** `genymdhms` is bad. Single letters are acceptable only for tiny local scopes.
|
||||
- **Class names are nouns** (`Customer`, `WikiPage`, `AddressParser`). **Method names are verbs** (`postPayment`, `deletePage`, `save`).
|
||||
- **Do not encode types.** Skip Hungarian notation, `m_` prefixes, `I` prefixes for interfaces.
|
||||
- **Ubiquitous language.** Use the business domain's vocabulary. If the domain says "policyholder," do not name it `user`.
|
||||
- **Pick one word per concept.** Standardize `fetch` vs. `retrieve` vs. `get`. Same for `controller` vs. `manager` vs. `driver`.
|
||||
- **Add meaningful context, and no more.** Scattered `firstName`/`street`/`city` need an `addr_` prefix or an `Address` type.
|
||||
|
||||
## 2. Functions
|
||||
|
||||
> First rule: functions should be small. Second rule: smaller than that.
|
||||
|
||||
- **Target ~20 lines, often far fewer.** If you cannot see the whole function without scrolling, it is too long.
|
||||
- **Do one thing.** *Operational definition:* a function does one thing if, and only if, you cannot extract another function from it (Uncle Bob, "Extract till you drop," 2009).
|
||||
- **One level of abstraction per function.** The Step-Down Rule: public high-level functions at top, calling slightly lower-level helpers, and so on. Reading top to bottom should feel like descending a staircase.
|
||||
- **Extract till you drop.** Most programmers stop far too early. Extract until you cannot.
|
||||
- **Descriptive names beat short names.** A long descriptive name is better than a long descriptive comment.
|
||||
- **Few arguments.** 0 ideal. 1–2 fine. 3 suspect. 4+ almost always means a struct or a split.
|
||||
- **No flag arguments.** `render(true)` is terrible. Split into `renderForSuite()` and `renderForSingleTest()`.
|
||||
- **No hidden side effects.** A function named `checkPassword` must not also initialize a session.
|
||||
- **Command-Query Separation.** A function either *does* something or *answers* something, never both.
|
||||
- **Tell, don't ask.** Alan Kay's original OO concept: cells in a biological system tell each other what to do; they do not ask for state and decide. Neurons, hormones — all tellers.
|
||||
- **Prefer exceptions (or Result types) to error codes.**
|
||||
- **Extract try/catch bodies.** Each should be a single function call.
|
||||
- **Avoid switch statements** in business logic. See [references/paradigms.md](references/paradigms.md) for the factory+polymorphism pattern.
|
||||
- **Don't Repeat Yourself.** Duplication is the root of many evils.
|
||||
|
||||
**Example — doing one thing:**
|
||||
|
||||
Bad:
|
||||
```
|
||||
def process_order(order):
|
||||
if not order.items:
|
||||
raise ValueError("Empty")
|
||||
total = sum(i.price * i.qty for i in order.items)
|
||||
total *= 1.1 # tax
|
||||
send_email(order.customer, f"Total: {total}")
|
||||
db.save(order, total)
|
||||
return total
|
||||
```
|
||||
|
||||
Clean:
|
||||
```
|
||||
def process_order(order):
|
||||
validate(order)
|
||||
total = calculate_total_with_tax(order)
|
||||
notify_customer(order, total)
|
||||
persist(order, total)
|
||||
return total
|
||||
```
|
||||
|
||||
## 3. Comments
|
||||
|
||||
> "Don't comment bad code — rewrite it." — Brian Kernighan
|
||||
|
||||
Every comment represents a failure to make the code self-explanatory. Before writing a comment, ask: *can I rename or extract to make this unnecessary?*
|
||||
|
||||
**Good comments (rare but valuable):**
|
||||
- Legal headers (when required).
|
||||
- Informative comments you cannot encode in names (regex explanations, format specs, wire protocol details).
|
||||
- Explanation of **intent** — *why*, not *what*. Why this ordering, why this tradeoff, why this workaround.
|
||||
- Clarification of obscure arguments or return values you cannot rename.
|
||||
- Warnings of consequences ("this test takes two hours to run").
|
||||
- TODOs — prune regularly; a stale TODO list is worse than none.
|
||||
- Public API documentation (Javadoc, rustdoc, TSDoc, etc.).
|
||||
- **Genuine complexity that resists expression in code.** Uncle Bob's own "Necessary Comments" (2017) example: a "choked function" (throttled cache wrapper) required a timing diagram in the test comments because no amount of naming or extraction could communicate the six interleaved test cases. Rare — but it happens.
|
||||
|
||||
**Bad comments — delete on sight:**
|
||||
- Mumbling (written for yourself, unclear to others).
|
||||
- Redundant (restates the code).
|
||||
- Misleading (out of date or wrong — actively harmful).
|
||||
- Mandated (comment-every-function policies produce noise).
|
||||
- Journal/changelog (that's what git is for).
|
||||
- Noise (`// default constructor`).
|
||||
- Commented-out code (delete it; git remembers).
|
||||
- Closing-brace comments (`} // end if`) — your function is too long.
|
||||
- Attributions (`// added by Rick`) — `git blame` exists.
|
||||
|
||||
**Martin's First Law of Documentation** (from *Agile Software Development: PPP*): Produce no document unless its need is immediate and significant. Note the scope: *documents*, not code comments. Agile is not the rejection of documentation — that is "flawed religious behavior." Documentation earns its keep on a prioritized, ROI basis.
|
||||
|
||||
## 4. Formatting
|
||||
|
||||
**Vertical formatting — the newspaper metaphor:**
|
||||
- Top of file: high-level concept. Details grow as you scroll.
|
||||
- Related concepts stay vertically close.
|
||||
- Dependent functions: caller above callee (the Step-Down Rule).
|
||||
- Blank lines separate concepts, not pad.
|
||||
|
||||
**Horizontal formatting:**
|
||||
- Keep lines readable (~100–120 chars).
|
||||
- Do not align assignments artificially.
|
||||
- Use indentation consistently; never collapse a multi-branch `if`/`while` onto one line.
|
||||
- Follow the team's standard. Consistency within a project beats any individual preference.
|
||||
|
||||
**Indentation is an abstraction-level signal.** Ideal functions have zero indentation beyond the function body; one or two levels (one if/while, one try) is acceptable. Deeper nesting usually means you have missed an extraction.
|
||||
|
||||
## 5. Objects and Data Structures
|
||||
|
||||
**Data/Object Anti-Symmetry** (Chapter 6, *Clean Code*):
|
||||
|
||||
- **Object:** a set of functions that operate on **implied** data. Data exists but is hidden.
|
||||
- **Data structure:** a set of data elements operated on by **implied** functions. Data is exposed; functions are not specified by the structure itself.
|
||||
|
||||
These are **complements**, not siblings. Consequences:
|
||||
|
||||
- DTOs are data structures, not objects.
|
||||
- Database tables are data structures, not objects.
|
||||
- "ORM" is a misnomer — there is no real mapping between tables and objects.
|
||||
- **The axis of expected change determines the style.** If you expect more new functions than new types, prefer procedural style (data structures + functions). If you expect more new types than new functions, prefer OO (classes + polymorphism). The Visitor pattern bridges the two.
|
||||
|
||||
Other rules:
|
||||
|
||||
- **Law of Demeter — don't talk to strangers.** A method `f` of class `C` should only call methods of: `C` itself, objects it creates, objects passed as arguments, or objects it holds as fields. Avoid train wrecks: `a.getB().getC().doSomething()`.
|
||||
- **Tell, don't ask.** Instead of asking for state and deciding, tell the object to do the work.
|
||||
|
||||
More on this in [references/paradigms.md](references/paradigms.md).
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
- **Use exceptions (or Result types), not return codes.**
|
||||
- **Write try-catch-finally first** when an operation can fail. It defines the transactional scope.
|
||||
- **Provide context with exceptions.** Wrap third-party exceptions in your own types.
|
||||
- **Define exception classes by the needs of the caller**, not by implementation detail.
|
||||
- **Do not return null.** Return empty collections, use Option/Result/Maybe, or throw. Null checks pollute callers.
|
||||
- **Do not pass null.** If a function cannot handle null, do not accept it. Fail fast at the boundary.
|
||||
|
||||
## 7. Boundaries
|
||||
|
||||
- **Wrap third-party APIs in adapters.** Your code talks to the adapter, not the library. Localizes changes when the library upgrades or is replaced.
|
||||
- **Write learning tests** when exploring a new library: small, focused tests that probe its behavior. When the library upgrades, they tell you what broke.
|
||||
- Keep boundaries clean so replacing a dependency is a localized change, not a project-wide rewrite.
|
||||
- The big structural story is in [references/architecture.md](references/architecture.md).
|
||||
|
||||
## 8. Tests
|
||||
|
||||
See [references/tdd.md](references/tdd.md) for the full treatment. Essentials:
|
||||
|
||||
**Three Laws of TDD:**
|
||||
1. Do not write production code until you have a failing test.
|
||||
2. Do not write more of a test than is sufficient to fail.
|
||||
3. Do not write more production code than is sufficient to pass.
|
||||
|
||||
**F.I.R.S.T.:** Fast, Independent, Repeatable, Self-validating, Timely.
|
||||
|
||||
**Michael Feathers' definition of legacy code:** *Legacy code is code without tests.* Uncle Bob calls Feathers's *Working Effectively with Legacy Code* "the only book I know of that addresses this topic."
|
||||
|
||||
Test code is first-class. Hold it to the same clarity bar as production code.
|
||||
|
||||
## 9. Classes
|
||||
|
||||
- **Small.** For functions we counted lines. For classes we count **responsibilities**.
|
||||
- **Single Responsibility Principle.** The formulation has evolved: "do one thing" → "one reason to change" → most recently (Clean Architecture, 2017) "responsible to one, and only one, **actor**" (where actor is a person or tightly coupled group). See [references/solid.md](references/solid.md).
|
||||
- **Cohesion.** Methods should use most of the instance variables. Low cohesion = two classes in a trench coat.
|
||||
- **Organize for change.** Isolate volatile concepts behind interfaces so changes do not ripple.
|
||||
- **Class organization order:** public static constants → private static variables → private instance variables → public functions → private helpers (grouped with their public caller).
|
||||
|
||||
## 10. Systems
|
||||
|
||||
- **Separate construction from use.** Startup code (wiring dependencies) lives in one place; business logic does not touch it.
|
||||
- **Dependency injection** over hardcoded `new` expressions deep in business logic.
|
||||
- **Cross-cutting concerns** (logging, transactions, security, metrics) belong in middleware/aspects/interceptors, not scattered through the domain.
|
||||
- Let architecture **emerge** as the system grows, but defend the seams — the places where modules plug together — at every stage.
|
||||
- See [references/architecture.md](references/architecture.md).
|
||||
|
||||
## 11. Emergent Design — Kent Beck's Four Rules
|
||||
|
||||
A design is *simple* if, in priority order, it:
|
||||
|
||||
1. **Runs all the tests.**
|
||||
2. **Contains no duplication.**
|
||||
3. **Expresses the intent of the programmer.**
|
||||
4. **Minimizes the number of classes and methods.**
|
||||
|
||||
Order matters. Never sacrifice test coverage to reduce class count.
|
||||
|
||||
---
|
||||
|
||||
## 12. Code Smells — A Review Checklist
|
||||
|
||||
Scan for these before declaring code done.
|
||||
|
||||
**Function smells**
|
||||
- Too many arguments (>3).
|
||||
- Flag arguments (booleans that switch behavior).
|
||||
- Selector arguments (enums that drive internal switches).
|
||||
- Dead parameters, dead code paths.
|
||||
- Obscure intent — body reads as a sequence of mystery steps.
|
||||
- Misplaced responsibility — function lives on the wrong class/module.
|
||||
- Inappropriate static — method should be polymorphic.
|
||||
|
||||
**Class smells**
|
||||
- Feature envy (method uses another class's data more than its own).
|
||||
- Large class / god class.
|
||||
- Too many responsibilities.
|
||||
- Inappropriate intimacy between classes.
|
||||
- Lazy class — no longer pulls its weight.
|
||||
|
||||
**General smells**
|
||||
- **Duplication** — the #1 smell; hunt it everywhere.
|
||||
- Magic numbers or strings — extract named constants.
|
||||
- Inconsistent names for the same concept.
|
||||
- Artificial coupling — things glued together that don't belong.
|
||||
- Negative conditionals (`if (!isNotEmpty)`) — invert.
|
||||
- **If-else or switch chains on type** — replace with factory + polymorphism; see [references/paradigms.md](references/paradigms.md).
|
||||
- Dead code.
|
||||
- Vertical separation — variables declared far from use.
|
||||
- Boundaries violated — business logic importing framework classes, entities reaching the DB.
|
||||
|
||||
**Name smells**
|
||||
- Non-descriptive (`data`, `info`, `handle`, `process`).
|
||||
- Names not matching level of abstraction.
|
||||
- Mental mapping required (decode `r`, `q`, `tmp`).
|
||||
- Encoded names (Hungarian, type prefixes).
|
||||
- Side info stuffed into names ("the `u` here is because…").
|
||||
|
||||
**Test smells**
|
||||
- Insufficient tests.
|
||||
- Skipped or ignored tests accumulating.
|
||||
- Tests dependent on execution order.
|
||||
- Tests that test the framework, not your code.
|
||||
- Slow tests (they will stop being run).
|
||||
- Over-mocking — tests break on refactor without any real regression.
|
||||
|
||||
---
|
||||
|
||||
## A Note on Disagreement
|
||||
|
||||
Clean Code is not scripture. Uncle Bob himself has revised definitions across his career (SRP has three formulations; LSP was initially taught as about inheritance and later clarified as about subtyping). He has also publicly recommended John Ousterhout's *A Philosophy of Software Design* (2022) while noting disagreements with Ousterhout on two key Clean Code points:
|
||||
|
||||
- Ousterhout prefers larger functions with "deep implementations behind narrow interfaces."
|
||||
- Ousterhout advocates more use of comments.
|
||||
|
||||
These are honest, ongoing debates. The skill's default aligns with Uncle Bob; you are expected to think about the tradeoffs, not apply the rules blindly. When a codebase's structure makes the opposite choice more readable for the reader-in-context, the reader wins.
|
||||
|
||||
## One More Thing
|
||||
|
||||
Clean code is not a destination — it is a **practice**. Every function is an opportunity to practice. The single most important lesson is not any rule; it is the **attitude of caring enough to leave the code better than you found it**.
|
||||
|
||||
The professional commitment behind all of this is captured in [references/oath.md](references/oath.md) and elaborated in [references/craft.md](references/craft.md).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Sources
|
||||
|
||||
This skill was built from material crawled from the Uncle Bob source network you provided. This file is an honest accounting of what was fetched and what wasn't, so you can verify the provenance of any claim and follow up if something seems off.
|
||||
|
||||
## Fully read (body captured)
|
||||
|
||||
### Course outlines (cleancoder.com/files/)
|
||||
|
||||
- cleanCodeCourse.md
|
||||
- cleanArchitectureCourse.md
|
||||
- tdd.md
|
||||
- advanced-tdd.md
|
||||
- clean-agile.md
|
||||
- immersion.md
|
||||
|
||||
### Blog posts (blog.cleancoder.com) — full or near-full body
|
||||
|
||||
- The Clean Architecture (2012-08-13)
|
||||
- The Programmer's Oath (2015-11-18)
|
||||
- The Single Responsibility Principle (2014-05-08, partial)
|
||||
- The Open Closed Principle (2014-05-12, via search)
|
||||
- OO vs FP (2014-11-24, via search)
|
||||
- First-Class Tests (2017-05-05, via search)
|
||||
- Testing Like the TSA (2017-03-06, via search)
|
||||
- Test Contra-variance (2017-10-03, via search)
|
||||
- Necessary Comments (2017-02-23)
|
||||
- Solid Relevance (2020-10-18)
|
||||
- if-else-switch (2021-03-06, via search)
|
||||
- Screaming Architecture (2011-09-30, via search)
|
||||
- A Little Architecture (2016-01-04, via search)
|
||||
- Classes vs Data Structures (2019-06-16, via search)
|
||||
- Functional Classes (2023-01-18, via search)
|
||||
- Loopy (2020-09-30, via search)
|
||||
|
||||
### Other sources
|
||||
|
||||
- cleancoder.com/books (full — Uncle Bob's recommended-reading list with annotations)
|
||||
- sites.google.com/site/unclebobconsultingllc/.../articles (index + inline excerpts of ~40 articles)
|
||||
- sites.google.com/.../articles/one-thing-extract-till-you-drop (full body after fighting Google Sites' massive-nav rendering)
|
||||
- butunclebob.com old-wiki pages via web_search:
|
||||
- ArticleS.UncleBob.PrinciplesOfOod (SRP evolution, component principles)
|
||||
- ArticleS.UncleBob.AgilePeopleStillDontGetIt (shipping untested code is unacceptable)
|
||||
- ArticleS.UncleBob.OnDocumentation (Martin's First Law of Documentation)
|
||||
- ArticleS.UncleBob.P2M2 (pairing guidelines)
|
||||
- ArticleS.UncleBob.IuseVisitor (Visitor pattern as SRP-preserver)
|
||||
- ArticleS.MichaelFeathers.LiskovSubstitutionInDynamicLanguages (LSP in dynamic languages)
|
||||
|
||||
## Titles only (index excerpts captured, body not fetched)
|
||||
|
||||
~120 additional blog.cleancoder.com posts — titles, dates, and (for ~30 of them) 1–3 sentence excerpts from Anthropic web_search results. Topics include: The Cycles of TDD, The Little Mocker, When to Mock, Monogamous TDD, Test Induced Design Damage?, The Transformation Priority Premise (+ three follow-ups), Three Paradigms, Why Clojure, FP Basics E1–E4, The Principles of Craftsmanship, The Humble Craftsman, Saying No, The Churn, The Lurn, NO DB, Clean Micro-service Architecture, Framework Bound, 'Interface' Considered Harmful, The Little Singleton, Type Wars, TDD Doesn't Work, TDD Harms Architecture, and roughly 90 others (including essays on hiring, certification, industry culture, and a handful of politically-themed posts).
|
||||
|
||||
The remaining ~40 Google Sites top-level articles — again, titles captured with some inline excerpts, bodies not individually fetched due to Google Sites' nav-heavy rendering (each fetch costs ~20K tokens in nav alone before the article body begins).
|
||||
|
||||
## Not fetched
|
||||
|
||||
- butunclebob.com front page and the full wiki structure beyond the few articles surfaced via web_search. The old wiki is largely dormant; the direct URLs I tried returned empty pages; content was reachable only via search result excerpts.
|
||||
- cleancoders.com video-episode descriptions (peripheral; the book/blog material covers the same ground).
|
||||
- Uncle Bob's Twitter/X archive (referenced in a few places but I fetched only material that appeared in search results).
|
||||
|
||||
## What this means for the skill
|
||||
|
||||
- Core-principle claims (the SOLID wording, the Clean Architecture layers, the TDD three laws, the F.I.R.S.T. attributes, the test taxonomy, the Data/Object Anti-Symmetry, the three paradigms, the oath) are backed by directly fetched body text or search-excerpt evidence.
|
||||
- Some narrower historical and biographical claims (the Parnas 1972 citation, Liskov 1987 date, Meyer 1988 OOSC citation, attribution of specific phrasings to specific articles) were corroborated across multiple sources but not verified at their original citations. If any of them matters for a formal use, double-check against the original paper.
|
||||
- The sections on ~120 unfetched blog posts are not directly represented in the skill — the skill is built from the ~25 sources I did read fully, plus the consistent pattern of excerpts from the rest.
|
||||
|
||||
If there's a specific blog post from the unfetched list that you want me to integrate, point me at it and I'll fetch it directly and revise the relevant reference file.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Clean Architecture
|
||||
|
||||
When to load this reference: when structuring a new service or module, drawing boundaries between components, deciding what a microservice should own, untangling framework coupling, reviewing a system for testability and longevity, or choosing a top-level folder structure.
|
||||
|
||||
Clean Architecture is Uncle Bob's synthesis of Hexagonal Architecture (Alistair Cockburn), Onion Architecture (Jeffrey Palermo), DCI (Coplien & Reenskaug), and BCE (Ivar Jacobson, *Object-Oriented Software Engineering*, 1992). They differ in detail but agree on one goal: **separation of concerns by layering**, with business rules isolated from delivery mechanisms.
|
||||
|
||||
The foundational insight comes from Jacobson: **architectures are structures that support the use cases of the system.** Not frameworks. Not databases. Not UIs. Use cases.
|
||||
|
||||
---
|
||||
|
||||
## What a Clean Architecture Produces
|
||||
|
||||
A system that is:
|
||||
|
||||
1. **Independent of frameworks.** Frameworks are tools, not constraints.
|
||||
2. **Testable.** Business rules tested without UI, DB, web server, or any external element.
|
||||
3. **Independent of UI.** The UI can be replaced (web → console → CLI → TUI) without touching business rules.
|
||||
4. **Independent of database.** Swap PostgreSQL for MongoDB, ClickHouse, or in-memory without rewriting domain logic.
|
||||
5. **Independent of any external agency.** The core business rules know nothing about the outside world.
|
||||
|
||||
**The database is a detail.** So is the web. So is the framework. These are the most common sources of architectural rot because developers mistake them for foundations.
|
||||
|
||||
> "The database is merely an IO device. It happens to provide some useful tools for sorting, querying, and reporting but those are ancillary to the system architecture." — *A Little Architecture* (2016)
|
||||
|
||||
---
|
||||
|
||||
## The Dependency Rule
|
||||
|
||||
The one rule that makes everything else work:
|
||||
|
||||
> **Source code dependencies point only inward, toward higher-level policy.**
|
||||
|
||||
- Nothing in an inner layer may name anything from an outer layer — no function, class, variable, or data format.
|
||||
- Data formats convenient for the outer layer (ORM row struct, JSON DTO) must not leak inward.
|
||||
- Control flow may cross boundaries in either direction, but *source dependencies* point only inward. The Dependency Inversion Principle (see [solid.md](solid.md)) is the mechanism that makes this possible when control flow runs outward.
|
||||
|
||||
When this rule is obeyed, external details — databases, frameworks, UIs — become replaceable plugins.
|
||||
|
||||
---
|
||||
|
||||
## The Four Concentric Layers
|
||||
|
||||
Schematic. You may need more or fewer for a given system, but the Dependency Rule always applies.
|
||||
|
||||
### 1. Entities (innermost)
|
||||
|
||||
Encapsulate **enterprise-wide** business rules. An entity can be a class with methods or a data structure plus functions — style choice.
|
||||
|
||||
- Entities know nothing about applications, use cases, frameworks, or anything outside.
|
||||
- For single applications (no "enterprise"), these are your core business objects.
|
||||
- These are the least affected by operational change. Changes to page navigation, auth mechanisms, or DB schemas must not reach here.
|
||||
|
||||
### 2. Use Cases
|
||||
|
||||
Encapsulate **application-specific** business rules. Use cases orchestrate entities to accomplish the application's goals.
|
||||
|
||||
- A use case directs entities; it does not contain enterprise-wide rules itself.
|
||||
- Changes to the application's *behavior* land here. Changes to externalities do not.
|
||||
- Simple request/response data structures (not entities) flow in and out.
|
||||
|
||||
### 3. Interface Adapters
|
||||
|
||||
Convert data between the format convenient for use cases/entities and the format convenient for external agencies.
|
||||
|
||||
- MVC's Controllers, Presenters, and Views live here.
|
||||
- All SQL lives here (if the database is SQL). Nothing inside knows about SQL.
|
||||
- DTOs are translated into domain types and back here.
|
||||
|
||||
### 4. Frameworks and Drivers (outermost)
|
||||
|
||||
The web framework, the database, the message broker, the file system. Glue code only — you do not write much application logic here. Details live here because details change, and the outer ring is where change is cheap.
|
||||
|
||||
---
|
||||
|
||||
## Crossing Boundaries
|
||||
|
||||
When control flow needs to run outward — a use case needs to call a presenter — a direct call violates the Dependency Rule (the inner layer names something in the outer layer).
|
||||
|
||||
**Solution: the Dependency Inversion Principle.** The use case calls an interface (an "output port") defined in its own layer. The outer-layer presenter implements that interface. Control flows outward; source dependencies point inward. Same pattern works for repositories, gateways, any outward call.
|
||||
|
||||
---
|
||||
|
||||
## What Crosses Boundaries
|
||||
|
||||
Only **simple data structures** cross boundaries:
|
||||
- Plain structs or Data Transfer Objects.
|
||||
- Primitive arguments in function calls.
|
||||
- Maps/dictionaries, when appropriate.
|
||||
|
||||
Never pass Entity objects or ORM row objects across boundaries — that couples layers. Translate to the format most convenient for the inner circle at every boundary crossing.
|
||||
|
||||
---
|
||||
|
||||
## Screaming Architecture
|
||||
|
||||
From the 2011 blog post of the same name. The top-level layout of a project should *scream* what the system does, not what framework it uses.
|
||||
|
||||
**The blueprint metaphor.** Imagine looking at the blueprints of a building. A single-family residence: front entrance, foyer, living room, dining room, kitchen. A library: grand entrance, check-in clerks, reading areas, galleries of bookshelves. A shopping mall: corridors, store bays, parking lots. You can tell what kind of building it is before you see any sign.
|
||||
|
||||
What does *your* application architecture scream?
|
||||
|
||||
**Bad top-level:** `controllers/`, `models/`, `views/`, `services/`. Tells you the system uses MVC. Tells you nothing about what the system is for.
|
||||
|
||||
**Good top-level:** `billing/`, `shipping/`, `catalog/`, `fraud_detection/`. Now you know what the system does.
|
||||
|
||||
**Why it matters:** A good architecture lets you defer decisions about Rails, Spring, Hibernate, Tomcat, MySQL, or React until much later in the project. A framework-centric top-level locks those decisions in day one, and also makes the code base mute about its own purpose. The web is a *delivery mechanism*; the database is a *detail*. Neither should dominate your system structure.
|
||||
|
||||
If a stranger cannot tell from the directory structure whether they are looking at an e-commerce platform or a hospital records system, the architecture is failing at the highest level.
|
||||
|
||||
---
|
||||
|
||||
## Component Principles
|
||||
|
||||
Once modules are organized, they group into **components** — independently deployable units (libraries, services, jars, crates). Two sets of principles govern them.
|
||||
|
||||
### Component Cohesion
|
||||
|
||||
- **REP — Reuse/Release Equivalence Principle.** The unit of reuse is the unit of release.
|
||||
- **CCP — Common Closure Principle.** Group together classes that change for the same reasons at the same times. (SRP at component scale.)
|
||||
- **CRP — Common Reuse Principle.** Classes used together belong together; classes not used together don't. (ISP at component scale.)
|
||||
|
||||
These three pull in different directions — the **tension diagram** is a triangle and component design is an ongoing balance. Early-stage projects lean toward REP+CCP (ship quickly, include more); mature, widely-reused components shift toward CRP (exclude what clients don't need).
|
||||
|
||||
### Component Coupling
|
||||
|
||||
- **ADP — Acyclic Dependencies Principle.** The dependency graph among components must have no cycles. Break cycles with DIP or by extracting a new component both sides depend on.
|
||||
- **SDP — Stable Dependencies Principle.** Depend in the direction of stability.
|
||||
- **SAP — Stable Abstractions Principle.** Stable components should be abstract; volatile components should be concrete.
|
||||
|
||||
---
|
||||
|
||||
## Applying This in Practice
|
||||
|
||||
- **"NO DB" and "NO Web" are valid starting positions.** Business rules should be expressible, testable, and useful before either is chosen.
|
||||
- **Frameworks are tools, not partners.** Wrap them. Keep `import django` or `import axum::Router` out of the core. (Uncle Bob's 2014 "Framework Bound" is a full rant on this.)
|
||||
- **Not every project needs four full circles.** Small projects may collapse Entities and Use Cases into one layer. The Dependency Rule still applies whatever the count.
|
||||
- **The seams matter most.** Architecture lives at the boundaries between components. Defend them at every review — once they rot, replacing a dependency stops being a weekend task and becomes a six-month project.
|
||||
- **Dialog from *A Little Architecture* (2016).** An aspiring architect says they want to make decisions about databases, frameworks, and webservers. Uncle Bob's response: "Oh. Well, then you don't want to become a Software Architect after all." The architect's job is to make decisions that let you **defer** the irrelevant decisions.
|
||||
|
||||
---
|
||||
|
||||
## Architecture and Agility
|
||||
|
||||
From "The Scatology of Agile Architecture" (2009): Agile does *not* mean no up-front architecture. The myth that you evolve architecture from zero is, in Uncle Bob's words, "horse shit." Good teams do enough architecture up front to get the seams right, then let the details emerge inside those seams. See [craft.md](craft.md) for more on this.
|
||||
@@ -0,0 +1,135 @@
|
||||
# The Craftsmanship Ethic
|
||||
|
||||
When to load this reference: when the task raises questions of professional judgment — estimation, deadline pressure, sloppy code accumulating, pairing, saying no to bad requests, or when the user invokes "technical debt" or "mess" or "craftsmanship."
|
||||
|
||||
The behaviors in *Clean Code* and *Clean Architecture* are not ends in themselves. They are instrumental to a larger ethic that Uncle Bob has been refining since the early 2000s: the software craftsmanship movement, which evolved into the Programmer's Oath (see [oath.md](oath.md)) and the 2022 book *Clean Craftsmanship*. This reference captures the non-code parts of that ethic that still materially affect how Claude should behave when writing or reviewing code.
|
||||
|
||||
---
|
||||
|
||||
## Clean Code Is a Practice, Not a Destination
|
||||
|
||||
From many posts, consolidated:
|
||||
|
||||
- Every function is an opportunity to practice. You don't reach "clean" and stop.
|
||||
- The **Boy Scout Rule** is the daily discipline: leave each module cleaner than you found it, even if just by renaming one variable.
|
||||
- "The only way to go fast is to go well." Dirty code does not trade speed for quality; it trades illusory short-term speed for enormous long-term slowness. This is the Productivity Roller-Coaster: feel fast for weeks, slow to a crawl over months.
|
||||
- From *Going Fast*: "Fast" is a property you get by being disciplined, not by skipping discipline.
|
||||
- From *Speed Kills*: conversely, the illusion that you can get fast by cutting corners almost always kills a project.
|
||||
|
||||
---
|
||||
|
||||
## A Mess Is Not Technical Debt
|
||||
|
||||
**This distinction matters.** People conflate them, and the conflation is a way to make sloppiness sound respectable.
|
||||
|
||||
**Ward Cunningham's Technical Debt (the original, 1992):** a **deliberate, considered** engineering trade-off when a schedule or learning situation justifies using a suboptimal design temporarily. You know what the right design is; you are choosing the wrong one now, *with intent*, and you will fix it later. Example: initial website uses server-rendered pages because there's no time to build an Ajax framework.
|
||||
|
||||
**A Mess:** bad code written by someone who did not do the work to understand the problem, did not refactor, did not test, did not think. It is not "debt" because it was never a considered choice — it is just poor craftsmanship.
|
||||
|
||||
From "A Mess is not a Technical Debt" (2009): calling a mess "technical debt" launders bad craftsmanship as if it were responsible engineering. It is not. When refusing to ship a mess, do not accept the framing that "we're just taking on some debt." Debt is deliberate; a mess is sloppy.
|
||||
|
||||
**Fowler's four quadrants of debt** (prudent/imprudent × deliberate/inadvertent) are a better map:
|
||||
- Deliberate+prudent: the original Cunningham case ("we must ship now, we'll fix X next sprint").
|
||||
- Deliberate+imprudent: "we don't have time for design" (toxic, not actually debt).
|
||||
- Inadvertent+prudent: "now I know how we should have done it" (honest learning).
|
||||
- Inadvertent+imprudent: plain-old-mess masquerading as debt.
|
||||
|
||||
---
|
||||
|
||||
## Saying No
|
||||
|
||||
From "Saying No!" (2009) and elaborated in *The Clean Coder*: professionals have an obligation to refuse impossible or unethical demands.
|
||||
|
||||
- When a manager asks for something that cannot be done correctly in the time allowed, the professional answer is "no, but here's what I can do," not "yes" followed by silent quality compromise.
|
||||
- "Yes and then failing to deliver" is worse than "no" — the manager loses the ability to plan around reality.
|
||||
- Professionals push back on their own estimates. If pressure makes you shorten a number you believe, you have stopped being the expert the organization pays you to be.
|
||||
|
||||
Applied to Claude: when a user asks for something that cannot be done well under the stated constraints (skip the tests, skip the error handling, ship something that will crash), the right response includes the pushback. Offer what you *can* deliver cleanly, not a degraded version of what was asked for.
|
||||
|
||||
---
|
||||
|
||||
## Honest Estimates
|
||||
|
||||
From "Why is Estimating so Hard?" (2012) and related posts:
|
||||
|
||||
- Estimates are **probability distributions, not numbers.** Give a range: optimistic, nominal, pessimistic. Three-point estimates are honest; single-point estimates almost always compress uncertainty.
|
||||
- "I don't know yet, let me do a spike" is a professional answer. "I'll have it by Friday" said under duress without real confidence is not.
|
||||
- An estimate is not a commitment; commitments come from negotiating after estimates are honestly given.
|
||||
|
||||
---
|
||||
|
||||
## On Documentation
|
||||
|
||||
**Martin's First Law of Documentation** (from *Agile Software Development: PPP*): "Produce no document unless its need is immediate and significant."
|
||||
|
||||
This is often misread as "Agile means no documentation." It does not. From the butunclebob.com wiki:
|
||||
|
||||
> "Agile Development is NOT development without documentation. Rejecting documentation in the name of 'Agility' is a flawed religious behavior. It is just as flawed as uncritically accepting the production of dozens of different documents."
|
||||
|
||||
Documentation, like any engineering activity, is prioritized by ROI. Create documents that more than pay back the effort to produce them. Skip documents written because policy requires them but no one will read them.
|
||||
|
||||
What counts as documentation:
|
||||
- API docs (rustdoc, TSDoc, javadoc) — high value, close to code.
|
||||
- Architecture decision records (ADRs) — capture *why* decisions were made.
|
||||
- Onboarding / how-to guides — pay back every time a new person joins.
|
||||
- Specs for important flows — pay back every time a flow breaks.
|
||||
|
||||
What does not:
|
||||
- Status reports that recapitulate information already in the tracker.
|
||||
- Design documents written after implementation that no one will read.
|
||||
- Comments that restate the code.
|
||||
|
||||
---
|
||||
|
||||
## Pairing Guidelines
|
||||
|
||||
From "Pairing Guidelines" (2021) and earlier posts:
|
||||
|
||||
- Pairing is a **tool**, not a religion. Use it when it works; don't when it doesn't.
|
||||
- Mature agile teams pair maybe 50–70% of the time, not 100%.
|
||||
- Some problems require "time, focus, and silence" to study before attacking. Pairing on those is worse than solo.
|
||||
- Pair at the start of a story to align direction; solo for deep-focus passages; reunite to review.
|
||||
- The strategy "separate the syntax issues from the semantic issues" is a useful pattern when stuck as a pair — refactor the mechanical noise (parsing, config, regex) into a helper module so the core algorithm can be reasoned about on its own.
|
||||
|
||||
---
|
||||
|
||||
## Shipping Under Pressure
|
||||
|
||||
From "AgilePeopleStillDontGetIt" (2006) and "We must ship now and deal with consequences" (2009):
|
||||
|
||||
- "It is completely unacceptable to release code that you aren't sure works. Either make sure it works, or don't ship it. Period."
|
||||
- "A feature that crashes is much worse than a feature that doesn't exist. A feature that doesn't exist will defer revenue. A feature that crashes makes enemies out of customers."
|
||||
- "Our customers interpret features as promises. When we release a feature we are promising that it works. When it crashes we have broken that promise."
|
||||
- "Shipping untested software is shipping something unfinished and your customers will force you to finish it. The pressure will be higher at orders of magnitude if you finish it AFTER you have shipped it."
|
||||
|
||||
Applied to Claude: when asked to ship quickly and drop tests, the honest response is that the tests aren't slowing you down; they are the only way to ship correctly. "Going fast" without tests produces code that will return tenfold in debugging and firefighting over the next weeks.
|
||||
|
||||
---
|
||||
|
||||
## Professionalism Is Not Rigid Formalism
|
||||
|
||||
From "Why the sea is boiling hot" (2009) — the closing statement of Uncle Bob's 2009 Rails Conf keynote:
|
||||
|
||||
> "Professionalism does not mean rigid formalism. Professionalism does not mean adhering to bureaucracy. Professionalism is **honor**. Professionalism is being honest with yourself and disciplined in the way you work. Professionalism is not letting fear take over."
|
||||
|
||||
Honor and discipline. Not process for its own sake. The rules in this skill are tools for being disciplined; they are not a rulebook to hide behind.
|
||||
|
||||
---
|
||||
|
||||
## The Tricky Bit
|
||||
|
||||
From "The Tricky Bit" (2010): a British MP flew the Concorde and complained to the designer that going supersonic "didn't feel any different at all." The designer beamed: "Yes, that was the tricky bit."
|
||||
|
||||
Clean code, good architecture, solid tests — when they are working, the reader doesn't notice. The absence of friction is the product. Code that *announces* how clever it is, how much architecture it has, how sophisticated its patterns are, is usually the opposite of clean. The goal is invisibility — the reader moves through the code and feels nothing but understanding.
|
||||
|
||||
---
|
||||
|
||||
## When Claude Should Invoke Any of This
|
||||
|
||||
- **User wants to skip tests "just this once":** reference the "A Mess is not Debt" framing and the shipping-under-pressure material.
|
||||
- **User wants a speculative number instead of a range:** offer a range and explain why.
|
||||
- **User wants you to document something they won't read:** suggest the minimum viable doc that pays its way.
|
||||
- **User wants a "quick fix" that you can see will rot the module:** explain the Boy Scout Rule cost — a quick fix that makes the code worse is a negative-value change even at zero time cost.
|
||||
- **User says "we're doing Agile, we don't write documentation":** redirect to Martin's First Law and the "it's about ROI" framing.
|
||||
|
||||
The oath ([oath.md](oath.md)) captures the promises. This file captures the attitude and the vocabulary for navigating the hard conversations where craft meets pressure.
|
||||
@@ -0,0 +1,81 @@
|
||||
# The Programmer's Oath
|
||||
|
||||
When to load this reference: when the task raises a question of professional responsibility — shipping under pressure with known defects, accumulating "temporary" hacks, padding estimates, degrading code to hit a deadline, or pushing back on a manager who is asking for the impossible.
|
||||
|
||||
In 2015, Uncle Bob proposed an oath for programmers, modeled loosely on the Hippocratic Oath, that captures the professional commitments behind Clean Code, Clean Architecture, and Agile practice. The oath exists because software increasingly runs civilization — cars, medical devices, infrastructure, money — and the people who write that software carry a corresponding weight of responsibility. His 2019 post "737 Max 8" is the most concrete illustration of why this matters: what happens when mission-critical software ships without the oath.
|
||||
|
||||
---
|
||||
|
||||
## The Oath
|
||||
|
||||
*In order to defend and preserve the honor of the profession of computer programmers, I promise that, to the best of my ability and judgement:*
|
||||
|
||||
1. **I will not produce harmful code.** Not code known to be defective. Not code that degrades the product. Not code that lies.
|
||||
2. **The code I produce will always be my best work.** I will not knowingly allow defective behavior or defective structure to accumulate.
|
||||
3. **I will produce, with each release, a quick, sure, and repeatable proof that every element of the code works as it should.** Automated tests that run fast and tell the truth.
|
||||
4. **I will make frequent, small releases** so that I do not impede the progress of others.
|
||||
5. **I will fearlessly and relentlessly improve my creations at every opportunity.** I will never degrade them. Each commit leaves the system at least as clean as I found it — preferably cleaner.
|
||||
6. **I will do all that I can to keep the productivity of myself and others as high as possible.** I will do nothing that decreases that productivity. This is why clean code matters: dirty code taxes every future developer who reads it.
|
||||
7. **I will continuously ensure that others can cover for me, and that I can cover for them.** No knowledge silos. No indispensable person. Shared ownership of the codebase.
|
||||
8. **I will produce estimates that are honest** both in magnitude and precision. **I will not make promises without certainty.** "I don't know yet" is a professional answer. Padding to please is not.
|
||||
9. **I will never stop learning and improving my craft.** Programming is a practice, not a credential.
|
||||
|
||||
---
|
||||
|
||||
## How This Oath Informs the Skill
|
||||
|
||||
The oath is not abstract ethics — it is directly wired into the practices that Clean Code, Clean Architecture, and TDD embody.
|
||||
|
||||
- **"Not produce harmful code"** → No flag arguments hiding branches, no null returns that hide failures, no functions with secret side effects. Clean Code, Chapter 7 (Error Handling). Mission-critical relevance: the 737 MAX is what happens when software that can kill people is built without the oath.
|
||||
- **"Best work … will not allow defective structure to accumulate"** → the Boy Scout Rule. A mess is not technical debt (see [craft.md](craft.md)).
|
||||
- **"Quick, sure, repeatable proof"** → TDD and F.I.R.S.T. tests. See [tdd.md](tdd.md).
|
||||
- **"Frequent small releases"** → continuous integration and the Agile practices. If a release takes a day, releases are infrequent by economic necessity; fix the release process, not the schedule.
|
||||
- **"Fearlessly improve"** → only possible with a test net. Without tests, every change is a gamble, so code rots by default. *Legacy code is code without tests* (Feathers).
|
||||
- **"Keep productivity high"** → the whole argument for clean code. Dirty code slows the team down. "We'll clean it up later" is almost always a lie.
|
||||
- **"Others can cover for me"** → pairing, code review, shared ownership, honest naming. See [craft.md](craft.md). If only one person understands the billing module, the billing module is a liability.
|
||||
- **"Honest estimates"** → ranges, not points. Three-point estimates (optimistic, nominal, pessimistic). "I cannot know yet, I will know more after the spike" is honest; a number pulled from the air to calm a manager is not.
|
||||
- **"Never stop learning"** → the craftsmanship attitude. Every function is practice.
|
||||
|
||||
---
|
||||
|
||||
## The Underlying Argument: Professionalism Is Honor
|
||||
|
||||
From Uncle Bob's 2009 Rails Conf keynote "Why the sea is boiling hot":
|
||||
|
||||
> "Professionalism does not mean rigid formalism. Professionalism does not mean adhering to bureaucracy. Professionalism is honor. Professionalism is being honest with yourself and disciplined in the way you work. Professionalism is not letting fear take over."
|
||||
|
||||
The oath is not a rulebook; it is a set of promises about the kind of engineer you intend to be. When pressure mounts and shortcuts beckon, those promises are what keep the work honest.
|
||||
|
||||
---
|
||||
|
||||
## The 737 MAX Argument
|
||||
|
||||
From "737 Max 8" (2019). Software increasingly operates in domains where failure kills. Cars, medical devices, aircraft, infrastructure control systems. The argument Uncle Bob makes:
|
||||
|
||||
- Our industry still doesn't act like a profession. There are no widely-enforced standards for competence. Anyone can ship anything.
|
||||
- When failures in a civilian product only mean frustrated customers, this is tolerable. When failures mean dead people, it is not.
|
||||
- Either the industry disciplines itself, or governments will do it for us — and government-imposed discipline will be crude and bureaucratic compared to what we could choose.
|
||||
- The oath is a voluntary first step.
|
||||
|
||||
For Claude-written code, this usually doesn't feel immediate. But the oath's standards apply to any code that runs. A user asking Claude to "just ship it, we'll fix bugs later" in a payment system is asking Claude to contribute to harm the user probably hasn't imagined.
|
||||
|
||||
---
|
||||
|
||||
## When to Cite This in a Session
|
||||
|
||||
- **User is asking Claude to skip tests "just this once."** Clean code practice says no — the "just this once" mindset is how codebases accumulate the slow-rotting debt the oath forbids.
|
||||
- **User is asking Claude to estimate something the code cannot yet answer.** Honest ranges beat false precision. "I do not know, let me find out" is honest.
|
||||
- **User is under deadline pressure and proposes shipping code with known defects.** The oath is unambiguous: unknown defects happen to everyone; knowingly shipped ones are a professional failure.
|
||||
- **User asks Claude to produce code that lies** — silently swallows errors, hides side effects, misrepresents state. Decline and explain why.
|
||||
- **A proposed change would make future developers' lives worse** for a short-term gain. Promise 6 of the oath is explicitly about not decreasing others' productivity.
|
||||
- **User is shipping to a safety-critical domain** and cutting corners. Name the risk explicitly; the user may not have considered it.
|
||||
|
||||
The oath is not a stick to beat users with. It is a reminder of the stakes that clean practice is trying to address, and a useful touchstone when a decision has no obvious technical answer. Lead with helpfulness; invoke the oath when helpfulness would mean producing work Claude should not be producing.
|
||||
|
||||
---
|
||||
|
||||
## Related Reading
|
||||
|
||||
- [craft.md](craft.md) — the surrounding ethic in more detail: mess vs. debt, saying no, estimation, pairing, the "tricky bit."
|
||||
- Uncle Bob's *The Clean Coder* (2011) — the book-length treatment of professional ethics in software. Unlike *Clean Code*, it is about behavior rather than technique.
|
||||
- Uncle Bob's *Clean Craftsmanship* (2021) — the discipline-focused sequel to *Clean Code*, integrating TDD, refactoring, simple design, and the oath.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Programming Paradigms
|
||||
|
||||
When to load this reference: when choosing between procedural and OO style, writing code in a functional language, refactoring switch statements, handling persistence, or when the user asks about OO vs FP, design patterns, or Clean Code's chapter on objects and data structures.
|
||||
|
||||
Uncle Bob's reductionist framing of the three paradigms is a powerful lens for reasoning about code shape. Each paradigm imposes **discipline** by **taking something away** from the programmer.
|
||||
|
||||
---
|
||||
|
||||
## The Three Paradigms
|
||||
|
||||
Each paradigm is defined by what it *forbids*, not by what it enables. This is Dijkstra-style reasoning: fewer primitives mean fewer ways to be wrong.
|
||||
|
||||
### Structured Programming
|
||||
|
||||
- **Forbids:** `goto` (direct transfer of control).
|
||||
- **Provides:** Sequence, Selection (if/else), Iteration (while). Dijkstra proved any algorithm can be expressed with just these three.
|
||||
- **Why:** Dijkstra's 1968 letter "Go To Statement Considered Harmful." Unrestricted `goto` makes programs impossible to reason about. Restricted control flow is provably correct for sequence, selection, iteration; not provably correct with arbitrary `goto`.
|
||||
- **Status today:** Won so completely that most developers don't even realize they're using it. Modern languages don't have `goto` (or discourage it).
|
||||
|
||||
### Object-Oriented Programming
|
||||
|
||||
- **Forbids:** Raw function pointers / indirect transfer of control through unmanaged pointers.
|
||||
- **Provides:** Polymorphism. The language manages the function pointers for you.
|
||||
- **Why:** Raw function pointers (as in C) are correct but fragile — every caller must follow conventions every time. Polymorphism provides the same runtime capability through a disciplined mechanism: objects carry their own dispatch table, set up once when the object is created.
|
||||
- **The reductionist core:** OO = polymorphism. Encapsulation, methods-bound-to-data, and simple inheritance exist in C and Pascal too. **What OO uniquely gives you is convenient polymorphism.** "OO without polymorphism is not OO."
|
||||
|
||||
### Functional Programming
|
||||
|
||||
- **Forbids:** Assignment / mutation of state.
|
||||
- **Provides:** Referential transparency. Same inputs → same outputs, always, everywhere.
|
||||
- **Why:** Shared mutable state is the source of most concurrency bugs and most "action at a distance" reasoning failures. Forbidding it means state changes are explicit and localized.
|
||||
- **The reductionist core:** FP = referential transparency. Higher-order functions exist in OO languages too (Smalltalk, etc.). What FP uniquely gives you is the guarantee that a function call cannot change anything you didn't pass to it.
|
||||
|
||||
### Why "Three Paradigms" Matters
|
||||
|
||||
These are **orthogonal**, not competing. Each removes a different freedom:
|
||||
|
||||
| Paradigm | Discipline on | Mechanism |
|
||||
|---|---|---|
|
||||
| Structured | Direct transfer of control | No `goto` |
|
||||
| OO | Indirect transfer of control | Polymorphism |
|
||||
| FP | Assignment | Referential transparency |
|
||||
|
||||
A language can (and modern ones often do) impose all three disciplines at once. You can write OO code functionally, and you can apply SOLID inside a functional program.
|
||||
|
||||
---
|
||||
|
||||
## OO and FP Are Orthogonal, Not Exclusive
|
||||
|
||||
From Uncle Bob's 2014 and 2018 "FP vs OO" posts:
|
||||
|
||||
> "The principles of software design still apply, regardless of your programming style. The fact that you've decided to use a language that doesn't have an assignment operator does not mean that you can ignore the Single Responsibility Principle; or that the Open Closed Principle is somehow automatic."
|
||||
|
||||
And from his 2023 *Functional Classes* post: "Should you subdivide a functional program into classes the way you would an object oriented program? Yes. You should. Because the rules don't change just because you've chosen to use immutable data structures."
|
||||
|
||||
**A class, reductively:** "A group of cohesive and narrowly defined functions that operate on an encapsulated data structure. The functions may, or may not, be polymorphically deployed." This definition works in Clojure, Haskell, Rust, Java, TypeScript, Python.
|
||||
|
||||
**The design principles transcend paradigm:**
|
||||
- SRP applies in Clojure (group functions by actor).
|
||||
- OCP applies in Haskell (use abstraction, add type class instances).
|
||||
- DIP applies anywhere there are modules.
|
||||
- A "class" in the sense above is a cohesive namespace of related functions plus the data they operate on.
|
||||
|
||||
---
|
||||
|
||||
## Data/Object Anti-Symmetry
|
||||
|
||||
From Chapter 6 of *Clean Code* and elaborated in the 2019 blog post "Classes vs. Data Structures."
|
||||
|
||||
**Two definitions that complement each other:**
|
||||
|
||||
- **Object:** A set of functions that operate on **implied** data. Data exists but is hidden. Callers see only functions.
|
||||
- **Data structure:** A set of data elements operated on by **implied** functions. Data is exposed. Functions exist but are not specified by the structure.
|
||||
|
||||
They are **diametric opposites**. You cannot fully be both.
|
||||
|
||||
### Consequences
|
||||
|
||||
- **DTOs are data structures, not objects.**
|
||||
- **Database tables are data structures, not objects.**
|
||||
- **"ORM" is a misnomer.** There is no mapping between database tables and objects. ORMs map tables to data structures. (This is not pedantic; it explains why ORMs have the smells they do.)
|
||||
- **Polymorphism is the marker of objects.** When `shape.area()` dispatches dynamically to the Circle or Square implementation, you are doing OO. When `area(shape)` is a free function with `match shape { Circle => …, Square => … }`, you are doing procedural work.
|
||||
|
||||
### The Four Symmetry Rules
|
||||
|
||||
These tell you when to choose each style.
|
||||
|
||||
| | Add new FUNCTION | Add new TYPE |
|
||||
|---|---|---|
|
||||
| **Classes (OO)** | **Hard** — change every class | **Easy** — add one class |
|
||||
| **Data structures (procedural)** | **Easy** — add one function | **Hard** — change every function |
|
||||
|
||||
**Choose by expected axis of change:**
|
||||
|
||||
- If you expect more new functions than new types → procedural style with data structures + functions (e.g., visitor pattern, pattern matching over enums, Clojure-style).
|
||||
- If you expect more new types than new functions → OO style with classes and polymorphism.
|
||||
- The **Visitor pattern** is procedural-style behavior over OO data — it bridges the two.
|
||||
|
||||
**In Rust specifically:** enums with `match` are procedural by this taxonomy (add a variant → every match must handle it); traits with implementations are OO (add an impl → no existing code changes). Neither is wrong; choose by axis of change. If new variants are rare and new operations are common, the enum wins. If new types are common, the trait wins.
|
||||
|
||||
---
|
||||
|
||||
## Polymorphism and if-else-switch
|
||||
|
||||
From "if-else-switch" (2021). A very common refactor:
|
||||
|
||||
**The pattern.** When you see an if/else chain or switch that branches by type or by "kind," replace it with:
|
||||
|
||||
1. A base class or interface with one method per case.
|
||||
2. Concrete implementations, one per branch.
|
||||
3. A **factory** that creates the right implementation based on the discriminator (this is where the if/else/switch ends up, condensed into one place).
|
||||
4. The business logic calls the interface, never the discriminator.
|
||||
|
||||
**Runtime characteristics are identical.** If/else does a procedural lookup, switch uses a compiler-built jump table, polymorphic dispatch uses a vtable — similar performance.
|
||||
|
||||
**What you gain:**
|
||||
- The high-level business code no longer transitively depends on every low-level case.
|
||||
- Each case is its own named method, not an indented block within a branch.
|
||||
- New cases = new classes (OCP).
|
||||
- Independent deployment becomes possible: the high-level module and each implementation can live in separate components.
|
||||
|
||||
**When not to apply:** if the switch is small, stable, and not type-based (e.g., processing a small enum of flags in one place), leaving it as a switch is fine. The rule is "factor out switches on *type*," not "destroy every conditional."
|
||||
|
||||
---
|
||||
|
||||
## The Tell-Don't-Ask Style
|
||||
|
||||
Alan Kay's original OO conception: objects as cells in a biological system.
|
||||
|
||||
> "Neurons are tellers, not askers. Hormones are tellers, not askers. In biological systems, communication was half-duplex."
|
||||
|
||||
Instead of:
|
||||
```
|
||||
if account.getBalance() < amount:
|
||||
throw InsufficientFunds
|
||||
account.setBalance(account.getBalance() - amount)
|
||||
```
|
||||
|
||||
Say:
|
||||
```
|
||||
account.withdraw(amount) // account decides if it can, and how
|
||||
```
|
||||
|
||||
The caller stops interrogating state and deciding. The object owns the decision. This is what Law of Demeter is a weak shadow of — the deeper principle is that state should not leak out of objects.
|
||||
|
||||
---
|
||||
|
||||
## Loops and State Machines
|
||||
|
||||
From the 2020 "Loopy" post. Any program with nested loops can be refactored step-by-step into a Turing-style finite state machine, with tests passing at every step. This is a useful mental exercise: a nested loop is a state machine that a programmer wrote too compactly.
|
||||
|
||||
Practical takeaway: when a loop body is getting complex, consider extracting an explicit state (enum of states) and transitioning between them. Reads better than four nested `if`s; generalizes better; easier to test.
|
||||
|
||||
---
|
||||
|
||||
## Applying This in Practice
|
||||
|
||||
- **Default to OO + polymorphism** for business logic where types vary (entities, strategies, handlers). Polymorphism is the mechanism behind DIP, OCP, and Clean Architecture boundaries.
|
||||
- **Default to data structures + free functions** for values, messages, and records that flow through the system. DTOs, events, API payloads, DB rows.
|
||||
- **Keep the two species apart.** A "hybrid" that has both public fields and rich behavior usually gets the worst of both worlds.
|
||||
- **FP is not an exception to SOLID.** Cohesion, SRP, DIP all still apply; you express them with namespaces, protocols, or type classes instead of classes.
|
||||
@@ -0,0 +1,138 @@
|
||||
# SOLID Principles
|
||||
|
||||
The five class- and module-level principles that make a codebase flexible, testable, and resistant to rot. Load when designing a new class, module, or microservice; when refactoring for flexibility; or when reviewing dependencies between units.
|
||||
|
||||
Uncle Bob reaffirmed in 2020 that these principles remain as relevant as they were in the 1990s, and that microservices and dynamic typing do *not* make them obsolete. The shape of software — sequence, selection, iteration — has not fundamentally changed since the first stored-program computer, and neither has the shape of good design.
|
||||
|
||||
The "SOLID" acronym was popularized by Uncle Bob, but most of the individual principles predate him. Understanding the roots helps you reason about them when they seem to conflict.
|
||||
|
||||
---
|
||||
|
||||
## SRP — Single Responsibility Principle
|
||||
|
||||
**Roots.** David L. Parnas, "On the Criteria To Be Used in Decomposing Systems into Modules" (CACM 15:12, December 1972): "begin decomposition with a list of difficult design decisions or design decisions *which are likely to change*. Each module is designed to hide such a decision from the others." Edsger Dijkstra's 1974 paper "On the role of scientific thought" coined **Separation of Concerns**. Larry Constantine, Tom DeMarco, and Meilir Page-Jones formalized **cohesion** as "functional relatedness" through the 1970s and 1980s. Uncle Bob consolidated these into "SRP" in the late 1990s (he suspects he borrowed the name from Bertrand Meyer).
|
||||
|
||||
**The definition has evolved three times:**
|
||||
|
||||
1. *Early:* "A module should do one thing, do it well, and do it only."
|
||||
2. *Clean Code era (2008):* "A class should have one, and only one, reason to change."
|
||||
3. *Clean Architecture (2017):* "A module should be responsible to one, and only one, **actor**." An actor is a person or a tightly coupled group representing a single narrowly defined business function.
|
||||
|
||||
The actor formulation is the current canonical one. Uncle Bob gave this example (2014): "SRP is about people. When you write a software module, you want to make sure that when changes are requested, those changes can only originate from a single person, or rather, a single tightly coupled group of people representing a single narrowly defined business function. Why? Because we don't want to get the COO fired because we made a change requested by the CTO."
|
||||
|
||||
**What this means in practice:**
|
||||
- Business rules do not live in GUI code.
|
||||
- SQL queries do not live next to communication protocols.
|
||||
- A module modified because of a change in report format should not also be modified because of a change in tax law — those are different actors.
|
||||
- Microservices do not solve SRP. A tangled microservice is still tangled; a tangled set of microservices is worse than a tangled monolith because the tangles cross network boundaries.
|
||||
|
||||
**Smells that suggest SRP violation:**
|
||||
- The class keeps getting edited by different people for different reasons.
|
||||
- Changing one feature breaks tests for an unrelated feature.
|
||||
- The class name contains "and," "manager," "util," or "helper."
|
||||
|
||||
---
|
||||
|
||||
## OCP — Open-Closed Principle
|
||||
|
||||
**Roots.** Bertrand Meyer, *Object-Oriented Software Construction* (1988). Meyer's original formulation used **implementation inheritance**: "A class is closed, since it may be compiled, stored in a library, baselined, and used by client classes. But it is also open, since any new class may use it as parent, adding new features."
|
||||
|
||||
In the 1990s the principle was **reinterpreted polymorphically** (largely by Uncle Bob's writing): use abstracted interfaces with multiple implementations, not base-class inheritance. This is the dominant modern reading.
|
||||
|
||||
**Definition (modern):** "A module should be open for extension but closed for modification." Or: "You should be able to extend the behavior of a system without having to modify that system."
|
||||
|
||||
**In practice:**
|
||||
- Imagine writing a system where writing to disk, printer, screen, or network pipe were scattered as `if` cases throughout business logic. That is the OCP failure mode — and why operating systems invented device independence.
|
||||
- New payment methods plug in without modifying the checkout flow. New report formats plug in without modifying the report generator.
|
||||
- **Plugin architectures are the apotheosis of OCP.** Eclipse, IntelliJ, VS Code, Vim, Minecraft — all extend without modifying.
|
||||
|
||||
**Simple code is both open and closed.** Complexity in this dimension comes from *missing* abstractions, not extra ones. Do not over-abstract speculatively.
|
||||
|
||||
---
|
||||
|
||||
## LSP — Liskov Substitution Principle
|
||||
|
||||
**Roots.** Barbara Liskov's 1987 keynote "Data abstraction and hierarchy" at OOPSLA, later formalized with Jeannette Wing (1994). Liskov's own framing is mathematical: about subtypes that preserve the behavior their supertype's clients expect.
|
||||
|
||||
**A common misread:** LSP is about inheritance. It is not — it is about **subtyping**. Subtyping includes:
|
||||
- Interface implementations.
|
||||
- Trait/protocol implementations.
|
||||
- Duck types that satisfy an implicit interface.
|
||||
- Any context where "type B can be used where type A is expected."
|
||||
|
||||
**Canonical definition (Uncle Bob):** "A program that uses an interface must not be confused by an implementation of that interface."
|
||||
|
||||
**In practice:**
|
||||
- The classic Square-from-Rectangle example: Square cannot be substituted for Rectangle without surprising callers who expect to vary width and height independently.
|
||||
- Keep subtype contracts crisp. Document invariants, preconditions, postconditions (Meyer's Design by Contract from *OOSC*).
|
||||
- If a subtype needs to refuse operations the base type promised, the hierarchy is wrong.
|
||||
- An abstraction that leaks its concretions breaks LSP.
|
||||
|
||||
Michael Feathers noted (2006) that in dynamic languages LSP applies just as strongly — it is about substitutability, not inheritance. Duck typing gives you substitutability; LSP is what ensures substituted objects behave sensibly.
|
||||
|
||||
---
|
||||
|
||||
## ISP — Interface Segregation Principle
|
||||
|
||||
**Definition:** "Keep interfaces small so that clients don't end up depending on things they don't need."
|
||||
|
||||
ISP matters most where compile-time or link-time coupling exists — which is still most of the industry. In statically typed languages (Rust, Java, Go, C#, C++, Swift, TypeScript with strict mode), when module A depends on module B at compile time but only uses one method, a change to an unrelated method in B still triggers recompilation and redeployment of A.
|
||||
|
||||
Dynamically typed languages are not immune — package managers (npm, Maven, Cargo, pip) impose coupling through version resolution.
|
||||
|
||||
**In practice:**
|
||||
- Prefer many small, role-focused interfaces over one fat interface.
|
||||
- Split a class with two unrelated interfaces into two classes (often aligns with SRP).
|
||||
- In Rust, favor small focused traits over giant trait blobs.
|
||||
|
||||
---
|
||||
|
||||
## DIP — Dependency Inversion Principle
|
||||
|
||||
**Definition:** "Depend in the direction of abstraction. High-level modules should not depend on low-level details; both should depend on abstractions."
|
||||
|
||||
This is the single most important architectural principle. Computations that produce business value must not depend on:
|
||||
- SQL dialects.
|
||||
- HTTP framework types.
|
||||
- File formats.
|
||||
- UI widget libraries.
|
||||
- Vendor SDKs.
|
||||
|
||||
**The mechanic (from "OO vs FP", 2014):** In most software systems when one function calls another, the runtime dependency and the source-code dependency point the same direction. When polymorphism is injected between them, an **inversion of the source-code dependency** occurs. The calling module still depends on the called module at runtime, but the source of the calling module depends only on a polymorphic interface — not on the source of the called module. The called module becomes a plugin.
|
||||
|
||||
**In practice:**
|
||||
- Define interfaces in terms of what the domain needs, not what the infrastructure provides.
|
||||
- Place those interfaces in the high-level module; place implementations in the low-level module.
|
||||
- Wire them together in a single composition root (the Main component).
|
||||
- "To be robust, a system must employ polymorphism across significant architectural boundaries."
|
||||
|
||||
DIP is the mechanism that makes Clean Architecture's Dependency Rule enforceable. See [architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Component Principles
|
||||
|
||||
Once modules are organized, they group into **components** — independently deployable units (libraries, services, jars, crates). Two sets of principles govern them.
|
||||
|
||||
### Component Cohesion — what belongs together
|
||||
|
||||
- **REP — Reuse/Release Equivalence Principle.** The unit of reuse is the unit of release. Things reused together must be released together, with version numbers.
|
||||
- **CCP — Common Closure Principle.** Group together classes that change for the same reasons at the same times. (SRP at component scale.)
|
||||
- **CRP — Common Reuse Principle.** Classes that are used together belong together; classes that are not used together do not belong together. (ISP at component scale.)
|
||||
|
||||
**The tension diagram.** These three principles pull in different directions — REP and CCP tend to include more; CRP tends to exclude. Designing components is an ongoing balance within the triangle they form. Where you place a component in this triangle depends on maturity: early-stage components lean toward REP+CCP (include-more); mature, widely-reused components shift toward CRP (exclude).
|
||||
|
||||
### Component Coupling — how they relate
|
||||
|
||||
- **ADP — Acyclic Dependencies Principle.** The dependency graph among components must have no cycles. Break cycles with DIP or by extracting a new component both sides depend on.
|
||||
- **SDP — Stable Dependencies Principle.** Depend in the direction of stability. Volatile components may depend on stable ones, never the reverse.
|
||||
- **SAP — Stable Abstractions Principle.** Stable components should be abstract, so they can be extended. Volatile components should be concrete. Corollary: depend on stable abstractions.
|
||||
|
||||
---
|
||||
|
||||
## Applying SOLID in Practice
|
||||
|
||||
- **Do not apply all five at once on day one.** Let the code tell you which principle is being violated. Pain surfaces one at a time.
|
||||
- **Duplication and rigidity are the strongest signals.** If you cannot change one thing without changing ten, some SOLID principle is being violated — usually SRP or DIP.
|
||||
- **Beware of over-abstraction.** SOLID is about *managing* dependencies, not maximizing interfaces. A speculative interface with one implementation is YAGNI until a second implementation appears or tests demand it.
|
||||
- **Uncle Bob's synthesis (2020):** Simple code is both open and closed. Simple code maintains crisp subtype relationships. Simple code depends on abstractions. The principles describe what simple code looks like when it survives contact with change.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Test Driven Development
|
||||
|
||||
When to load this reference: when writing new tests, reviewing tests, debugging brittle tests, dealing with legacy code that resists testing, or deciding on a testing strategy for a module.
|
||||
|
||||
Tests are the safety net that makes fearless refactoring possible. Without that net, every change is a gamble; with it, every change can be confident. Tests are also the most precise, executable documentation a system will ever have.
|
||||
|
||||
**Michael Feathers's definition of legacy code:** *Legacy code is code without tests.* Uncle Bob adopted this definition and it underpins the TDD practice.
|
||||
|
||||
---
|
||||
|
||||
## The Three Laws of TDD
|
||||
|
||||
1. **You are not allowed to write any production code unless it is to make a failing unit test pass.**
|
||||
2. **You are not allowed to write any more of a unit test than is sufficient to fail — and compilation failures are failures.**
|
||||
3. **You are not allowed to write any more production code than is sufficient to pass the one failing unit test.**
|
||||
|
||||
The loop is measured in seconds, not minutes. Write a line or two of test, see it fail, write a line or two of production, see it pass, repeat. This is the **nano-cycle**.
|
||||
|
||||
**Why these rules:**
|
||||
|
||||
- **Debugging time plummets** — you were never more than 60 seconds away from working code.
|
||||
- **Tests are automatic documentation** that cannot fall out of sync with the system.
|
||||
- **Design improves** because code written to be testable is naturally decoupled.
|
||||
- **Refactoring becomes fearless** because the net catches regressions instantly.
|
||||
|
||||
This is double-entry bookkeeping for software. Every behavior is stated twice — once in the test, once in the code — and they must agree.
|
||||
|
||||
---
|
||||
|
||||
## F.I.R.S.T. — Clean Tests
|
||||
|
||||
Clean tests are:
|
||||
|
||||
- **Fast.** Slow tests will stop being run. If a suite takes 10 minutes, people will commit without running it. 15-minute CI feedback is too slow for the TDD loop.
|
||||
- **Independent.** No test depends on another. Any test can run alone, in any order.
|
||||
- **Repeatable.** Same result in every environment — laptop, CI, staging. If a test depends on the network, wall clock, or shared database, it is flaky and must be fixed.
|
||||
- **Self-validating.** Pass or fail. No manual inspection.
|
||||
- **Timely.** Written *just before* the production code they cover — not "when we have time."
|
||||
|
||||
Test code is first-class. Hold it to the same clarity bar as production code. When tests rot, production code rots.
|
||||
|
||||
---
|
||||
|
||||
## Canonical Test Definitions (First-Class Tests, 2017)
|
||||
|
||||
The industry has been sloppy about what "unit," "integration," "acceptance," etc. mean. Uncle Bob's proposed taxonomy:
|
||||
|
||||
- **Unit Test.** Written by a programmer, for a programmer. Ensures production code does what the programmer expected. Sometimes called **programmer test** or **micro-test**.
|
||||
- **Acceptance Test.** Written by the business (or a BA/QA representing the business). Ensures production code does what the business expects. Sometimes called **customer test**.
|
||||
- **Integration Test.** Written by architects or technical leads. Ensures a sub-assembly of system components operates correctly. **These are plumbing tests, not business-rule tests** — rules are already verified by unit and acceptance tests.
|
||||
- **System Test.** An integration test for the whole integrated system.
|
||||
- **Micro-test** (Mike Hill / @GeePawHill). A unit test at very small scope — tests a single function or small group.
|
||||
- **Functional Test.** A unit test at larger scope, with mocks for slow components.
|
||||
|
||||
> "Integration tests do not test business rules. Those rules have already been tested, once by programmer (unit) tests, and again by customer (acceptance) tests. Integration tests test the plumbing and choreography of the components." — Uncle Bob (Twitter, 2019)
|
||||
|
||||
**Implication for Claude when writing tests:** Know which kind of test you are writing and don't couple it to the wrong kind. If you're asked to "add tests" for a pure function, write unit/micro tests. If you're asked to "test the API works end-to-end," that's integration/system. Don't test business rules in an integration test — the rules should already have unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Test Structure
|
||||
|
||||
Use one of these structures; be consistent.
|
||||
|
||||
- **Arrange / Act / Assert** — set up context, perform action, check result.
|
||||
- **Given / When / Then** — same thing in BDD vocabulary.
|
||||
- **Build / Operate / Check** — same thing, different vocabulary.
|
||||
|
||||
One *concept* per test. Often one assertion, but "one concept" is the real rule — several assertions verifying the same behavior are fine.
|
||||
|
||||
### Test Naming
|
||||
|
||||
Name the test for what it verifies about behavior, not for the method. `returns_empty_list_when_given_empty_input` beats `test_filter_1`. If the name runs long, the test is probably doing more than one thing.
|
||||
|
||||
---
|
||||
|
||||
## Test Doubles — The Hierarchy
|
||||
|
||||
Adapted from Gerard Meszaros's *xUnit Patterns*, with Uncle Bob's gloss. Each is a degree of sophistication above the last.
|
||||
|
||||
- **Dummy.** Passed around but never used. Fills a parameter slot.
|
||||
- **Stub.** Returns canned answers. No logic.
|
||||
- **Spy.** A stub that records the calls it received.
|
||||
- **Mock.** A spy with expectations built in: set up *before* the act, verified *after*. Fails if expected interactions didn't happen.
|
||||
- **Fake.** A working implementation with production-unfit shortcuts — e.g., in-memory repo that stands in for a real database.
|
||||
|
||||
Pick the lowest-sophistication double that does the job. A mock where a stub would suffice adds coupling and fragility.
|
||||
|
||||
**Uncle Bob hand-rolls most of his Java mocks** ("Manual Mocking," 2009) rather than using mockito, to keep explicit control over ceremony. This is a taste preference, not a rule, but his reasoning (less magic, clearer test code) is worth knowing.
|
||||
|
||||
---
|
||||
|
||||
## Chicago vs. London (State-ism vs. Mockism)
|
||||
|
||||
Two schools of TDD.
|
||||
|
||||
- **Chicago / Classical / State-ist.** Test behavior through state. Exercise the object, assert on its final state (or collaborators' state). Minimal mocking. Less coupled to implementation detail.
|
||||
- **London / Mockist.** Test behavior through interactions. Mock collaborators; assert on calls. More explicit about collaboration but more coupled to it.
|
||||
|
||||
**Practical guidance:** Use Chicago for value objects, algorithms, internal logic. Use London at **boundaries** — where the code coordinates external collaborators. Never mock what you own when you could exercise it directly; mock (or fake) what you do not own when the real thing would make the test slow or flaky.
|
||||
|
||||
---
|
||||
|
||||
## Fragile Tests
|
||||
|
||||
Tests that break without a real regression are worse than no tests — they train developers to ignore the suite. Known causes:
|
||||
|
||||
- **Interface sensitivity.** Tests break because a signature changed, not behavior. Often a sign of excessive mocking.
|
||||
- **Behavior sensitivity.** Tests break because an unrelated behavior changed. A sign of poor isolation.
|
||||
- **Data sensitivity.** Tests break because shared fixtures changed. Fix by making tests own their data.
|
||||
- **Context sensitivity.** Tests pass locally, fail in CI. Remove environmental coupling: clock, network, filesystem, time zone.
|
||||
- **Over-specification.** Tests assert on more than the behavior under test — internal call order, private fields, log output. Assert on what the *user of the code* would observe.
|
||||
|
||||
A fragile test is a design signal — usually a missing abstraction, a leaky boundary, or an over-eager mock.
|
||||
|
||||
"Skilled TDDers understand that neither micro-tests, nor functional tests, nor acceptance tests should be coupled to the implementation of the system." — *First-Class Tests* (2017)
|
||||
|
||||
---
|
||||
|
||||
## As Tests Get More Specific, Code Gets More Generic
|
||||
|
||||
Uncle Bob's formulation (2009): tests are specifications. As you add tests, the specifications grow more specific. To satisfy them all, the production code must grow more *generic*. This is the inverse relationship that drives TDD-induced good design — the code gets pushed toward abstractions that cover many cases rather than one.
|
||||
|
||||
---
|
||||
|
||||
## The Transformation Priority Premise (TPP)
|
||||
|
||||
When making a failing test pass, there is a natural ordering of changes, simpler before more complex. Prefer earlier transformations when more than one would work:
|
||||
|
||||
1. `{} → nil` — no code → returning nil
|
||||
2. `nil → constant` — return a constant
|
||||
3. `constant → variable` — replace constant with a variable
|
||||
4. `statement → statements` — add another statement
|
||||
5. `unconditional → if` — introduce a branch
|
||||
6. `scalar → array` — move from a single value to a collection
|
||||
7. `array → container` — move to a richer collection type
|
||||
8. `statement → recursion` — replace a statement with recursion
|
||||
9. `if → while` — replace a branch with iteration
|
||||
10. `expression → function` — extract a function
|
||||
11. `variable → assignment` — introduce mutation
|
||||
|
||||
Using lower-priority transformations earlier creates needless complexity; using higher-priority ones later often indicates a design that could be simpler. TPP is a tiebreaker, not a law — but it usually guides tests toward algorithms that generalize cleanly.
|
||||
|
||||
---
|
||||
|
||||
## The Cycles of TDD
|
||||
|
||||
TDD operates at multiple time scales simultaneously. Working at only one scale produces bad software.
|
||||
|
||||
- **Seconds (Red-Green-Refactor).** The nano-cycle.
|
||||
- **Minutes (Specific-to-Generic).** Tests grow more specific; code grows more generic.
|
||||
- **Tens of minutes (Boundary).** Periodically step back and ask whether the module is still well-factored. Extract. Rename. Regroup.
|
||||
- **Hours (Architecture).** Once a day or so, step back further: are the component boundaries still correct? Does the Dependency Rule still hold?
|
||||
- **Days (Acceptance).** Acceptance tests (at the feature/use-case level) close the loop with the business.
|
||||
|
||||
Skipping the larger cycles is the most common failure mode. Red-Green-Refactor religiously, but never step back to reconsider architecture, and you end up with a suite of fine-grained tests wrapped around a tangled ball of mud.
|
||||
|
||||
---
|
||||
|
||||
## Testing Across Architectural Boundaries
|
||||
|
||||
- **The test boundary** is a first-class part of architecture. Tests live outside the system they test.
|
||||
- **Do not couple tests to UI frameworks or databases.** If a test needs a browser to exercise a use case, the boundary between use case and UI is broken.
|
||||
- **Legacy code strategy** (Feathers). Find a seam — a place where behavior can be varied without modifying code. Write a characterization test at that seam to pin down current behavior. Refactor behind the pin. Repeat.
|
||||
|
||||
Uncle Bob's position on test placement: "Don't test through UIs. Don't test through web servers. Test as close to the code as you can." — *Testing Like the TSA* (2017)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Writing tests after the fact.** Produces tests that confirm whatever the code happens to do, including the bugs. Much lower value than TDD.
|
||||
- **Slow test suites.** If any unit test takes more than a fraction of a second, isolate it. Keep the unit suite fast and run integration tests separately.
|
||||
- **Mocking what you own.** Prefer real objects for your own code.
|
||||
- **Testing implementation details.** Refactors then break tests without any real regression, and people conclude "TDD gets in the way of refactoring." It doesn't — the tests were just wrong.
|
||||
- **Skipping refactor.** Red-Green-… is not TDD. The third step is where design emerges.
|
||||
- **Over-coverage religion.** Uncle Bob's ratio for some project types: 20% test-first, 80% test-after is acceptable for controllers/models/views (per *Testing Like the TSA*, 2017). The three laws are guidance for the hottest logic in the system, not dogma for every trivial accessor.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: commit-convention
|
||||
description: Enforce commit message convention for features, fixes, chores, and docs
|
||||
---
|
||||
|
||||
# Commit Message Convention
|
||||
|
||||
All commit messages MUST follow one of the four formats below. The type is lowercase. The subject is imperative, lowercase, and has no trailing period.
|
||||
|
||||
## Types
|
||||
|
||||
### `feat` — New feature or new behaviour
|
||||
Scope is required: the name of the affected module.
|
||||
|
||||
```
|
||||
feat(<module>): <what changed or what feature>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
feat(auth): add google oauth sign-in
|
||||
feat(billing): support multi-currency invoices
|
||||
feat(users): allow avatar upload
|
||||
```
|
||||
|
||||
### `fix` — Bug fix or issue resolution
|
||||
Scope is required: the name of the affected module.
|
||||
|
||||
```
|
||||
fix(<module>): <what was fixed>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
fix(auth): prevent token refresh race condition
|
||||
fix(cart): correct total when discount is zero
|
||||
fix(api): return 404 instead of 500 on missing user
|
||||
```
|
||||
|
||||
### `chore` — Housekeeping, dependency bumps, config changes
|
||||
No scope.
|
||||
|
||||
```
|
||||
chore: <what was adjusted>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
chore: adjust package.json version (bump)
|
||||
chore: update eslint config
|
||||
chore: remove unused devDependency
|
||||
```
|
||||
|
||||
### `docs` — Documentation only
|
||||
No scope.
|
||||
|
||||
```
|
||||
docs: <what changed>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
docs: add setup guide to README
|
||||
docs: document commit convention
|
||||
docs: clarify env variable defaults
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. Type is ALWAYS lowercase (`feat`, `fix`, `chore`, `docs`).
|
||||
2. `feat` and `fix` REQUIRE a module scope in parentheses.
|
||||
3. `chore` and `docs` do NOT use a scope.
|
||||
4. Subject line is imperative mood ("add", not "added" or "adds").
|
||||
5. Subject line is lowercase, no trailing period.
|
||||
6. Keep the subject under ~72 characters.
|
||||
7. If a commit needs multiple types, split it into multiple commits.
|
||||
|
||||
## Choosing the right type
|
||||
|
||||
| Situation | Type |
|
||||
|-----------|------|
|
||||
| New user-facing capability | `feat` |
|
||||
| New internal behaviour | `feat` |
|
||||
| Something was broken, now works | `fix` |
|
||||
| Version bump in package.json | `chore` |
|
||||
| Lockfile regeneration, config tweak | `chore` |
|
||||
| README, guide, or comment-only change | `docs` |
|
||||
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: kana-rust-backend-best-practice
|
||||
description: Reference guide for building a Rust clean-architecture backend with Axum, SeaORM, Argon2, JWT, and sea-orm-migration. Use when scaffolding a new Rust service, adding a feature (domain + use-case + repository + handler), or reviewing Rust code against the axum-clean-architecture reference layout.
|
||||
---
|
||||
|
||||
# Axum Clean Architecture Skill
|
||||
|
||||
Reference stack (see `../axum-clean-architecture`):
|
||||
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| HTTP framework | Axum 0.8 |
|
||||
| ORM | SeaORM 1.1 (PostgreSQL via sqlx + rustls) |
|
||||
| Migrations | sea-orm-migration |
|
||||
| Auth | Argon2 (password hashing) + jsonwebtoken (JWT) |
|
||||
| Validation | zod-rs (schema-driven, mirrors Zod) |
|
||||
| Pagination | paginator-rs + paginator-sea-orm + paginator-axum |
|
||||
| Observability | tracing + tracing-subscriber |
|
||||
| Middleware | tower-http (CORS, TraceLayer) |
|
||||
| Runtime | Tokio (full features) |
|
||||
| Error handling | anyhow (app-level), typed domain errors |
|
||||
|
||||
---
|
||||
|
||||
## 0. Workspace layout
|
||||
|
||||
```
|
||||
axum-clean-architecture/
|
||||
├── Cargo.toml # workspace, resolver = "3"
|
||||
├── apps/
|
||||
│ ├── iam/ # core domain library (lib crate)
|
||||
│ │ └── src/
|
||||
│ │ ├── domain/ # entities, repository traits, domain errors
|
||||
│ │ ├── application/ # use cases + port traits
|
||||
│ │ ├── infrastructure/ # SeaORM repos + auth services
|
||||
│ │ └── presentation/ # Axum handlers, DTOs, middleware, state
|
||||
│ ├── gateway/ # binary — assembles router, runs server
|
||||
│ └── bootstrap/ # binary — seeds permissions/roles/admin
|
||||
├── .config/ # AppServer, database/env helpers
|
||||
└── .migrations/ # sea-orm-migration crate
|
||||
```
|
||||
|
||||
The `iam` app is a **library crate**. `gateway` and `bootstrap` depend on it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dependency rules (strictly enforced)
|
||||
|
||||
```
|
||||
presentation → application → domain
|
||||
infrastructure → domain (implements domain traits)
|
||||
presentation → infrastructure (only to wire AppState)
|
||||
```
|
||||
|
||||
- Domain has **zero** external crate dependencies beyond `uuid`, `chrono`.
|
||||
- Use cases depend only on port traits — never on concrete infrastructure types.
|
||||
- Presentation instantiates use cases from `AppState` on every request; use cases are not stored.
|
||||
|
||||
---
|
||||
|
||||
## 2. Domain layer
|
||||
|
||||
### Entity pattern
|
||||
|
||||
Plain Rust structs — no derives beyond what domain logic needs. No ORM annotations.
|
||||
|
||||
```rust
|
||||
// domain/user/entity.rs
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserPatch {
|
||||
pub email: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Repository trait pattern
|
||||
|
||||
Use `impl Future` in trait methods (Rust 2024 edition, no `async_trait` needed).
|
||||
Always `Send + Sync` on the trait.
|
||||
|
||||
```rust
|
||||
// domain/user/repository.rs
|
||||
pub trait UserRepository: Send + Sync {
|
||||
fn find_by_id(&self, id: Uuid)
|
||||
-> impl Future<Output = Result<Option<User>, RepositoryError>> + Send;
|
||||
fn find_by_email(&self, email: &str)
|
||||
-> impl Future<Output = Result<Option<User>, RepositoryError>> + Send;
|
||||
fn create(&self, user: NewUser)
|
||||
-> impl Future<Output = Result<User, RepositoryError>> + Send;
|
||||
fn update(&self, id: Uuid, patch: UserPatch)
|
||||
-> impl Future<Output = Result<User, RepositoryError>> + Send;
|
||||
fn delete(&self, id: Uuid)
|
||||
-> impl Future<Output = Result<(), RepositoryError>> + Send;
|
||||
fn list(&self, params: &PaginationParams)
|
||||
-> impl Future<Output = Result<PaginatorResponse<User>, RepositoryError>> + Send;
|
||||
}
|
||||
```
|
||||
|
||||
### Shared RepositoryError (lives in domain)
|
||||
|
||||
```rust
|
||||
pub enum RepositoryError {
|
||||
NotFound,
|
||||
Conflict(String),
|
||||
Database(String),
|
||||
}
|
||||
```
|
||||
|
||||
### Domain errors
|
||||
|
||||
Per-aggregate. `AuthError` lives in `domain/auth/errors.rs`:
|
||||
|
||||
```rust
|
||||
pub enum AuthError {
|
||||
InvalidCredentials,
|
||||
EmailAlreadyExists,
|
||||
UserNotFound,
|
||||
PasswordHashFailed(String),
|
||||
PasswordVerificationFailed(String),
|
||||
TokenGenerationFailed(String),
|
||||
InvalidToken(String),
|
||||
RepositoryError(String),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Application layer
|
||||
|
||||
### Port traits (interfaces for external services)
|
||||
|
||||
```rust
|
||||
// application/auth/ports/password.rs
|
||||
pub trait PasswordService: Send + Sync {
|
||||
fn hash(&self, password: &str)
|
||||
-> impl Future<Output = Result<String, PasswordError>> + Send;
|
||||
fn verify(&self, password: &str, hash: &str)
|
||||
-> impl Future<Output = Result<bool, PasswordError>> + Send;
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// application/auth/ports/token.rs
|
||||
pub trait TokenService: Send + Sync {
|
||||
fn generate_auth_tokens(&self, sub: &str)
|
||||
-> impl Future<Output = Result<(String, String), TokenError>> + Send;
|
||||
fn verify_access_token(&self, token: &str)
|
||||
-> Result<String, TokenError>;
|
||||
}
|
||||
```
|
||||
|
||||
### Use case pattern
|
||||
|
||||
Generic over port traits and repository traits. Constructed in the handler, not stored.
|
||||
|
||||
```rust
|
||||
// application/user/use_cases/create.rs
|
||||
pub struct CreateUserCommand { pub email: String, pub password: String }
|
||||
|
||||
pub struct CreateUserUseCase<P, R> {
|
||||
password_service: P,
|
||||
user_repository: R,
|
||||
}
|
||||
|
||||
impl<P: PasswordService, R: UserRepository> CreateUserUseCase<P, R> {
|
||||
pub fn new(password_service: P, user_repository: R) -> Self { ... }
|
||||
|
||||
pub async fn execute(&self, cmd: CreateUserCommand) -> Result<User, AuthError> {
|
||||
// 1. guard: check uniqueness
|
||||
// 2. hash password via port
|
||||
// 3. create domain entity with Uuid::new_v4()
|
||||
// 4. persist via repository
|
||||
// 5. log + return
|
||||
info!(user_id = %user.id, "user created");
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use case naming convention
|
||||
|
||||
| File | Struct | Command/Query |
|
||||
|---|---|---|
|
||||
| `create.rs` | `CreateXxxUseCase` | `CreateXxxCommand` |
|
||||
| `update.rs` | `UpdateXxxUseCase` | `UpdateXxxCommand` |
|
||||
| `delete.rs` | `DeleteXxxUseCase` | `DeleteXxxCommand` |
|
||||
| `detail.rs` | `XxxDetailUseCase` | `XxxDetailQuery` |
|
||||
| `list.rs` | `ListXxxsUseCase` | takes `&PaginationParams` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Infrastructure layer
|
||||
|
||||
### SeaORM repository implementation
|
||||
|
||||
```rust
|
||||
// infrastructure/repository/user.rs
|
||||
#[derive(Clone)]
|
||||
pub struct SeaOrmUserRepository { db: DatabaseConnection }
|
||||
|
||||
// Convert ORM Model → domain entity here (not in domain)
|
||||
impl From<Model> for User { ... }
|
||||
|
||||
// Map DbErr → RepositoryError
|
||||
fn map_db_err(e: DbErr) -> RepositoryError {
|
||||
match e {
|
||||
DbErr::RecordNotFound(_) => RepositoryError::NotFound,
|
||||
other => { error!(error = %other, "database operation failed"); RepositoryError::Database(other.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRepository for SeaOrmUserRepository {
|
||||
async fn create(&self, user: NewUser) -> Result<User, RepositoryError> {
|
||||
let model = ActiveModel {
|
||||
id: Set(user.id),
|
||||
email: Set(user.email),
|
||||
password_hash: Set(user.password_hash),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
let inserted = model.insert(&self.db).await.map_err(|e| match e {
|
||||
DbErr::Exec(ref msg) | DbErr::Query(ref msg)
|
||||
if msg.to_string().contains("unique") =>
|
||||
RepositoryError::Conflict("email already exists".into()),
|
||||
other => RepositoryError::Database(other.to_string()),
|
||||
})?;
|
||||
Ok(User::from(inserted))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pagination uses `paginator-sea-orm`:
|
||||
|
||||
```rust
|
||||
let response = UserEntity::find()
|
||||
.paginate_with(&self.db, params)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::Database(e.to_string()))?;
|
||||
let mapped: Vec<User> = response.data.into_iter().map(User::from).collect();
|
||||
Ok(PaginatorResponse { data: mapped, meta: response.meta })
|
||||
```
|
||||
|
||||
### Auth services
|
||||
|
||||
- `Argon2PasswordService`: uses `spawn_blocking` for CPU-bound hashing, `SaltString::generate(OsRng)`.
|
||||
- `JwtTokenService`: stores `secret: Vec<u8>`, generates separate access/refresh tokens with a `type` claim. `verify_access_token` checks `claims.token_type == "access"`.
|
||||
|
||||
### SeaORM entities (ORM models)
|
||||
|
||||
Live in `infrastructure/repository/entities/`. One file per table. Junction tables (`user_role`, `role_permission`) have composite primary keys. Timestamps use `DateTimeWithTimeZone`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Presentation layer
|
||||
|
||||
### AppState
|
||||
|
||||
Concrete types only — no trait objects. Cheap to clone because `DatabaseConnection` is internally Arc-backed.
|
||||
|
||||
```rust
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub password_service: Argon2PasswordService,
|
||||
pub token_service: JwtTokenService,
|
||||
pub user_repository: SeaOrmUserRepository,
|
||||
pub role_repository: SeaOrmRoleRepository,
|
||||
pub permission_repository: SeaOrmPermissionRepository,
|
||||
}
|
||||
```
|
||||
|
||||
Injected via `Extension(state)` on every handler. Use cases are constructed inside handlers.
|
||||
|
||||
### AppError
|
||||
|
||||
```rust
|
||||
pub enum AppError { BadRequest(String), Unauthorized, Forbidden, NotFound, Conflict(String), Internal(String) }
|
||||
|
||||
impl IntoResponse for AppError { /* maps to HTTP status + JSON { "error": "..." } */ }
|
||||
|
||||
impl From<AuthError> for AppError { ... }
|
||||
impl From<RepositoryError> for AppError { ... }
|
||||
impl From<TokenError> for AppError { ... }
|
||||
```
|
||||
|
||||
Internal errors are logged with `tracing::error!` before returning a generic 500 message.
|
||||
|
||||
### Handler pattern
|
||||
|
||||
```rust
|
||||
#[instrument(skip_all, fields(actor = %actor.id, email = %req.email))]
|
||||
pub async fn create(
|
||||
Extension(state): Extension<AppState>,
|
||||
Extension(actor): Extension<AuthenticatedUser>,
|
||||
Json(req): Json<CreateUserRequest>,
|
||||
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
|
||||
let use_case = CreateUserUseCase::new(
|
||||
state.password_service.clone(),
|
||||
state.user_repository.clone(),
|
||||
);
|
||||
let user = use_case.execute(req.into()).await?;
|
||||
Ok((StatusCode::CREATED, Json(user.into())))
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Always `#[instrument(skip_all, fields(...))]` on every handler.
|
||||
- Use `?` to propagate `AppError` (via `From` impls).
|
||||
- `201 CREATED` for `POST`, `204 NO_CONTENT` for `DELETE`, `200 OK` for everything else.
|
||||
- Pagination handlers return `PaginatedJson<Dto>` via `paginator-axum`.
|
||||
|
||||
### DTO pattern
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Serialize, Deserialize, ZodSchema)]
|
||||
pub struct CreateUserRequest {
|
||||
#[zod(email)]
|
||||
pub email: String,
|
||||
#[zod(min_length(8), max_length(128))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl From<CreateUserRequest> for CreateUserCommand { ... }
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse { pub id: Uuid, pub email: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc> }
|
||||
|
||||
impl From<User> for UserResponse { ... }
|
||||
```
|
||||
|
||||
- Request structs: `Deserialize + ZodSchema`. Use `#[zod(...)]` for field-level validation.
|
||||
- Response structs: `Serialize` only. Never expose `password_hash`.
|
||||
- Conversions: `impl From<Request> for Command` and `impl From<DomainEntity> for Response`.
|
||||
|
||||
### Middleware
|
||||
|
||||
**Auth middleware** (`presentation/middleware/auth.rs`):
|
||||
- Extracts `Bearer <token>` from `Authorization` header.
|
||||
- Calls `state.token_service.verify_access_token(token)`.
|
||||
- Inserts `AuthenticatedUser { id: Uuid }` into request extensions.
|
||||
|
||||
**Permission check** (`presentation/middleware/permission.rs`):
|
||||
- Called inline from handlers: `ensure_permission(&state, &actor, "users:write").await?`.
|
||||
- Queries `permission_repository.find_for_user(actor.id)` and checks by name.
|
||||
|
||||
### Router assembly
|
||||
|
||||
```rust
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/me", me::router())
|
||||
.nest("/users", user::router())
|
||||
.nest("/roles", role::router())
|
||||
.nest("/permissions", permission::router())
|
||||
.layer(Extension(state))
|
||||
}
|
||||
```
|
||||
|
||||
Gateway nests the IAM router at `/api/v1/iam` and adds a health check at `/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Migrations (sea-orm-migration)
|
||||
|
||||
```rust
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager.create_table(
|
||||
Table::create()
|
||||
.table(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Users::Email).string().not_null().unique_key())
|
||||
...
|
||||
.to_owned(),
|
||||
).await
|
||||
}
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
pub enum Users { Table, Id, Email, PasswordHash, CreatedAt, UpdatedAt }
|
||||
```
|
||||
|
||||
Naming convention: `m{YYYYMMDD}_{6-digit-seq}_{description}.rs`, e.g. `m20260413_000001_create_users.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bootstrap pattern
|
||||
|
||||
A separate `bootstrap` binary seeds idempotent system data (permissions, roles, admin user):
|
||||
|
||||
```rust
|
||||
// Check existence before inserting — fully idempotent
|
||||
if permission_repo.find_by_name("rbac:manage").await?.is_none() {
|
||||
permission_repo.create(...).await?;
|
||||
}
|
||||
```
|
||||
|
||||
Standard permissions:
|
||||
- `rbac:manage`, `users:read`, `users:write`, `roles:read`, `roles:write`, `permissions:read`, `permissions:write`
|
||||
|
||||
---
|
||||
|
||||
## 8. Adding a new aggregate (checklist)
|
||||
|
||||
1. **Domain**: `domain/{name}/entity.rs` (entity + NewXxx + XxxPatch), `domain/{name}/repository.rs` (trait + errors), `domain/{name}/errors.rs` if needed.
|
||||
2. **Application**: `application/{name}/mod.rs`, `application/{name}/use_cases/{create,update,delete,detail,list}.rs`.
|
||||
3. **Infrastructure entity**: `infrastructure/repository/entities/{name}.rs` (SeaORM model).
|
||||
4. **Infrastructure repo**: `infrastructure/repository/{name}.rs` (`From<Model>`, `impl XxxRepository for SeaOrmXxxRepository`).
|
||||
5. **Add to AppState**: `{name}_repository: SeaOrmXxxRepository`.
|
||||
6. **Presentation DTO**: `presentation/{name}/dto.rs` (Request + Response with `From` impls).
|
||||
7. **Presentation handlers**: `presentation/{name}/handlers.rs` (`#[instrument]`, construct use case, return DTO).
|
||||
8. **Presentation router**: `presentation/{name}/mod.rs` (define routes with `axum_route_macro` or `Router::new().route(...)`).
|
||||
9. **Nest in `build_router`**.
|
||||
10. **Migration**: new file in `.migrations/src/` following naming convention.
|
||||
11. **Bootstrap**: seed any required initial data.
|
||||
|
||||
---
|
||||
|
||||
## 9. Key conventions
|
||||
|
||||
- Edition **2024** — use `impl Future` in traits, not `#[async_trait]`.
|
||||
- All timestamps are `DateTime<Utc>` in domain; `DateTimeWithTimeZone` in SeaORM models; convert with `.with_timezone(&Utc)`.
|
||||
- UUIDs generated with `Uuid::new_v4()` in the use case, not the repository.
|
||||
- Unique-constraint conflicts detected via string match on `DbErr::Exec`/`DbErr::Query` containing `"unique"` — map to `RepositoryError::Conflict`.
|
||||
- `tracing::instrument` on every handler; log user/actor IDs as structured fields.
|
||||
- `warn!` for expected failures (wrong password, permission denied), `error!` for unexpected DB errors.
|
||||
- Response structs never expose internal fields (`password_hash`, internal IDs from junction tables).
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: push-flow-convention
|
||||
description: Enforce pre-commit/pre-push hooks, lint-staged checks, and semver version bump on every push
|
||||
---
|
||||
|
||||
# Push Flow Convention
|
||||
|
||||
Every repository MUST enforce the same pre-commit, pre-push, and versioning flow. No push lands without hooks, lint-staged, and a version bump.
|
||||
|
||||
## Required Setup
|
||||
|
||||
### 1. Lefthook (pre-commit + pre-push)
|
||||
|
||||
> **Always use [Lefthook](https://lefthook.dev/) for git hooks. Never use husky.**
|
||||
|
||||
Install once per repo:
|
||||
|
||||
```bash
|
||||
pnpm add -D lefthook lint-staged
|
||||
pnpm exec lefthook install
|
||||
```
|
||||
|
||||
Create `lefthook.yml` in project root:
|
||||
|
||||
```yaml
|
||||
pre-commit:
|
||||
commands:
|
||||
lint-staged:
|
||||
run: pnpm exec lint-staged
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
lint-staged:
|
||||
run: pnpm exec lint-staged --diff="origin/{push_remote_branch}...HEAD"
|
||||
bump:
|
||||
run: pnpm run bump
|
||||
```
|
||||
|
||||
### 2. lint-staged
|
||||
|
||||
Declared in `package.json`. Runs ONLY on staged files so commits stay fast.
|
||||
|
||||
```json
|
||||
{
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,yml,yaml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Version bump script
|
||||
|
||||
`package.json` MUST expose a `bump` script used by `pre-push`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"bump": "node scripts/bump-version.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The script inspects the diff between the current branch and its upstream, applies the semver rule below, and writes the new version back to `package.json`. Commit the bump before pushing (amend the previous commit or create a `chore: adjust package.json version (bump)` commit — see `commit-convention`).
|
||||
|
||||
## Semver Rules (applied on every push)
|
||||
|
||||
The bump is based on the changes in the commits being pushed:
|
||||
|
||||
| Change size / kind | Bump |
|
||||
|--------------------|------|
|
||||
| `< 5` changed files across pushed commits | **patch** (`x.y.Z`) |
|
||||
| `>= 5` changed files across pushed commits | **minor** (`x.Y.0`) |
|
||||
| New feature OR new behaviour (any `feat:` commit) | **major** (`X.0.0`) |
|
||||
|
||||
Rules in order of precedence:
|
||||
1. If ANY commit being pushed is a `feat(...)` → **major** bump.
|
||||
2. Otherwise, count files changed (`git diff --name-only origin/<branch>...HEAD | wc -l`):
|
||||
- fewer than 5 → **patch**
|
||||
- 5 or more → **minor**
|
||||
|
||||
The `feat` rule always wins — a new feature is always a major bump regardless of file count.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. NEVER push without pre-commit and pre-push hooks installed.
|
||||
2. NEVER bypass hooks with `--no-verify` — if a hook fails, fix the root cause.
|
||||
3. NEVER push without a version bump. Every push = new version.
|
||||
4. The bump commit MUST use the `chore: adjust package.json version (bump)` message (see `commit-convention`).
|
||||
5. lint-staged MUST run on every commit. A green lint-staged is a prerequisite for the commit to be created.
|
||||
6. If `pnpm` is not the package manager, substitute with `npm` or `yarn` but keep the same flow.
|
||||
|
||||
## Quick verification checklist
|
||||
|
||||
Before declaring the push flow set up, confirm:
|
||||
|
||||
- [ ] `lefthook.yml` exists with `pre-commit` and `pre-push` hooks
|
||||
- [ ] `pnpm exec lefthook install` has been run (hooks registered in `.git/hooks/`)
|
||||
- [ ] `package.json` has a `lint-staged` block
|
||||
- [ ] `package.json` has a `bump` script
|
||||
- [ ] A dry-run commit triggers lint-staged
|
||||
- [ ] A dry-run push triggers the version bump
|
||||
@@ -0,0 +1,50 @@
|
||||
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Standard JWT claims with optional session binding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String,
|
||||
pub exp: u64,
|
||||
pub iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
|
||||
let iat = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
Self {
|
||||
sub,
|
||||
exp,
|
||||
iat,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a set of claims into a JWT string using HS256.
|
||||
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
||||
let token = jsonwebtoken::encode(&header, &claims, &key)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verify a JWT string and return its claims.
|
||||
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
validation.required_spec_claims = ["sub", "exp", "iat"]
|
||||
.iter()
|
||||
.map(|&s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
|
||||
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Auth service implementations: JWT signing/verification, Argon2 password
|
||||
//! hashing, and OAuth loopback server.
|
||||
|
||||
pub mod jwt;
|
||||
pub mod oauth_loopback;
|
||||
pub mod password;
|
||||
+33
-42
@@ -1,7 +1,5 @@
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
//!
|
||||
//! Ported from `zesdex-backend::service::oauth::loopback` to centralise OAuth
|
||||
//! primitives in the `zesdex-iam` crate.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
@@ -13,40 +11,30 @@ pub struct LoopbackServer {
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
/// Bind to an OS-assigned free port on localhost.
|
||||
///
|
||||
/// Return: `Err` if the loopback interface can't be bound.
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
/// The redirect URI to hand to the OAuth authorization endpoint.
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
/// Block until one HTTP request arrives, then extract the `code` query param
|
||||
/// and validate that the `state` param matches the expected value.
|
||||
///
|
||||
/// Flow: accept one connection → apply read timeout → parse request line
|
||||
/// → verify state matches → respond 200/400 depending on whether the code
|
||||
/// was found and state matched.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` if no `code` param is present or the state
|
||||
/// doesn't match `expected_state`.
|
||||
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
|
||||
pub fn wait_for_code(
|
||||
&self,
|
||||
timeout_ms: u64,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
/// Read and parse a single HTTP callback request off `stream`, replying with a status page.
|
||||
///
|
||||
/// Why: writes the HTTP response before returning so the browser tab
|
||||
/// shows a result regardless of whether the code was found.
|
||||
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
|
||||
fn read_callback(
|
||||
stream: &mut TcpStream,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
@@ -54,12 +42,25 @@ impl LoopbackServer {
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab.",
|
||||
(Some(_), false) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nState mismatch — possible CSRF attack.",
|
||||
(None, _) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code.",
|
||||
(Some(_), true) => {
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Authorization complete. You may close this tab."
|
||||
}
|
||||
(Some(_), false) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
State mismatch — possible CSRF attack."
|
||||
}
|
||||
(None, _) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Missing authorization code."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if let Err(e) = stream.write_all(response.as_bytes()) {
|
||||
tracing::warn!("OAuth loopback write error: {e}");
|
||||
}
|
||||
if let Err(e) = stream.flush() {
|
||||
tracing::warn!("OAuth loopback flush error: {e}");
|
||||
}
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
@@ -67,16 +68,10 @@ impl LoopbackServer {
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"code not found in callback",
|
||||
)
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `code` param.
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
@@ -90,9 +85,6 @@ impl LoopbackServer {
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the `state` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `state` param.
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
@@ -108,10 +100,6 @@ impl LoopbackServer {
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
///
|
||||
/// Why: invalid escape sequences (missing/non-hex digits) are passed through
|
||||
/// literally as `%` rather than erroring, since this only handles a redirect
|
||||
/// query param, not untrusted binary data.
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
@@ -121,7 +109,10 @@ fn urlencoding(s: &str) -> String {
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
||||
(Some(hi), Some(lo)) => {
|
||||
let byte: u8 = (hi as u8) * 16 + lo as u8;
|
||||
result.push(char::from(byte));
|
||||
}
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Argon2 password hashing and verification utilities.
|
||||
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let password = password.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed PHC string.
|
||||
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let password = password.to_string();
|
||||
let hash = hash.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parsed_hash = PasswordHash::new(&hash)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
||||
let argon2 = Argon2::default();
|
||||
let valid = argon2
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
Ok(valid)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user