diff --git a/.agents/skills/cavecrew/README.md b/.agents/skills/cavecrew/README.md new file mode 100644 index 00000000..20bb07a2 --- /dev/null +++ b/.agents/skills/cavecrew/README.md @@ -0,0 +1,61 @@ +# cavecrew + +Decision guide. When to delegate to caveman subagents instead of doing the work inline. + +## What it does + +Tells the main thread when to spawn a caveman-style subagent versus the vanilla equivalent. The win: subagent tool-results inject back into main context verbatim, and caveman output is roughly 1/3 the size of vanilla prose. Across 20 delegations in one session, that is the difference between context exhaustion and finishing the task. + +Three subagents: + +| Subagent | Job | Use when | +|----------|-----|----------| +| `cavecrew-investigator` | Locate code (read-only) | "Where is X defined / what calls Y / list uses of Z" | +| `cavecrew-builder` | Surgical edit, 1-2 files | Scope is obvious, ≤2 files. Refuses 3+ file scope. | +| `cavecrew-reviewer` | Diff/file review | One-line findings with severity emoji | + +Use vanilla `Explore` or `Code Reviewer` when you want prose, architecture commentary, or rationale. Use main thread directly for one-line answers and 3+ file refactors. + +This skill is a decision guide, not a slash command. It activates when the conversation mentions delegation. + +## How to invoke + +Triggers on phrases like "delegate to subagent", "use cavecrew", "spawn investigator", "save context", "compressed agent output". + +## Example chaining + +Locate → fix → verify (most common): + +1. `cavecrew-investigator` returns site list (`path:line — symbol — note`) +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder` +3. `cavecrew-reviewer` audits the resulting diff + +Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests). Aggregate in main. + +## Model overrides + +By default, `cavecrew-reviewer` and `cavecrew-investigator` pin `model: haiku` in their frontmatter; `cavecrew-builder` has no `model:` line (uses the API session default). Set env vars in your shell before launching Claude Code to override per-agent: + +| Env var | Agent | +|---|---| +| `CAVECREW_REVIEWER_MODEL` | `cavecrew-reviewer` | +| `CAVECREW_BUILDER_MODEL` | `cavecrew-builder` | +| `CAVECREW_INVESTIGATOR_MODEL` | `cavecrew-investigator` | + +Example — run reviewer on sonnet, keep others on default: + +```sh +export CAVECREW_REVIEWER_MODEL=sonnet +``` + +Use the same model name strings you'd use in any Claude Code agent frontmatter (e.g. `haiku`, `sonnet`, `opus`). + +Overrides patch only the `model:` line in the installed agent's frontmatter; the prompt body is untouched and keeps receiving upstream updates. Plugin installs only — standalone hook installs have no local agent files to patch. Unset or blank = no change. The patch persists in the installed file until the plugin is updated or reinstalled. + +## See also + +- [`SKILL.md`](./SKILL.md) — full decision matrix and output contracts +- [`agents/cavecrew-investigator.md`](../../agents/cavecrew-investigator.md) +- [`agents/cavecrew-builder.md`](../../agents/cavecrew-builder.md) +- [`agents/cavecrew-reviewer.md`](../../agents/cavecrew-reviewer.md) +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/cavecrew/SKILL.md b/.agents/skills/cavecrew/SKILL.md new file mode 100644 index 00000000..efa413fd --- /dev/null +++ b/.agents/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/.agents/skills/caveman-commit/README.md b/.agents/skills/caveman-commit/README.md new file mode 100644 index 00000000..d5aee013 --- /dev/null +++ b/.agents/skills/caveman-commit/README.md @@ -0,0 +1,44 @@ +# caveman-commit + +Terse Conventional Commits. Why over what. + +## What it does + +Generates commit messages in Conventional Commits format. Subject ≤50 chars, hard cap 72. Imperative mood. Body only when the *why* is non-obvious or there are breaking changes. No AI attribution, no "this commit does X", no emoji unless the project uses them. Body always required for breaking changes, security fixes, data migrations, and reverts — future debuggers need the context. + +Outputs only the message. Does not stage, commit, or amend. + +## How to invoke + +``` +/caveman-commit +``` + +Also triggers on phrases like "write a commit", "commit message", "generate commit". + +## Example output + +Diff: new endpoint for user profile. + +``` +feat(api): add GET /users/:id/profile + +Mobile client needs profile data without the full user payload +to reduce LTE bandwidth on cold-launch screens. + +Closes #128 +``` + +Diff: breaking API rename. + +``` +feat(api)!: rename /v1/orders to /v1/checkout + +BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout +before 2026-06-01. Old route returns 410 after that date. +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-commit/SKILL.md b/.agents/skills/caveman-commit/SKILL.md new file mode 100644 index 00000000..b9999e35 --- /dev/null +++ b/.agents/skills/caveman-commit/SKILL.md @@ -0,0 +1,65 @@ +--- +name: caveman-commit +description: > + Ultra-compressed commit message generator. Cuts noise from commit messages while preserving + intent and reasoning. Conventional Commits format. Subject ≤50 chars, body only when "why" + isn't obvious. Use when user says "write a commit", "commit message", "generate commit", + "/commit", or invokes /caveman-commit. Auto-triggers when staging changes. +--- + +Write commit messages terse and exact. Conventional Commits format. No fluff. Why over what. + +## Rules + +**Subject line:** +- `(): ` — `` optional +- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`, `style`, `revert` +- Imperative mood: "add", "fix", "remove" — not "added", "adds", "adding" +- ≤50 chars when possible, hard cap 72 +- No trailing period +- Match project convention for capitalization after the colon + +**Body (only if needed):** +- Skip entirely when subject is self-explanatory +- Add body only for: non-obvious *why*, breaking changes, migration notes, linked issues +- Wrap at 72 chars +- Bullets `-` not `*` +- Reference issues/PRs at end: `Closes #42`, `Refs #17` + +**What NEVER goes in:** +- "This commit does X", "I", "we", "now", "currently" — the diff says what +- "As requested by..." — use Co-authored-by trailer +- "Generated with Claude Code" or any AI attribution — unless the user's own rule requires an `Assisted-by`/AI-attribution trailer, then add it as a trailer +- Emoji (unless project convention requires) +- Restating the file name when scope already says it + +## Examples + +Diff: new endpoint for user profile with body explaining the why +- ❌ "feat: add a new endpoint to get user profile information from the database" +- ✅ + ``` + feat(api): add GET /users/:id/profile + + Mobile client needs profile data without the full user payload + to reduce LTE bandwidth on cold-launch screens. + + Closes #128 + ``` + +Diff: breaking API change +- ✅ + ``` + feat(api)!: rename /v1/orders to /v1/checkout + + BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout + before 2026-06-01. Old route returns 410 after that date. + ``` + +## Auto-Clarity + +Always include body for: breaking changes, security fixes, data migrations, anything reverting a prior commit. Never compress these into subject-only — future debuggers need the context. + +## Boundaries + +Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style. diff --git a/.agents/skills/caveman-compress/README.md b/.agents/skills/caveman-compress/README.md new file mode 100644 index 00000000..4aa149a3 --- /dev/null +++ b/.agents/skills/caveman-compress/README.md @@ -0,0 +1,163 @@ +

+ +

+ +

caveman-compress

+ +

+ shrink memory file. save token every session. +

+ +--- + +A Claude Code skill that compresses your project memory files (`CLAUDE.md`, todos, preferences) into caveman format — so every session loads fewer tokens automatically. + +Claude read `CLAUDE.md` on every session start. If file big, cost big. Caveman make file small. Cost go down forever. + +## What It Do + +``` +/caveman-compress CLAUDE.md +``` + +``` +CLAUDE.md ← compressed (Claude reads this — fewer tokens every session) +CLAUDE.original.md ← human-readable backup (you edit this) +``` + +Original never lost. Backup lives in a data dir, not next to your file — `$XDG_DATA_HOME/caveman-compress/backups//` (macOS/Linux) or `%LOCALAPPDATA%\caveman-compress\backups\\` (Windows) — so skill auto-loaders don't re-read it as a live file. You can read and edit `.original.md` there. Run skill again to re-compress after edits. + +## Benchmarks + +Real results on real project files: + +| File | Original | Compressed | Saved | +|------|----------:|----------:|------:| +| `claude-md-preferences.md` | 706 | 285 | **59.6%** | +| `project-notes.md` | 1145 | 535 | **53.3%** | +| `claude-md-project.md` | 1122 | 636 | **43.3%** | +| `todo-list.md` | 627 | 388 | **38.1%** | +| `mixed-with-code.md` | 888 | 560 | **36.9%** | +| **Average** | **898** | **481** | **46%** | + +All validations passed ✅ — headings, code blocks, URLs, file paths preserved exactly. + +## Before / After + + + + + + +
+ +### 📄 Original (706 tokens) + +> "I strongly prefer TypeScript with strict mode enabled for all new code. Please don't use `any` type unless there's genuinely no way around it, and if you do, leave a comment explaining the reasoning. I find that taking the time to properly type things catches a lot of bugs before they ever make it to runtime." + + + +### rock Caveman (285 tokens) + +> "Prefer TypeScript strict mode always. No `any` unless unavoidable — comment why if used. Proper types catch bugs early." + +
+ +**Same instructions. 60% fewer tokens. Every. Single. Session.** + +## Security + +`caveman-compress` is flagged as Snyk High Risk due to subprocess and file I/O patterns detected by static analysis. This is a false positive — see [SECURITY.md](./SECURITY.md) for a full explanation of what the skill does and does not do. + +## Install + +Compress is built in with the `caveman` plugin. Install `caveman` once, then use `/caveman-compress`. + +If you need local files, the compress skill lives at: + +```bash +caveman-compress/ +``` + +**Requires:** Python 3.10+ + +## Usage + +``` +/caveman-compress +``` + +Examples: +``` +/caveman-compress CLAUDE.md +/caveman-compress docs/preferences.md +/caveman-compress todos.md +``` + +### What files work + +| Type | Compress? | +|------|-----------| +| `.md`, `.txt`, `.rst`, `.typ`, `.typst`, `.tex` | ✅ Yes | +| Extensionless natural language | ✅ Yes | +| `.py`, `.js`, `.ts`, `.json`, `.yaml` | ❌ Skip (code/config) | +| `*.original.md` | ❌ Skip (backup files) | + +## How It Work + +``` +/caveman-compress CLAUDE.md + ↓ +detect file type (no tokens) + ↓ +Claude compresses (tokens — one call) + ↓ +validate output (no tokens) + checks: headings, code blocks, URLs, file paths, bullets + ↓ +if errors: Claude fixes cherry-picked issues only (tokens — targeted fix) + does NOT recompress — only patches broken parts + ↓ +retry up to 2 times + ↓ +write compressed → CLAUDE.md +write original → CLAUDE.original.md +``` + +Only two things use tokens: initial compression + targeted fix if validation fails. Everything else is local Python. + +## What Is Preserved + +Caveman compress natural language. It never touch: + +- Code blocks (` ``` ` fenced or indented) +- Inline code (`` `backtick content` ``) +- URLs and links +- File paths (`/src/components/...`) +- Commands (`npm install`, `git commit`) +- Technical terms, library names, API names +- Headings (exact text preserved) +- Tables (structure preserved, cell text compressed) +- Dates, version numbers, numeric values + +## Why This Matter + +`CLAUDE.md` loads on **every session start**. A 1000-token project memory file costs tokens every single time you open a project. Over 100 sessions that's 100,000 tokens of overhead — just for context you already wrote. + +Caveman cut that by ~46% on average. Same instructions. Same accuracy. Less waste. + +``` +┌────────────────────────────────────────────┐ +│ TOKEN SAVINGS PER FILE █████ 46% │ +│ SESSIONS THAT BENEFIT ██████████ 100% │ +│ INFORMATION PRESERVED ██████████ 100% │ +│ SETUP TIME █ 1x │ +└────────────────────────────────────────────┘ +``` + +## Part of Caveman + +This skill is part of the [caveman](https://github.com/JuliusBrussee/caveman) toolkit — making Claude use fewer tokens without losing accuracy. + +- **caveman** — make Claude *speak* like caveman (cuts response tokens ~65%) +- **caveman-compress** — make Claude *read* less (cuts context tokens ~46%) diff --git a/.agents/skills/caveman-compress/SECURITY.md b/.agents/skills/caveman-compress/SECURITY.md new file mode 100644 index 00000000..0efa9fe1 --- /dev/null +++ b/.agents/skills/caveman-compress/SECURITY.md @@ -0,0 +1,31 @@ +# Security + +## Snyk High Risk Rating + +`caveman-compress` receives a Snyk High Risk rating due to static analysis heuristics. This document explains what the skill does and does not do. + +### What triggers the rating + +1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY` is not set. The subprocess call uses a fixed argument list — no shell interpolation occurs. User file content is passed via stdin, not as a shell argument. + +2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved to an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups//`, or `%LOCALAPPDATA%\caveman-compress\backups\\` on Windows). Beyond the target file and that backup location, no files are read or written. + +### What the skill does NOT do + +- Does not execute user file content as code +- Does not make network requests except to Anthropic's API (via SDK or CLI) +- Does not access files outside the path the user provides +- Does not use shell=True or string interpolation in subprocess calls +- Does not collect or transmit any data beyond the file being compressed + +### Auth behavior + +If `ANTHROPIC_API_KEY` is set, the skill uses the Anthropic Python SDK directly (no subprocess). If not set, it falls back to the `claude` CLI, which uses the user's existing Claude desktop authentication. + +### File size limit + +Files larger than 500KB are rejected before any API call is made. + +### Reporting a vulnerability + +If you believe you've found a genuine security issue, please open a GitHub issue with the label `security`. diff --git a/.agents/skills/caveman-compress/SKILL.md b/.agents/skills/caveman-compress/SKILL.md new file mode 100644 index 00000000..0b95aab5 --- /dev/null +++ b/.agents/skills/caveman-compress/SKILL.md @@ -0,0 +1,111 @@ +--- +name: caveman-compress +description: > + Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format + to save input tokens. Preserves all technical substance, code, URLs, and structure. + Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md. + Trigger: /caveman-compress FILEPATH or "compress memory file" +--- + +# Caveman Compress + +## Purpose + +Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups//`, or `%LOCALAPPDATA%\caveman-compress\backups\\` on Windows) so skill auto-loaders don't re-ingest it as a live file. + +## Trigger + +`/caveman-compress ` or when user asks to compress a memory file. + +## Process + +1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md. + +2. From the directory containing this SKILL.md, run: + +python3 -m scripts + +3. The CLI will: +- detect file type (no tokens) +- call Claude to compress +- validate output (no tokens) +- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression) +- retry up to 2 times +- if still failing after 2 retries: report error to user, leave original file untouched + +4. Return result to user + +## Compression Rules + +### Remove +- Articles: a, an, the +- Filler: just, really, basically, actually, simply, essentially, generally +- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend" +- Hedging: "it might be worth", "you could consider", "it would be good to" +- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because" +- Connective fluff: "however", "furthermore", "additionally", "in addition" + +### Preserve EXACTLY (never modify) +- Code blocks (fenced ``` and indented) +- Inline code (`backtick content`) +- URLs and links (full URLs, markdown links) +- File paths (`/src/components/...`, `./config.yaml`) +- Commands (`npm install`, `git commit`, `docker build`) +- Technical terms (library names, API names, protocols, algorithms) +- Proper nouns (project names, people, companies) +- Dates, version numbers, numeric values +- Environment variables (`$HOME`, `NODE_ENV`) + +### Preserve Structure +- All markdown headings (keep exact heading text, compress body below) +- Bullet point hierarchy (keep nesting level) +- Numbered lists (keep numbering) +- Tables (compress cell text, keep structure) +- Frontmatter/YAML headers in markdown files + +### Compress +- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize" +- Fragments OK: "Run tests before commit" not "You should always run tests before committing" +- Drop "you should", "make sure to", "remember to" — just state the action +- Merge redundant bullets that say the same thing differently +- Keep one example where multiple examples show the same pattern + +CRITICAL RULE: +Anything inside ``` ... ``` must be copied EXACTLY. +Do not: +- remove comments +- remove spacing +- reorder lines +- shorten commands +- simplify anything + +Inline code (`...`) must be preserved EXACTLY. +Do not modify anything inside backticks. + +If file contains code blocks: +- Treat code blocks as read-only regions +- Only compress text outside them +- Do not merge sections around code + +## Pattern + +Original: +> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production. + +Compressed: +> Run tests before push to main. Catch bugs early, prevent broken prod deploys. + +Original: +> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens. + +Compressed: +> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens. + +## Boundaries + +- ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless) +- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh +- If file has mixed content (prose + code), compress ONLY the prose sections +- If unsure whether something is code or prose, leave it unchanged +- Original file is backed up as FILE.original.md before overwriting — in the out-of-tree backup data dir (see Purpose), not beside the source file +- Never compress FILE.original.md (skip it) diff --git a/.agents/skills/caveman-compress/scripts/__init__.py b/.agents/skills/caveman-compress/scripts/__init__.py new file mode 100644 index 00000000..16b8c53c --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/__init__.py @@ -0,0 +1,9 @@ +"""Caveman compress scripts. + +This package provides tools to compress natural language markdown files +into caveman format to save input tokens. +""" + +__all__ = ["cli", "compress", "detect", "validate"] + +__version__ = "1.0.0" diff --git a/.agents/skills/caveman-compress/scripts/__main__.py b/.agents/skills/caveman-compress/scripts/__main__.py new file mode 100644 index 00000000..4e28416e --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/.agents/skills/caveman-compress/scripts/benchmark.py b/.agents/skills/caveman-compress/scripts/benchmark.py new file mode 100644 index 00000000..97d081b5 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/benchmark.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +# Support both direct execution and module import +try: + from .validate import validate +except ImportError: + sys.path.insert(0, str(Path(__file__).parent)) + from validate import validate + +try: + import tiktoken + _enc = tiktoken.get_encoding("o200k_base") +except ImportError: + _enc = None + + +def count_tokens(text): + if _enc is None: + return len(text.split()) # fallback: word count + return len(_enc.encode(text)) + + +def benchmark_pair(orig_path: Path, comp_path: Path): + orig_text = orig_path.read_text(encoding="utf-8", errors="ignore") + comp_text = comp_path.read_text(encoding="utf-8", errors="ignore") + + orig_tokens = count_tokens(orig_text) + comp_tokens = count_tokens(comp_text) + saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0 + result = validate(orig_path, comp_path) + + return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid) + + +def print_table(rows): + print("\n| File | Original | Compressed | Saved % | Valid |") + print("|------|----------|------------|---------|-------|") + for r in rows: + print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |") + + +def main(): + # Direct file pair: python3 benchmark.py original.md compressed.md + if len(sys.argv) == 3: + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + if not orig.exists(): + print(f"❌ Not found: {orig}") + sys.exit(1) + if not comp.exists(): + print(f"❌ Not found: {comp}") + sys.exit(1) + print_table([benchmark_pair(orig, comp)]) + return + + # Glob mode: repo_root/tests/caveman-compress/ + # __file__ lives at /skills/caveman-compress/scripts/benchmark.py + # Walk up four dirs: scripts → caveman-compress → skills → repo_root. + tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress" + if not tests_dir.exists(): + print(f"❌ Tests dir not found: {tests_dir}") + sys.exit(1) + + rows = [] + for orig in sorted(tests_dir.glob("*.original.md")): + comp = orig.with_name(orig.stem.removesuffix(".original") + ".md") + if comp.exists(): + rows.append(benchmark_pair(orig, comp)) + + if not rows: + print("No compressed file pairs found.") + return + + print_table(rows) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/caveman-compress/scripts/cli.py b/.agents/skills/caveman-compress/scripts/cli.py new file mode 100644 index 00000000..75ea8a66 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/cli.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Caveman Compress CLI + +Usage: + caveman +""" + +import sys + +# Force UTF-8 on stdout/stderr before any code can print. Windows consoles +# default to cp1252 and crash on the ❌ glyphs in error/validation branches, +# masking the real error and leaving the user with a half-compressed file. +for _stream in (sys.stdout, sys.stderr): + reconfigure = getattr(_stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +from pathlib import Path + +from .compress import backup_dir_for, compress_file +from .detect import detect_file_type, should_compress + + +def print_usage(): + print("Usage: caveman ") + + +def main(): + if len(sys.argv) != 2: + print_usage() + sys.exit(1) + + filepath = Path(sys.argv[1]) + + # Check file exists + if not filepath.exists(): + print(f"❌ File not found: {filepath}") + sys.exit(1) + + if not filepath.is_file(): + print(f"❌ Not a file: {filepath}") + sys.exit(1) + + filepath = filepath.resolve() + + # Detect file type + file_type = detect_file_type(filepath) + + print(f"Detected: {file_type}") + + # Check if compressible + if not should_compress(filepath): + print("Skipping: file is not natural language (code/config)") + sys.exit(0) + + print("Starting caveman compression...\n") + + try: + success = compress_file(filepath) + + if success: + print("\nCompression completed successfully") + backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md") + print(f"Compressed: {filepath}") + print(f"Original: {backup_path}") + sys.exit(0) + else: + print("\n❌ Compression failed after retries") + sys.exit(2) + + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(130) + + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/caveman-compress/scripts/compress.py b/.agents/skills/caveman-compress/scripts/compress.py new file mode 100644 index 00000000..80da5200 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/compress.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +""" +Caveman Memory Compression Orchestrator + +Usage: + python scripts/compress.py +""" + +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import List + +OUTER_FENCE_REGEX = re.compile( + r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL +) + +# YAML frontmatter: starts at file start with --- on its own line, ends with --- on its own line. +# Captures the entire block (including delimiters and trailing newline) and the body after. +FRONTMATTER_REGEX = re.compile( + r"\A(---\r?\n.*?\r?\n---\r?\n)(.*)", re.DOTALL +) + + +def split_frontmatter(text: str): + """Split YAML frontmatter from body. Returns (frontmatter, body). + + Memory files (and many other markdown docs) start with a YAML frontmatter + block delimited by `---` lines. The compression LLM has a habit of stripping + or rewriting these despite preserve-structure rules in the prompt — so we + surgically remove the frontmatter before compression and prepend it back + verbatim to the output. Files without frontmatter pass through unchanged. + """ + m = FRONTMATTER_REGEX.match(text) + if m: + return m.group(1), m.group(2) + return "", text + +# Filenames and paths that almost certainly hold secrets or PII. Compressing +# them ships raw bytes to the Anthropic API — a third-party data boundary that +# developers on sensitive codebases cannot cross. detect.py already skips .env +# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would +# slip through the natural-language filter. This is a hard refuse before read. +SENSITIVE_BASENAME_REGEX = re.compile( + r"(?ix)^(" + r"\.env(\..+)?" + r"|\.netrc" + r"|credentials(\..+)?" + r"|secrets?(\..+)?" + r"|passwords?(\..+)?" + r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?" + r"|authorized_keys" + r"|known_hosts" + r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)" + r")$" +) + +SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"}) + +SENSITIVE_NAME_TOKENS = ( + "secret", "credential", "password", "passwd", + "apikey", "accesskey", "token", "privatekey", +) + + +def backup_dir_for(filepath: Path) -> Path: + """Resolve the out-of-tree backup directory for a given source file. + + Backups must live OUTSIDE the source directory so skill auto-loaders + (Claude Code rules/, opencode instructions/, etc.) stop re-ingesting the + `.original.md` copies as live files. Base dir is platform-aware: + - Windows: %LOCALAPPDATA%\\caveman-compress\\backups + - else: $XDG_DATA_HOME/caveman-compress/backups if set, + else ~/.local/share/caveman-compress/backups + + The source file's parent-dir name is mirrored under the base to reduce + cross-project collisions (e.g. two `task.md` files in different repos). + """ + if os.name == "nt" or sys.platform == "win32": + local_appdata = os.environ.get("LOCALAPPDATA") + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + base = base / "caveman-compress" / "backups" + else: + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg) if xdg else Path.home() / ".local" / "share" + base = base / "caveman-compress" / "backups" + return base / filepath.parent.name + + +def is_sensitive_path(filepath: Path) -> bool: + """Heuristic denylist for files that must never be shipped to a third-party API.""" + name = filepath.name + if SENSITIVE_BASENAME_REGEX.match(name): + return True + lowered_parts = {p.lower() for p in filepath.parts} + if lowered_parts & SENSITIVE_PATH_COMPONENTS: + return True + # Normalize separators so "api-key" and "api_key" both match "apikey". + lower = re.sub(r"[_\-\s.]", "", name.lower()) + return any(tok in lower for tok in SENSITIVE_NAME_TOKENS) + + +def strip_llm_wrapper(text: str) -> str: + """Strip outer ```markdown ... ``` fence when it wraps the entire output.""" + m = OUTER_FENCE_REGEX.match(text) + if m: + return m.group(2) + return text + + +def write_text_atomic(path: Path, text: str) -> None: + """Write ``text`` to ``path`` atomically as UTF-8. + + Path.write_text() truncates the destination before encoding the string — + a UnicodeEncodeError (or any other failure) partway through leaves a + 0-byte file, destroying whatever was there before (issue #655). Encode + first, write the bytes to a sibling temp file, fsync, then os.replace() + so the destination only ever moves from one complete, valid file to + another. Preserves the original file's permission bits across the swap. + """ + data = text.encode("utf-8") + fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=path.name + ".", suffix=".tmp" + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + if path.exists(): + os.chmod(tmp_path, stat.S_IMODE(path.stat().st_mode)) + os.replace(tmp_path, path) + except Exception: + try: + tmp_path.unlink() + except OSError: + pass + raise + + +def first_nonblank_line(text: str) -> str: + """Return the first non-blank line, stripped — used to detect a prose + preamble smuggled in ahead of the real content (issue #588).""" + for line in text.splitlines(): + if line.strip(): + return line.strip() + return "" + + +def _write_target(filepath: Path, text: str, backup_path: Path) -> None: + """Write to the target file, surfacing the backup location if the write + itself fails. write_text_atomic already leaves the target untouched on + failure, but the caller still needs to know where the pre-compression + original lives instead of being left to guess (issue #652).""" + try: + write_text_atomic(filepath, text) + except Exception: + print(f"❌ Write to {filepath} failed. Original preserved at backup: {backup_path}") + raise + + +from .detect import should_compress +from .validate import validate + +MAX_RETRIES = 2 + + +# ---------- Claude Calls ---------- + + +def call_claude(prompt: str) -> str: + """Send a prompt to Claude. + + Prefers the Anthropic SDK when ANTHROPIC_API_KEY is set; otherwise falls + back to the ``claude --print`` CLI (which handles desktop auth). + + On Windows the CLI subprocess decoding defaults to the system codepage + (cp1251 / cp1252) and crashes on UTF-8 output — see issue #152. Pinning + ``encoding="utf-8"`` with ``errors="replace"`` matches the CLI's actual + native I/O and prevents the UnicodeDecodeError before validation can + report. Windows users with non-ASCII content can also set + ``ANTHROPIC_API_KEY`` to route through the SDK and skip the subprocess. + """ + api_key = os.environ.get("ANTHROPIC_API_KEY") + if api_key: + try: + import anthropic + + client = anthropic.Anthropic(api_key=api_key) + msg = client.messages.create( + model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"), + max_tokens=8192, + messages=[{"role": "user", "content": prompt}], + ) + return strip_llm_wrapper(msg.content[0].text.strip()) + except ImportError: + pass # anthropic not installed, fall back to CLI + # Fallback: use claude CLI (handles desktop auth). + # Resolve binary via shutil.which so Windows .cmd/.bat shims (e.g. + # %APPDATA%\npm\claude.CMD) work without shell=True. On POSIX, + # shutil.which returns the same absolute path as the implicit lookup, + # so this is a no-op there. Falls back to bare "claude" if not found + # on PATH so subprocess raises a clear FileNotFoundError. + claude_bin = shutil.which("claude") or "claude" + try: + result = subprocess.run( + [claude_bin, "--print"], + input=prompt, + text=True, + capture_output=True, + check=True, + encoding="utf-8", + errors="replace", + ) + return strip_llm_wrapper(result.stdout.strip()) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Claude call failed:\n{e.stderr}") + + +def build_compress_prompt(original: str) -> str: + return f""" +Compress this markdown into caveman format. + +STRICT RULES: +- Do NOT modify anything inside ``` code blocks +- Do NOT modify anything inside inline backticks +- Preserve ALL URLs exactly +- Preserve ALL headings exactly +- Preserve file paths and commands +- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. + +Only compress natural language. + +TEXT: +{original} +""" + + +def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str: + errors_str = "\n".join(f"- {e}" for e in errors) + return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found. + +CRITICAL RULES: +- DO NOT recompress or rephrase the file +- ONLY fix the listed errors — leave everything else exactly as-is +- The ORIGINAL is provided as reference only (to restore missing content) +- Preserve caveman style in all untouched sections + +ERRORS TO FIX: +{errors_str} + +HOW TO FIX: +- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED +- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED +- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED +- Do not touch any section not mentioned in the errors + +ORIGINAL (reference only): +{original} + +COMPRESSED (fix this): +{compressed} + +Return ONLY the fixed compressed file. No explanation. +""" + + +# ---------- Core Logic ---------- + + +def compress_file(filepath: Path) -> bool: + # Resolve and validate path + filepath = filepath.resolve() + MAX_FILE_SIZE = 500_000 # 500KB + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + if filepath.stat().st_size > MAX_FILE_SIZE: + raise ValueError(f"File too large to compress safely (max 500KB): {filepath}") + + # Refuse files that look like they contain secrets or PII. Compressing ships + # the raw bytes to the Anthropic API — a third-party boundary — so we fail + # loudly rather than silently exfiltrate credentials or keys. Override is + # intentional: the user must rename the file if the heuristic is wrong. + if is_sensitive_path(filepath): + raise ValueError( + f"Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Compression sends file contents to the Anthropic API. " + "Rename the file if this is a false positive." + ) + + print(f"Processing: {filepath}") + + if not should_compress(filepath): + print("Skipping (not natural language)") + return False + + original_text = filepath.read_text(encoding="utf-8", errors="ignore") + # Store backup outside the source directory so skill auto-loaders don't + # re-ingest the `.original.md` copy as a live file. Mirror the source's + # parent-dir name + stem under a platform-aware base to reduce collisions. + backup_dir = backup_dir_for(filepath) + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / (filepath.stem + ".original.md") + + if not original_text.strip(): + print("❌ Refusing to compress: file is empty or whitespace-only.") + return False + + # Check if backup already exists to prevent accidental overwriting + if backup_path.exists(): + print(f"⚠️ Backup file already exists: {backup_path}") + print("The original backup may contain important content.") + print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + return False + + # Split YAML frontmatter off before compression. Claude tends to strip or + # rewrite frontmatter despite preserve-structure rules; we keep it verbatim + # by removing it from the input and re-prepending it to the output. + frontmatter, body = split_frontmatter(original_text) + if frontmatter: + print(f"Detected YAML frontmatter ({len(frontmatter)} chars) — preserving verbatim") + + if not body.strip(): + print("❌ Refusing to compress: body is empty after frontmatter removal.") + return False + + # Step 1: Compress (body only, frontmatter excluded) + print("Compressing with Claude...") + compressed_body = call_claude(build_compress_prompt(body)) + + if compressed_body is None or not compressed_body.strip(): + print("❌ Compression aborted: Claude returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + + # Compare the BODY (not the whole file) — frontmatter is preserved verbatim + # and would never change, so identity must be judged on the compressible part. + if compressed_body.strip() == body.strip(): + print("❌ Compression aborted: output is identical to input.") + print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is") + print(" already in caveman form. Original file is untouched (no backup created).") + return False + + # Reassemble: frontmatter (verbatim) + compressed body + compressed = frontmatter + compressed_body + + # Save original as backup, then verify the backup readback before + # touching the input file. If the filesystem dropped bytes (encoding, + # antivirus, disk full), unlink the bad backup and abort instead of + # leaving the user with a corrupt backup + compressed primary. + write_text_atomic(backup_path, original_text) + backup_readback = backup_path.read_text(encoding="utf-8", errors="ignore") + if backup_readback != original_text: + print(f"❌ Backup write verification failed: {backup_path}") + print(" In-memory original differs from on-disk backup. Aborting before touching the input file.") + try: + backup_path.unlink() + except OSError: + pass + return False + _write_target(filepath, compressed, backup_path) + + # Step 2: Validate + Retry + for attempt in range(MAX_RETRIES): + print(f"\nValidation attempt {attempt + 1}") + + result = validate(backup_path, filepath) + + if result.is_valid: + print("Validation passed") + break + + print("❌ Validation failed:") + for err in result.errors: + print(f" - {err}") + + if attempt == MAX_RETRIES - 1: + # Restore original on failure + _write_target(filepath, original_text, backup_path) + backup_path.unlink(missing_ok=True) + print("❌ Failed after retries — original restored") + return False + + print("Fixing with Claude...") + compressed = call_claude( + build_fix_prompt(original_text, compressed, result.errors) + ) + + if compressed is None or not compressed.strip(): + print("❌ Fix attempt aborted: Claude returned an empty response.") + print(" Skipping this attempt.") + continue + + # Guard against a prose preamble smuggled in ahead of the real fixed + # content (issue #588). Only enforced when the original starts with a + # structural anchor (frontmatter `---` or a heading) — plain-prose + # first lines get legitimately rewritten by compression, and requiring + # them verbatim would reject every valid fix. + anchor = first_nonblank_line(original_text) + if anchor.startswith(("---", "#")) and first_nonblank_line(compressed) != anchor: + print("❌ Fix attempt aborted: output does not start with the original's first line.") + print(" Possible preamble leak. Skipping this attempt.") + continue + + _write_target(filepath, compressed, backup_path) + + return True diff --git a/.agents/skills/caveman-compress/scripts/detect.py b/.agents/skills/caveman-compress/scripts/detect.py new file mode 100644 index 00000000..6a468d5c --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/detect.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Detect whether a file is natural language (compressible) or code/config (skip).""" + +import json +import re +from pathlib import Path + +# Extensions that are natural language and compressible +COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} + +# Extensions that are code/config and should be skipped +SKIP_EXTENSIONS = { + ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", + ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml", + ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c", + ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua", + ".dockerfile", ".makefile", ".csv", ".ini", ".cfg", +} + +# Well-known build/config files that carry no (or a misleading) extension — +# `Dockerfile` has no suffix so `.dockerfile` above never matches it, and +# `CMakeLists.txt` would ride the compressible `.txt` rule. Checked by +# basename before any extension rule. +KNOWN_CODE_FILENAMES = { + "dockerfile", "makefile", "gnumakefile", "jenkinsfile", "vagrantfile", + "rakefile", "gemfile", "justfile", "procfile", "brewfile", + "cmakelists.txt", +} + +# Patterns that indicate a line is code +CODE_PATTERNS = [ + re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"), + re.compile(r"^\s*(def |class |function |async function |export )"), + re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"), + re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets + re.compile(r"^\s*@\w+"), # decorators/annotations + re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value + re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal +] + + +def _is_code_line(line: str) -> bool: + """Check if a line looks like code.""" + return any(p.match(line) for p in CODE_PATTERNS) + + +def _is_json_content(text: str) -> bool: + """Check if content is valid JSON.""" + try: + json.loads(text) + return True + except (json.JSONDecodeError, ValueError): + return False + + +def _is_yaml_content(lines: list[str]) -> bool: + """Heuristic: check if content looks like YAML.""" + yaml_indicators = 0 + for line in lines[:30]: + stripped = line.strip() + if stripped.startswith("---"): + yaml_indicators += 1 + elif re.match(r"^\w[\w\s]*:\s", stripped): + yaml_indicators += 1 + elif stripped.startswith("- ") and ":" in stripped: + yaml_indicators += 1 + # If most non-empty lines look like YAML + non_empty = sum(1 for l in lines[:30] if l.strip()) + return non_empty > 0 and yaml_indicators / non_empty > 0.6 + + +def detect_file_type(filepath: Path) -> str: + """Classify a file as 'natural_language', 'code', 'config', or 'unknown'. + + Returns: + One of: 'natural_language', 'code', 'config', 'unknown' + """ + ext = filepath.suffix.lower() + + # Known code filenames win over any extension rule + if filepath.name.lower() in KNOWN_CODE_FILENAMES: + return "code" + + # Extension-based classification + if ext in COMPRESSIBLE_EXTENSIONS: + return "natural_language" + if ext in SKIP_EXTENSIONS: + return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config" + + # Extensionless files (like CLAUDE.md, TODO) — check content + if not ext: + try: + text = filepath.read_text(encoding="utf-8", errors="ignore") + except (OSError, PermissionError): + return "unknown" + + lines = text.splitlines()[:50] + + # Shebang means executable script, never prose + if text.startswith("#!"): + return "code" + + if _is_json_content(text[:10000]): + return "config" + if _is_yaml_content(lines): + return "config" + + code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l)) + non_empty = sum(1 for l in lines if l.strip()) + if non_empty > 0 and code_lines / non_empty > 0.4: + return "code" + + return "natural_language" + + return "unknown" + + +def should_compress(filepath: Path) -> bool: + """Return True if the file is natural language and should be compressed.""" + if not filepath.is_file(): + return False + # Skip backup files + if filepath.name.endswith(".original.md"): + return False + return detect_file_type(filepath) == "natural_language" + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python detect.py [file2] ...") + sys.exit(1) + + for path_str in sys.argv[1:]: + p = Path(path_str).resolve() + file_type = detect_file_type(p) + compress = should_compress(p) + print(f" {p.name:30s} type={file_type:20s} compress={compress}") diff --git a/.agents/skills/caveman-compress/scripts/validate.py b/.agents/skills/caveman-compress/scripts/validate.py new file mode 100644 index 00000000..dcd2a5d3 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/validate.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +import re +from collections import Counter +from pathlib import Path + +URL_REGEX = re.compile(r"https?://[^\s)]+") +FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$") +HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE) +BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE) + +# crude but effective path detection +# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match +PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+") + + +class ValidationResult: + def __init__(self): + self.is_valid = True + self.errors = [] + self.warnings = [] + + def add_error(self, msg): + self.is_valid = False + self.errors.append(msg) + + def add_warning(self, msg): + self.warnings.append(msg) + + +def read_file(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +# ---------- Extractors ---------- + + +def extract_headings(text): + return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)] + + +def extract_code_blocks(text): + """Line-based fenced code block extractor. + + Handles ``` and ~~~ fences with variable length (CommonMark: closing + fence must use same char and be at least as long as opening). Supports + nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick + content). + """ + blocks = [] + lines = text.split("\n") + i = 0 + n = len(lines) + while i < n: + m = FENCE_OPEN_REGEX.match(lines[i]) + if not m: + i += 1 + continue + fence_char = m.group(2)[0] + fence_len = len(m.group(2)) + open_line = lines[i] + block_lines = [open_line] + i += 1 + closed = False + while i < n: + close_m = FENCE_OPEN_REGEX.match(lines[i]) + if ( + close_m + and close_m.group(2)[0] == fence_char + and len(close_m.group(2)) >= fence_len + and close_m.group(3).strip() == "" + ): + block_lines.append(lines[i]) + closed = True + i += 1 + break + block_lines.append(lines[i]) + i += 1 + if closed: + blocks.append("\n".join(block_lines)) + # Unclosed fences are silently skipped — they indicate malformed markdown + # and including them would cause false-positive validation failures. + return blocks + + +def extract_urls(text): + return set(URL_REGEX.findall(text)) + + +def extract_paths(text): + return set(PATH_REGEX.findall(text)) + + +def count_bullets(text): + return len(BULLET_REGEX.findall(text)) + + +def extract_inline_codes(text): + """Backtick-delimited inline spans, with fenced code blocks stripped first. + + Previously used a column-0-anchored regex to strip fences, which misses + fences indented 1-3 spaces (valid CommonMark). Reuse extract_code_blocks + (FENCE_OPEN_REGEX-based, indentation-aware) instead so an indented fence's + body backticks don't leak into inline-code pairing. + """ + text_without_fences = text + for block in extract_code_blocks(text): + text_without_fences = text_without_fences.replace(block, "", 1) + return re.findall(r"`([^`]+)`", text_without_fences) + + +# ---------- Validators ---------- + + +def validate_headings(orig, comp, result): + h1 = extract_headings(orig) + h2 = extract_headings(comp) + + if len(h1) != len(h2): + result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}") + + if h1 != h2: + result.add_warning("Heading text/order changed") + + +def validate_code_blocks(orig, comp, result): + c1 = extract_code_blocks(orig) + c2 = extract_code_blocks(comp) + + if c1 != c2: + result.add_error("Code blocks not preserved exactly") + + +def validate_urls(orig, comp, result): + u1 = extract_urls(orig) + u2 = extract_urls(comp) + + if u1 != u2: + result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}") + + +def validate_paths(orig, comp, result): + p1 = extract_paths(orig) + p2 = extract_paths(comp) + + if p1 != p2: + result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}") + + +def validate_bullets(orig, comp, result): + b1 = count_bullets(orig) + b2 = count_bullets(comp) + + if b1 == 0: + return + + diff = abs(b1 - b2) / b1 + + if diff > 0.15: + result.add_warning(f"Bullet count changed too much: {b1} -> {b2}") + + +def validate_inline_codes(orig, comp, result): + c1 = Counter(extract_inline_codes(orig)) + c2 = Counter(extract_inline_codes(comp)) + + if c1 != c2: + lost = set(c1.keys()) - set(c2.keys()) + added = set(c2.keys()) - set(c1.keys()) + for code, count in c1.items(): + if code in c2 and c2[code] < count: + lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)") + if lost: + result.add_error(f"Inline code lost: {lost}") + if added: + result.add_warning(f"Inline code added: {added}") + + +# ---------- Main ---------- + + +def validate(original_path: Path, compressed_path: Path) -> ValidationResult: + result = ValidationResult() + + orig = read_file(original_path) + comp = read_file(compressed_path) + + validate_headings(orig, comp, result) + validate_code_blocks(orig, comp, result) + validate_urls(orig, comp, result) + validate_paths(orig, comp, result) + validate_bullets(orig, comp, result) + validate_inline_codes(orig, comp, result) + + return result + + +# ---------- CLI ---------- + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 3: + print("Usage: python validate.py ") + sys.exit(1) + + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + + res = validate(orig, comp) + + print(f"\nValid: {res.is_valid}") + + if res.errors: + print("\nErrors:") + for e in res.errors: + print(f" - {e}") + + if res.warnings: + print("\nWarnings:") + for w in res.warnings: + print(f" - {w}") diff --git a/.agents/skills/caveman-help/README.md b/.agents/skills/caveman-help/README.md new file mode 100644 index 00000000..5841256f --- /dev/null +++ b/.agents/skills/caveman-help/README.md @@ -0,0 +1,38 @@ +# caveman-help + +Quick-reference card. One shot, no mode change. + +## What it does + +Prints a cheat sheet of all caveman modes, sibling skills, deactivation triggers, and how to set the default mode via env var or config file. One-shot display — does not flip the active mode, write flag files, or persist anything. Use when you forget the slash commands. + +## How to invoke + +``` +/caveman-help +``` + +Also triggers on "caveman help", "what caveman commands", "how do I use caveman". + +## Example output + +``` +Modes: + /caveman full (default) + /caveman lite lighter + /caveman ultra extreme + /caveman wenyan classical Chinese + +Skills: + /caveman-commit terse Conventional Commits + /caveman-review one-line PR comments + /caveman-stats session token savings + +Deactivate: + "stop caveman" or "normal mode" +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full reference card +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-help/SKILL.md b/.agents/skills/caveman-help/SKILL.md new file mode 100644 index 00000000..346579dc --- /dev/null +++ b/.agents/skills/caveman-help/SKILL.md @@ -0,0 +1,63 @@ +--- +name: caveman-help +description: > + Quick-reference card for all caveman modes, skills, and commands. + One-shot display, not a persistent mode. Trigger: /caveman-help, + "caveman help", "what caveman commands", "how do I use caveman". +--- + +# Caveman Help + +Display this reference card when invoked. One-shot — do NOT change mode, write flag files, or persist anything. Output in caveman style. + +## Modes + +| Mode | Trigger | What change | +|------|---------|-------------| +| **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. | +| **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. | +| **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. | +| **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. | +| **Wenyan-Full** | `/caveman wenyan` | Full 文言文. Maximum classical terseness. | +| **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. | + +Mode stick until changed or session end. + +## Skills + +| Skill | Trigger | What it do | +|-------|---------|-----------| +| **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. ≤50 char subject. | +| **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` | +| **caveman-compress** | `/caveman-compress ` | Compress .md files to caveman prose. Saves ~46% input tokens. | +| **caveman-help** | `/caveman-help` | This card. | + +## Deactivate + +Say "stop caveman" or "normal mode". Resume anytime with `/caveman`. + +## Language + +Keep user's language by default. User write Portuguese → reply Portuguese caveman. Compress the style, not the language. Technical terms, code, commands, commit types, and exact error strings stay verbatim unless user ask for translation. + +## Configure Default Mode + +Default mode = `full`. Change it: + +**Environment variable** (highest priority): +```bash +export CAVEMAN_DEFAULT_MODE=ultra +``` + +**Config file** (`~/.config/caveman/config.json` macOS/Linux, `%APPDATA%\caveman\config.json` Windows): +```json +{ "defaultMode": "lite" } +``` + +Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`. + +Resolution: env var > config file > `full`. + +## More + +Full docs: https://github.com/JuliusBrussee/caveman diff --git a/.agents/skills/caveman-review/README.md b/.agents/skills/caveman-review/README.md new file mode 100644 index 00000000..acf519fe --- /dev/null +++ b/.agents/skills/caveman-review/README.md @@ -0,0 +1,33 @@ +# caveman-review + +One-line PR comments. Location, problem, fix. No throat-clearing. + +## What it does + +Generates code review comments in `L: . .` format. One line per finding. Severity emoji: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question. Drops "I noticed that...", hedging, and restating what the diff already shows. Keeps exact line numbers, backticked symbols, and concrete fixes. + +Auto-clarity: drops terse mode for CVE-class security findings, architectural disagreements, and onboarding contexts where the author needs the *why*. Resumes terse for the rest. + +Output only — does not approve, request changes, or run linters. + +## How to invoke + +``` +/caveman-review +``` + +Also triggers on "review this PR", "code review", "review the diff". + +## Example output + +``` +L42: 🔴 bug: user can be null after .find(). Add guard before .email. +L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist. +L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3). +L107: ❓ q: why drop the cache here? Reads on next request will miss. +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-review/SKILL.md b/.agents/skills/caveman-review/SKILL.md new file mode 100644 index 00000000..48f4adbf --- /dev/null +++ b/.agents/skills/caveman-review/SKILL.md @@ -0,0 +1,55 @@ +--- +name: caveman-review +description: > + Ultra-compressed code review comments. Cuts noise from PR feedback while preserving + the actionable signal. Each comment is one line: location, problem, fix. Use when user + says "review this PR", "code review", "review the diff", "/review", or invokes + /caveman-review. Auto-triggers when reviewing pull requests. +--- + +Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing. + +## Rules + +**Format:** `L: . .` — or `:L: ...` when reviewing multi-file diffs. + +**Severity prefix (optional, when mixed):** +- `🔴 bug:` — broken behavior, will cause incident +- `🟡 risk:` — works but fragile (race, missing null check, swallowed error) +- `🔵 nit:` — style, naming, micro-optim. Author can ignore +- `❓ q:` — genuine question, not a suggestion + +**Drop:** +- "I noticed that...", "It seems like...", "You might want to consider..." +- "This is just a suggestion but..." — use `nit:` instead +- "Great work!", "Looks good overall but..." — say it once at the top, not per comment +- Restating what the line does — the reviewer can read the diff +- Hedging ("perhaps", "maybe", "I think") — if unsure use `q:` + +**Keep:** +- Exact line numbers +- Exact symbol/function/variable names in backticks +- Concrete fix, not "consider refactoring this" +- The *why* if the fix isn't obvious from the problem statement + +## Examples + +❌ "I noticed that on line 42 you're not checking if the user object is null before accessing the email property. This could potentially cause a crash if the user is not found in the database. You might want to add a null check here." + +✅ `L42: 🔴 bug: user can be null after .find(). Add guard before .email.` + +❌ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions for readability." + +✅ `L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.` + +❌ "Have you considered what happens if the API returns a 429? I think we should probably handle that case." + +✅ `L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).` + +## Auto-Clarity + +Drop terse mode for: security findings (CVE-class bugs need full explanation + reference), architectural disagreements (need rationale, not just a one-liner), and onboarding contexts where the author is new and needs the "why". In those cases write a normal paragraph, then resume terse for the rest. + +## Boundaries + +Reviews only — does not write the code fix, does not approve/request-changes, does not run linters. Output the comment(s) ready to paste into the PR. "stop caveman-review" or "normal mode": revert to verbose review style. \ No newline at end of file diff --git a/.agents/skills/caveman-stats/README.md b/.agents/skills/caveman-stats/README.md new file mode 100644 index 00000000..1dfdeab2 --- /dev/null +++ b/.agents/skills/caveman-stats/README.md @@ -0,0 +1,36 @@ +# caveman-stats + +Real session token receipts. No AI estimation. + +## What it does + +Reads the current Claude Code session log directly and reports actual input/output token usage plus estimated savings versus a non-caveman baseline. Numbers come from the JSONL session log on disk — the model itself does not compute or estimate them. Output is injected by the `caveman-mode-tracker` hook, which intercepts `/caveman-stats` and returns the formatted stats as a blocked-decision reason. + +Output also includes an `Est. rule overhead` and `Est. net` line whenever the savings figure above them is unambiguous (a single benchmarked mode with a known turn count — no guessing across mixed or unattributed spans). Overhead estimates the per-turn INPUT-token cost of the rules the skill injects every turn — default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS` if you've measured your own setup. Net is savings minus that overhead. On short, terse replies this can go negative — caveman's OUTPUT savings don't clear its INPUT cost — and the line says so directly instead of hiding it behind a gross-savings number. Background: `docs/HONEST-NUMBERS.md`. + +Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). That badge stays a gross-savings figure on purpose — it is a glanceable summary, not a full accounting; run `/caveman-stats` for the net picture. + +## How to invoke + +``` +/caveman-stats +``` + +## Example output + +``` +Session: 47 turns +Input: 12,304 tokens +Output: 3,891 tokens (caveman) +Baseline: 11,247 tokens (estimated without caveman) +Saved: 7,356 tokens (~65%) +Est. rule overhead: 58,750 (input, ~1,250/turn over 47 turns) +Est. net: -51,394 (caveman cost more than it saved for this workload — consider turning it off) +``` + +(Numbers above are illustrative — see `docs/HONEST-NUMBERS.md` for why short, terse-reply sessions tend to land net-negative even at a healthy output-savings percentage.) + +## See also + +- [`SKILL.md`](./SKILL.md) — hook contract and mechanics +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-stats/SKILL.md b/.agents/skills/caveman-stats/SKILL.md new file mode 100644 index 00000000..4c04b926 --- /dev/null +++ b/.agents/skills/caveman-stats/SKILL.md @@ -0,0 +1,12 @@ +--- +name: caveman-stats +description: > + Show real token usage and estimated savings for the current session. + Reads directly from the Claude Code session log — no AI estimation. + Triggers on /caveman-stats. Output is injected by the mode-tracker hook; + the model itself does not compute the numbers. +--- + +This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately. + +Output also includes `Est. rule overhead` and `Est. net` lines wherever a savings estimate exists with a known turn count. Rule overhead is the estimated per-turn INPUT-token cost of the injected caveman rules (default 1,250 tokens/turn, override with `CAVEMAN_RULE_OVERHEAD_TOKENS`) times the turn count. Net is savings minus that overhead — when negative, the output says so plainly and suggests turning caveman off for that workload, rather than hiding the net-negative regime behind a gross-savings number (see `docs/HONEST-NUMBERS.md`). diff --git a/.agents/skills/caveman/README.md b/.agents/skills/caveman/README.md new file mode 100644 index 00000000..696a4e3f --- /dev/null +++ b/.agents/skills/caveman/README.md @@ -0,0 +1,48 @@ +# caveman + +Talk like smart caveman. Same brain, fewer tokens. + +## What it does + +Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts 65% of output tokens (measured) with full accuracy preserved. Mode persists for the whole session until changed or stopped. + +Six intensity levels: + +| Level | What change | +|-------|-------------| +| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. | +| `full` | Default. Drop articles, fragments OK, short synonyms. | +| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. | +| `wenyan-lite` | Classical Chinese register, light compression. | +| `wenyan-full` | Maximum 文言文. 80-90% character reduction. | +| `wenyan-ultra` | Extreme classical compression. | + +Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part. + +## How to invoke + +``` +/caveman # full mode (default) +/caveman lite # lighter compression +/caveman ultra # extreme compression +/caveman wenyan # classical Chinese +stop caveman # back to normal prose +``` + +## Example output + +Question: "Why does my React component re-render?" + +Normal prose: +> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue. + +Caveman (full): +> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. + +Caveman (ultra): +> Inline obj prop → new ref → re-render. `useMemo`. + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview, install, benchmarks diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 00000000..2d31b3db --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,88 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Never drop not/never/no/only/except — flip meaning worse than any token saved. Numbers, units exact. + +Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer — never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity. + +Preserve user's dominant language exactly — reply in the language user writes, never switch regardless of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language — openings, pre-tool status lines, all — not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation. + +'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them — grammar, not filler; compress politeness/filler instead. + +No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations | +| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction — chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop, new ref, re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。" +- wenyan-ultra: "新參照則重繪。useMemo 包之。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool reuse open DB connections. No per-request handshake." +- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。" +- wenyan-ultra: "池蓄連,免逐請新開,省握手。" + +Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels. + +## Auto-Clarity + +Drop caveman when: +- Security warnings +- Irreversible action confirmations +- Multi-step sequences where fragment order or omitted conjunctions risk misread +- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) +- User asks to clarify or repeats question + +Resume caveman after clear part done. + +Example shows FORMAT only — write warning in session language, not example's. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Persisted outside chat: write normal prose — code, comments, commits, docs, issue/PR/MR text, memory files, third-party messages (/caveman-compress exempt). "stop caveman" or "normal mode": revert. Level persist until changed or session end. \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3252d3ac --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/*.verified.cs whitespace=-trailing-space diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 1b9c0d2b..cb0baea8 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -40,8 +40,10 @@ Examples: ## Workflows -- PR build, PR title check, release drafter, preview publishing, and release publishing use `LayeredCraft/devops-templates` reusable workflows. -- `pr-quality.yaml` is intentionally local: it preserves MinimalLambda-specific AOT, CleanupCode formatting, and Codecov gates. +- Use `LayeredCraft/devops-templates` reusable workflows whenever they support required behavior. Do not replace them with custom workflow logic for package building, publishing, or standard PR checks. +- If a local workflow or step is required, document why DevOps templates cannot support it in the PR and keep exception narrowly scoped. +- PR title check, release drafter, preview publishing, and release publishing use `LayeredCraft/devops-templates` reusable workflows. +- `pr-quality.yaml` is intentionally local: it preserves MinimalLambda-specific build, AOT, test/Codecov, and CleanupCode formatting gates. - `docs.yaml` is intentionally local: it builds/deploys the Zensical docs site with `uv` and GitHub Pages. ## Releases @@ -55,10 +57,11 @@ Examples: - Do not publish NuGet packages manually. - Do not create GitHub releases directly. -Packages version synchronously: +Core packages version synchronously: - `MinimalLambda` - `MinimalLambda.Abstractions` +- `MinimalLambda.DurableExecution` - `MinimalLambda.Envelopes` - `MinimalLambda.Envelopes.Alb` - `MinimalLambda.Envelopes.ApiGateway` diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml deleted file mode 100644 index 4b9654a3..00000000 --- a/.github/workflows/pr-build.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: PR Build - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - branches: [main] - paths-ignore: - - 'docs/**' - - 'mkdocs.yml' - - '.github/workflows/docs.yaml' - - 'pyproject.toml' - - 'uv.lock' - - '**.md' - -permissions: - id-token: write - contents: write - -concurrency: - group: pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - build: - uses: LayeredCraft/devops-templates/.github/workflows/pr-build.yaml@v10.3 - with: - solution: MinimalLambda.sln - dotnetVersion: 11.0.x - buildConfiguration: Release - hasTests: false - useMtpRunner: true - enableCodeCoverage: false - runCdk: false - secrets: inherit diff --git a/.github/workflows/pr-quality.yaml b/.github/workflows/pr-quality.yaml index 8a47bbb6..36b283a1 100644 --- a/.github/workflows/pr-quality.yaml +++ b/.github/workflows/pr-quality.yaml @@ -49,11 +49,10 @@ jobs: - name: Run coverage timeout-minutes: 10 - run: | - dotnet test tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj --framework net8.0 --configuration Release --no-build --results-directory ./coverage --coverage --coverage-output-format cobertura --no-progress --no-ansi - dotnet test tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj --framework net8.0 --configuration Release --no-build --results-directory ./coverage --coverage --coverage-output-format cobertura --no-progress --no-ansi - dotnet test tests/MinimalLambda.SourceGenerators.UnitTests/MinimalLambda.SourceGenerators.UnitTests.csproj --framework net8.0 --configuration Release --no-build --results-directory ./coverage --coverage --coverage-output-format cobertura --no-progress --no-ansi - dotnet test tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj --framework net9.0 --configuration Release --no-build --results-directory ./coverage --coverage --coverage-output-format cobertura --no-progress --no-ansi + run: >- + dotnet test MinimalLambda.sln --configuration Release --no-build + --results-directory ./coverage --coverage + --coverage-output-format cobertura --no-progress --no-ansi - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v6 diff --git a/.gitignore b/.gitignore index e0c15f6b..581506da 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ nunit-*.xml .dolt/ *.db .beads-credential-key +__pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 839d98e0..7caf4764 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,8 +119,7 @@ task format:csharpier ``` Always run `task format` before committing changes. Failing to format code may cause CI/CD checks to -fail, as the GitHub Actions workflow (`pr-build.yaml`) includes a code quality check that runs -`task format` and validates no files were modified. +fail, as the GitHub Actions workflow (`pr-quality.yaml`) runs CleanupCode and validates no files were modified. ### IDE Integration diff --git a/Directory.Build.props b/Directory.Build.props index a67bb53b..a861144c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.6.0-beta.1 + 2.6.0-beta.2 MIT @@ -24,7 +24,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + true true true @@ -37,4 +38,4 @@ $(NoWarn);NU5104 - + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 22448efa..4d4c0b98 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,27 +5,32 @@ - - - - - - - + + + + + + + + + + - - - + + + + - - + + + @@ -34,34 +39,34 @@ - + - - - - - + + + + + - - - + + + - + - - - - + + + + @@ -69,23 +74,23 @@ - - - + + + - - - + + + - - - + + + - - - + + + \ No newline at end of file diff --git a/MinimalLambda.Packages.slnf b/MinimalLambda.Packages.slnf new file mode 100644 index 00000000..6297694f --- /dev/null +++ b/MinimalLambda.Packages.slnf @@ -0,0 +1,22 @@ +{ + "solution": { + "path": "MinimalLambda.sln", + "projects": [ + "src/Envelopes/MinimalLambda.Envelopes.Alb/MinimalLambda.Envelopes.Alb.csproj", + "src/Envelopes/MinimalLambda.Envelopes.ApiGateway/MinimalLambda.Envelopes.ApiGateway.csproj", + "src/Envelopes/MinimalLambda.Envelopes.CloudWatchLogs/MinimalLambda.Envelopes.CloudWatchLogs.csproj", + "src/Envelopes/MinimalLambda.Envelopes.Kafka/MinimalLambda.Envelopes.Kafka.csproj", + "src/Envelopes/MinimalLambda.Envelopes.Kinesis/MinimalLambda.Envelopes.Kinesis.csproj", + "src/Envelopes/MinimalLambda.Envelopes.KinesisFirehose/MinimalLambda.Envelopes.KinesisFirehose.csproj", + "src/Envelopes/MinimalLambda.Envelopes.Sns/MinimalLambda.Envelopes.Sns.csproj", + "src/Envelopes/MinimalLambda.Envelopes.Sqs/MinimalLambda.Envelopes.Sqs.csproj", + "src/Envelopes/MinimalLambda.Envelopes/MinimalLambda.Envelopes.csproj", + "src/MinimalLambda.Abstractions/MinimalLambda.Abstractions.csproj", + "src/MinimalLambda.DurableExecution/MinimalLambda.DurableExecution.csproj", + "src/MinimalLambda.OpenTelemetry/MinimalLambda.OpenTelemetry.csproj", + "src/MinimalLambda.Templates/MinimalLambda.Templates.csproj", + "src/MinimalLambda.Testing/MinimalLambda.Testing.csproj", + "src/MinimalLambda/MinimalLambda.csproj" + ] + } +} diff --git a/MinimalLambda.sln b/MinimalLambda.sln index 05783250..66aa3229 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -97,6 +97,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotCompatibility.TestApp", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Templates", "src\MinimalLambda.Templates\MinimalLambda.Templates.csproj", "{737BAED5-D61F-4CDA-B3F0-621D242251F8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.DurableExecution", "src\MinimalLambda.DurableExecution\MinimalLambda.DurableExecution.csproj", "{E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.DurableExecution.UnitTests", "tests\MinimalLambda.DurableExecution.UnitTests\MinimalLambda.DurableExecution.UnitTests.csproj", "{A5852451-D1C2-4270-834C-BDCF03F4A9AA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.DurableLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.DurableLambda\MinimalLambda.Testing.UnitTests.DurableLambda.csproj", "{189636EE-6C05-46DB-8AEB-256F2617025C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Example.DurableExecution", "examples\MinimalLambda.Example.DurableExecution\MinimalLambda.Example.DurableExecution.csproj", "{998085AA-AEC6-45D9-9E51-26F09BD5138C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -491,6 +499,54 @@ Global {737BAED5-D61F-4CDA-B3F0-621D242251F8}.Release|x64.Build.0 = Release|Any CPU {737BAED5-D61F-4CDA-B3F0-621D242251F8}.Release|x86.ActiveCfg = Release|Any CPU {737BAED5-D61F-4CDA-B3F0-621D242251F8}.Release|x86.Build.0 = Release|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Debug|x64.Build.0 = Debug|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Debug|x86.Build.0 = Debug|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Release|Any CPU.Build.0 = Release|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Release|x64.ActiveCfg = Release|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Release|x64.Build.0 = Release|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Release|x86.ActiveCfg = Release|Any CPU + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA}.Release|x86.Build.0 = Release|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Debug|x64.Build.0 = Debug|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Debug|x86.Build.0 = Debug|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Release|Any CPU.Build.0 = Release|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Release|x64.ActiveCfg = Release|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Release|x64.Build.0 = Release|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Release|x86.ActiveCfg = Release|Any CPU + {A5852451-D1C2-4270-834C-BDCF03F4A9AA}.Release|x86.Build.0 = Release|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Debug|x64.ActiveCfg = Debug|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Debug|x64.Build.0 = Debug|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Debug|x86.ActiveCfg = Debug|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Debug|x86.Build.0 = Debug|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Release|Any CPU.Build.0 = Release|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Release|x64.ActiveCfg = Release|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Release|x64.Build.0 = Release|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Release|x86.ActiveCfg = Release|Any CPU + {189636EE-6C05-46DB-8AEB-256F2617025C}.Release|x86.Build.0 = Release|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Debug|x64.ActiveCfg = Debug|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Debug|x64.Build.0 = Debug|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Debug|x86.ActiveCfg = Debug|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Debug|x86.Build.0 = Debug|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Release|Any CPU.Build.0 = Release|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Release|x64.ActiveCfg = Release|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Release|x64.Build.0 = Release|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Release|x86.ActiveCfg = Release|Any CPU + {998085AA-AEC6-45D9-9E51-26F09BD5138C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -531,5 +587,9 @@ Global {69785FC6-B746-4104-8157-1618B80B0A4C} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} {A8B77486-5A06-4A02-B1F0-EB8E2669394F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {737BAED5-D61F-4CDA-B3F0-621D242251F8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E95304BB-646A-4A8A-A3C4-A4A2DA6BF0CA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A5852451-D1C2-4270-834C-BDCF03F4A9AA} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {189636EE-6C05-46DB-8AEB-256F2617025C} = {D9109C8A-AFA8-49C8-A19C-381500902B4D} + {998085AA-AEC6-45D9-9E51-26F09BD5138C} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 73b33712..d27a805c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MinimalLambda -[![PR Build](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-build.yaml/badge.svg)](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-build.yaml) +[![PR Quality Gates](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-quality.yaml/badge.svg)](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-quality.yaml) [![codecov](https://codecov.io/gh/LayeredCraft/minimal-lambda/graph/badge.svg?token=BWORPTQ0UK)](https://codecov.io/gh/LayeredCraft/minimal-lambda) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) diff --git a/decisions/ADR-001-durable-handler-integration-model.md b/decisions/ADR-001-durable-handler-integration-model.md new file mode 100644 index 00000000..80359f00 --- /dev/null +++ b/decisions/ADR-001-durable-handler-integration-model.md @@ -0,0 +1,151 @@ +# ADR-001: Durable handler integration model + +## Status + +- Accepted +- **Date:** 2026-07-29 +- **Deciders:** MinimalLambda maintainers +- **Supersedes:** none + +______________________________________________________________________ + +## Context + +AWS durable functions receive a service envelope, then extract the user's workflow input from that +envelope. MinimalLambda needs a durable API that keeps this plumbing hidden while preserving its +minimal handler and dependency-injection model. + +AWS already owns replay, checkpoints, and durable operations through +`Amazon.Lambda.DurableExecution`. MinimalLambda should integrate with that SDK rather than create a +second durable runtime. + +## Decision Drivers + +- Keep durable registration explicit and minimal. +- Reuse AWS `IDurableContext` and execution semantics. +- Preserve MinimalLambda dependency injection and invocation context access. +- Keep the outer AWS service envelope out of normal workflow code. +- Remain source-generated and NativeAOT friendly. + +## Options Considered + +### Option A: Dedicated `MapDurableHandler` + +```csharp +lambda.MapDurableHandler(async ( + [FromEvent] OrderRequest request, + IDurableContext durable, + IOrderService orders) => +{ + return await durable.StepAsync( + (_, ct) => orders.ProcessAsync(request, ct)); +}); +``` + +**Pros:** Explicit, minimal, and consistent with `MapHandler`. + +**Cons:** Requires durable-specific source generation. + +### Option B: Attribute-sensitive `MapHandler` + +```csharp +[DurableHandler] +static Task ProcessAsync( + [FromEvent] OrderRequest request, + IDurableContext durable) => ...; + +lambda.MapHandler(ProcessAsync); +``` + +**Pros:** Keeps one mapping method. + +**Cons:** Hides the different durable wire protocol behind an attribute. + +### Option C: Explicit AWS wrapper + +```csharp +lambda.MapHandler(( + [FromEvent] DurableExecutionInvocationInput envelope, + ILambdaInvocationContext context) => + DurableFunction.WrapAsync( + Workflow, + envelope, + context)); +``` + +**Pros:** Full AWS control with minimal framework behavior. + +**Cons:** Exposes envelope plumbing to every user. + +### Option D: MinimalLambda durable context wrapper + +```csharp +static Task Workflow( + OrderRequest request, + IMinimalDurableContext context) => ...; +``` + +**Pros:** One MinimalLambda-owned context. + +**Cons:** Duplicates and must track the evolving AWS `IDurableContext` API. + +## Decision + +We will use **Option A: dedicated `MapDurableHandler` backed by +`Amazon.Lambda.DurableExecution`**. + +- `[FromEvent] TInput` binds the deserialized workflow input supplied by AWS, not the outer durable + service envelope. +- AWS `IDurableContext` is injected unchanged. +- Safe execution metadata comes from `IDurableContext`, for example: + +```csharp +var executionArn = durable.ExecutionContext.DurableExecutionArn; +``` + +- `ILambdaInvocationContext` can also be injected when MinimalLambda or Lambda invocation facilities + are needed. +- MinimalLambda passes its invocation context to `DurableFunction.WrapAsync`, making it available + through `IDurableContext.LambdaContext`. +- A typed extension provides convenient access when direct injection is not practical: + +```csharp +var invocation = durable.GetInvocationContext(); +``` + +- The outer `DurableExecutionInvocationInput`, checkpoint token, and replay history are not exposed + as normal `MapDurableHandler` parameters. MinimalLambda does not add a public durable envelope. +- Option C remains the explicit low-level escape hatch for raw envelope access. + +## Rationale + +`MapDurableHandler` makes durable behavior visible while keeping the normal MinimalLambda authoring +model. Reusing AWS `IDurableContext` avoids API duplication and keeps AWS responsible for replay and +checkpoint semantics. + +This matches AWS TypeScript, Python, and Java durable SDKs: user workflows receive typed input and a +durable context while the SDK wrapper hides checkpoint transport. The explicit outer method in the +AWS .NET SDK is an adapter requirement, not the desired MinimalLambda workflow API. + +## Consequences + +### Positive + +- Familiar MinimalLambda API and DI. +- AWS durable behavior remains authoritative. +- Users can access both durable and MinimalLambda contexts. +- Outer service-envelope plumbing stays generated and hidden from normal workflow code. +- Checkpoint tokens and replay history remain AWS-owned protocol state. + +### Negative / trade-offs + +- Requires durable-specific generator support. +- Scoped services are recreated for every replay invocation. +- MinimalLambda must keep serializer/context integration compatible with the AWS SDK. +- Raw envelope access requires the explicit low-level mapping path. + +## References + +- [`ADR-002: Durable package and source-generation ownership`](./ADR-002-durable-package-and-source-generation-ownership.md) +- [Durable dependency and support matrix](./durable-dependency-support-matrix.md) +- [AWS durable execution key concepts](https://docs.aws.amazon.com/durable-execution/getting-started/key-concepts/) diff --git a/decisions/ADR-002-durable-package-and-source-generation-ownership.md b/decisions/ADR-002-durable-package-and-source-generation-ownership.md new file mode 100644 index 00000000..d8165fb2 --- /dev/null +++ b/decisions/ADR-002-durable-package-and-source-generation-ownership.md @@ -0,0 +1,123 @@ +# ADR-002: Durable package and source-generation ownership + +## Status + +- Accepted +- **Date:** 2026-07-29 +- **Deciders:** MinimalLambda maintainers +- **Supersedes:** none + +______________________________________________________________________ + +## Context + +Durable support adds optional AWS dependencies and needs source generation for +`MapDurableHandler`. Durable package releases use the same workflow and version as core packages. + +MinimalLambda currently ships one source generator inside the core `MinimalLambda` package. We need +to decide where durable runtime APIs and generation should live. + +## Decision Drivers + +- Keep durable dependencies optional. +- Release all MinimalLambda packages together with one version. +- Avoid duplicate generators and generated output. +- Reuse existing MinimalLambda handler-generation behavior. +- Keep package compatibility understandable. + +## Options Considered + +### Option A: Separate package, core generator + +```text +Application +├── MinimalLambda +│ └── MinimalLambda.SourceGenerators +└── MinimalLambda.DurableExecution + └── Amazon.Lambda.DurableExecution +``` + +**Pros:** Optional runtime package, one generator, shared handler-generation behavior. + +**Cons:** Generator changes may require coordinated core and durable releases. + +### Option B: Put everything in `MinimalLambda` + +```text +Application +└── MinimalLambda + ├── durable runtime APIs + ├── AWS durable dependencies + └── source generator +``` + +**Pros:** Simplest version and compatibility model. + +**Cons:** Every MinimalLambda user receives durable dependencies. + +### Option C: Separate package and separate generator + +```text +Application +├── MinimalLambda +│ └── MinimalLambda.SourceGenerators +└── MinimalLambda.DurableExecution + └── MinimalLambda.DurableExecution.SourceGenerators +``` + +**Pros:** Durable runtime and generator can version together. + +**Cons:** Two generators duplicate handler-binding infrastructure and increase collision risk. + +### Option D: Separate repository + +```text +minimal-lambda +minimal-lambda-durable-execution +``` + +**Pros:** Full release isolation. + +**Cons:** More coordination and duplicated repository infrastructure. + +## Decision + +We will use **Option A: a separate `MinimalLambda.DurableExecution` package with durable generation +implemented by the existing core `MinimalLambda.SourceGenerators` assembly**. It publishes with all +other MinimalLambda packages from the shared `v` release lane. + +A durable application references both packages: + +```xml + + +``` + +- `MinimalLambda.DurableExecution` owns `MapDurableHandler`, context extensions, and the AWS durable + dependency. +- Core source generator recognizes `MapDurableHandler` from the durable package. +- Durable package declares the matching `MinimalLambda` version. +- Durable runtime and generator changes release together with core. + +## Rationale + +This keeps durable dependencies outside core while retaining one generator and one handler-binding +model. Compatibility is explicit through matching package versions in each shared release. + +## Consequences + +### Positive + +- Durable dependencies remain optional. +- Only one MinimalLambda generator runs. +- Durable and ordinary handlers share generation behavior. +- One trusted-publishing lane publishes all packages with one version. + +### Negative / trade-offs + +- Durable runtime fixes require a core package release. + +## References + +- [`ADR-001: Durable handler integration model`](./ADR-001-durable-handler-integration-model.md) +- [Durable dependency and support matrix](./durable-dependency-support-matrix.md) diff --git a/decisions/ADR-003-durable-pipeline-and-adapter-ownership.md b/decisions/ADR-003-durable-pipeline-and-adapter-ownership.md new file mode 100644 index 00000000..30ed9cdb --- /dev/null +++ b/decisions/ADR-003-durable-pipeline-and-adapter-ownership.md @@ -0,0 +1,237 @@ +# ADR-003: Durable pipeline and adapter ownership + +## Status + +- Accepted +- **Date:** 2026-07-29 +- **Deciders:** MinimalLambda maintainers +- **Supersedes:** none +- **Amended:** 2026-08-03 — terminal lifecycle tracking removed; see middleware contract below. + +______________________________________________________________________ + +## Context + +AWS durable functions have two handler shapes: + +```text +Lambda: DurableExecutionInvocationInput -> DurableExecutionInvocationOutput +Workflow: TInput -> TOutput +``` + +`DurableFunction.WrapAsync` connects them. MinimalLambda must decide where this wrapper runs and +which adapter work is generated. + +AWS TypeScript, Python, and Java durable SDKs hide the service envelope: workflows receive typed +input and durable context while the SDK owns checkpoint transport. AWS .NET exposes the outer types +only at its required Lambda adapter boundary. + +## Decision Drivers + +- Let AWS own durable execution and inner serialization. +- Preserve the existing middleware and feature pipeline where durable replay semantics permit it. +- Generate only code requiring compile-time handler types. +- Avoid reflection, duplicate serialization, and a second pipeline. +- Prevent invocation timeout cancellation from becoming an accidental terminal workflow failure. +- Keep checkpoint tokens and replay history out of the normal workflow API. +- Preserve a manual escape hatch. + +## Options Considered + +### Option A: Generated terminal adapter + +```text +middleware -> generated terminal -> WrapAsync -> workflow +``` + +**Pros:** Reuses the existing pipeline and remains AOT friendly. + +**Cons:** Middleware sees the physical invocation, not typed workflow input. + +### Option B: Durable middleware adapter + +```text +WrapAsync -> middleware -> workflow +``` + +**Pros:** Middleware can access typed workflow input. + +**Cons:** AWS can abandon the workflow task on suspension, so code after `next` may never complete. + +### Option C: Separate typed RuntimeSupport pipeline + +```text +typed HandlerWrapper -> durable pipeline -> WrapAsync +``` + +**Pros:** RuntimeSupport owns outer serialization. + +**Cons:** Requires a second pipeline and duplicates existing feature and response plumbing. + +## Decision + +We will use **Option A: a generated terminal adapter inside the existing MinimalLambda pipeline**. +The outer AWS envelope exists only inside generated transport plumbing; the mapped workflow receives +typed input and `IDurableContext`. + +Conceptually, the generator emits: + +```csharp +async Task InvokeDurable(ILambdaInvocationContext invocation) +{ + var envelope = + invocation.GetRequiredEvent(); + + var output = await DurableFunction.WrapAsync( + async (input, durable) => + { + var orders = invocation.ServiceProvider + .GetRequiredService(); + + return await userHandler(input, durable, orders); + }, + envelope, + invocation); + + invocation.Features + .GetRequired>() + .SetResponse(output); +} +``` + +### Generated + +- Exact user-delegate cast. +- Direct outer durable envelope stream serialization. +- Typed or void `WrapAsync` overload selection. +- Binding of workflow input, `IDurableContext`, MinimalLambda context, and DI services. +- Handler-shape diagnostics. +- Serializer-metadata diagnostics for types statically inferable from the handler signature. +- Binding of an optional root `CancellationToken` to physical invocation cancellation. + +### MinimalLambda runtime + +- Raw-stream bootstrap and existing middleware pipeline. +- Invocation context and DI scope. +- Outer durable input deserialization and output serialization through invocation streams. +- Exposure of the configured `ILambdaSerializer` through + `ILambdaInvocationContext.Serializer`. + +### AWS runtime + +- Inner workflow payload extraction and serialization. +- Checkpoint serialization, replay, and suspension. +- `IDurableContext` construction. +- Durable status and result mapping. + +The same serializer instance handles outer envelopes in MinimalLambda and inner values through AWS. +Durable envelopes are not exposed through `IEventFeature` or `IResponseFeature`; MinimalLambda does +not create a public durable envelope abstraction or parse the inner payload. + +For `MapDurableHandler`, `[FromEvent] TInput` always means workflow input. The outer +`DurableExecutionInvocationInput`, checkpoint token, and replay history are not bindable handler +parameters. Execution identity and Lambda metadata remain available through `IDurableContext`: + +```csharp +var executionArn = durable.ExecutionContext.DurableExecutionArn; +var requestId = durable.LambdaContext.AwsRequestId; +``` + +Serializer diagnostics are necessarily limited. The generator can infer the durable envelope, +workflow input, and workflow output types, but cannot reliably discover serialization types hidden +inside workflow methods or referenced libraries. Users remain responsible for registering metadata +for step results, callback results, invoke and child-workflow payloads, wait-condition state, and +map or parallel results. + +### Cancellation + +A durable handler may declare one root `CancellationToken`. MinimalLambda binds it to physical +invocation cancellation through `ILambdaInvocationContext.CancellationToken`; it is not a durable +workflow-operation token. Near-timeout cancellation can fault the root workflow task, which AWS maps +to a terminal `FAILED` durable result instead of allowing the physical invocation to time out and retry. + +Use SDK-provided callback tokens for durable steps. A handler that uses the root token owns the +resulting durable failure and retry semantics. + +### Middleware + +Middleware wraps one physical Lambda invocation and runs again on replay. Neither raw checkpoint +transport nor typed workflow input is part of the durable middleware contract. Existing outer +features are framework transport plumbing, not a supported application abstraction. + +If a concrete middleware use case emerges, MinimalLambda may expose read-only semantic metadata, +such as execution ARN. It will not expose checkpoint token or replay history through that API. + +Durable middleware should call and await `next` once, preserve exceptions, and avoid response +short-circuits or fabrication. This is guidance, not framework enforcement: skipped `next` can return +an empty response, repeated `next` reruns the adapter, and swallowed failures can change AWS-visible +behavior. This tradeoff removes durable-specific state from the shared host pipeline. + +Existing middleware that only observes, logs, measures, or adds invocation-scoped behavior remains +reusable. Response caching, ordinary typed-response short-circuiting, and exception-to-response +translation middleware is not reusable unchanged with `MapDurableHandler`. + +### Escape hatch + +Advanced users can map the outer AWS types directly: + +```csharp +lambda.MapHandler(( + [FromEvent] DurableExecutionInvocationInput envelope, + ILambdaInvocationContext invocation, + IAmazonLambda client) => + DurableFunction.WrapAsync( + Workflow, + envelope, + invocation, + client)); +``` + +This supports custom AWS clients, protocol diagnostics, and new AWS overloads without expanding the +normal workflow API. It is the only supported path for raw envelope access. + +## Rationale + +The generated adapter is the narrow point where all required static type information is available. +Type-independent pipeline behavior stays in MinimalLambda runtime; durable protocol behavior stays in AWS. + +## Validation requirements + +Implementation must cover: + +- Successful, failed, and suspended AWS durable outputs. +- Middleware execution before and after a suspended physical invocation. +- Serializer identity across outer MinimalLambda and inner AWS serialization. +- Generator binding of an optional durable root `CancellationToken` to physical invocation cancellation. +- Diagnostics for inferable serializer roots without claiming coverage of nested workflow types. + +Full host-plus-replay testing may require separate MinimalLambda host tests and AWS durable SDK tests +because the AWS in-memory durable service-client overload is not public. + +## Consequences + +### Positive + +- Existing features, DI, and replay-safe middleware remain reusable. +- Inner serialization remains entirely AWS-owned. +- Normal workflows match the typed-input-and-context model used by other AWS language SDKs. +- Checkpoint tokens and replay history remain transport details. +- Generated code stays small and AOT friendly. +- Manual AWS integration remains available. + +### Negative / trade-offs + +- Middleware cannot inspect typed workflow input before the terminal runs. +- Raw envelope access requires the explicit low-level mapping path. +- Middleware executes on every physical replay. +- Middleware that short-circuits, repeats `next`, or translates exceptions into ordinary responses can change durable behavior and is unsupported guidance rather than a host-enforced error. +- Root `CancellationToken` represents physical invocation cancellation, not durable operation cancellation. +- Serializer diagnostics cannot cover types hidden inside workflow implementations or libraries. +- Core runtime needs serializer exposure, but no durable-specific lifecycle state. + +## References + +- [`ADR-001: Durable handler integration model`](./ADR-001-durable-handler-integration-model.md) +- [`ADR-002: Durable package and source-generation ownership`](./ADR-002-durable-package-and-source-generation-ownership.md) +- [Durable dependency and support matrix](./durable-dependency-support-matrix.md) +- [AWS durable execution key concepts](https://docs.aws.amazon.com/durable-execution/getting-started/key-concepts/) diff --git a/decisions/ADR-004-durable-handler-signature-and-diagnostics-contract.md b/decisions/ADR-004-durable-handler-signature-and-diagnostics-contract.md new file mode 100644 index 00000000..196fb582 --- /dev/null +++ b/decisions/ADR-004-durable-handler-signature-and-diagnostics-contract.md @@ -0,0 +1,44 @@ +# ADR-004: Durable handler adapter contract + +## Status + +- Accepted +- **Date:** 2026-08-03 +- **Deciders:** MinimalLambda maintainers + +## Context + +`MapDurableHandler` adapts a MinimalLambda handler to the public AWS Durable Execution wrappers: + +```csharp +Func +Func> +``` + +The generated terminal handler owns outer `DurableExecutionInvocationInput` and +`DurableExecutionInvocationOutput` transport. It uses `ILambdaInvocationContext.Serializer` for both outer transport and AWS `DurableFunction.WrapAsync` inner durable payload transport. `ILambdaInvocationContext` forwards runtime `ILambdaContext.Serializer` or falls back to the invocation services serializer. + +## Decision + +- `[FromEvent]` input and `IDurableContext` parameters are optional. If no event parameter is + present, the generated adapter uses an ignored `object` payload; if no durable context is present, + it is unused. +- Other value parameters follow normal MinimalLambda binding rules. `ref`, `in`, and `out` parameters + are rejected because generated durable adapters cannot safely preserve their calling semantics. +- Handler parameter, input, output, and service types must be accessible from namespace-level generated + code, cannot contain unbound type parameters, and cannot be pointer or ref-like types. Invalid signature + components suppress adapter emission. +- A requested root `CancellationToken` binds to `ILambdaInvocationContext.CancellationToken`: it is a + physical Lambda-invocation token, not an AWS durable-operation token. SDK operation callbacks retain + their own cancellation tokens; their behavior is out of scope here. +- The generator does not inspect source-generated serializer contexts. Applications remain responsible + for registering metadata needed by configured serializer. +- `LH0007` is emitted for unsupported durable signature components: return type must be `Task` or + `Task`; parameters must be values; emitted types must be accessible, closed, and valid generic + type arguments. + +## Consequences + +Handlers can be minimal (`Task Handle()`) or opt into input, durable context, invocation context, +and DI as needed. More shapes are left to the compiler, runtime, and application serializer rather +than rejected by generator-specific policy. diff --git a/decisions/ADR-005-durable-project-template-scope.md b/decisions/ADR-005-durable-project-template-scope.md new file mode 100644 index 00000000..cd2cd012 --- /dev/null +++ b/decisions/ADR-005-durable-project-template-scope.md @@ -0,0 +1,35 @@ +# ADR-005: Defer dedicated durable project template + +## Status + +- Accepted +- **Date:** 2026-08-01 +- **Deciders:** MinimalLambda maintainers +- **Supersedes:** none + +______________________________________________________________________ + +## Context + +Durable Execution requires package references, serializer roots, deployment defaults, and replay-aware +handler guidance that differ from ordinary Lambda applications. Existing `mlambda` and `mlambda-aot` +templates remain ordinary Lambda templates and must not gain Durable Execution dependencies. + +## Decision + +Defer `mlambda-durable` from `MinimalLambda.Templates` until durable package release coupling and managed-service deployment evidence are available. Candidate template sources remain unshipped and are excluded from the template package and package-reference stamping. + +No durable dependency is added to existing templates. NativeAOT durable template remains out of scope: +local NativeAOT evidence does not prove managed Durable Execution support. + +## Consequences + +- Published template package contains only standard and NativeAOT templates. +- Candidate durable template sources target only `net10.0` and deploy with `dotnet10` when later shipped. +- Durable template release requires managed-service deployment evidence and a versioning contract with `MinimalLambda.DurableExecution`. + +## References + +- [ADR-002: Durable package and source-generation ownership](./ADR-002-durable-package-and-source-generation-ownership.md) +- [Durable dependency support matrix](./durable-dependency-support-matrix.md) +- [Canonical durable example](../examples/MinimalLambda.Example.DurableExecution/README.md) diff --git a/decisions/ADR_TEMPLATE.md b/decisions/ADR_TEMPLATE.md new file mode 100644 index 00000000..49c9d0cb --- /dev/null +++ b/decisions/ADR_TEMPLATE.md @@ -0,0 +1,83 @@ +--- +adr: NNN +status: proposed # proposed | accepted | deprecated | superseded +date: YYYY-MM-DD +deciders: + - Name or role +supersedes: [] +superseded_by: +--- + +# ADR-NNN: + +## Context + + + +## Decision drivers + + + +## Options considered + +### Option A: + + + +**Pros** + +- + +**Cons** + +- + +### Option B: + + + +**Pros** + +- + +**Cons** + +- + + + +## Decision + + + +## Rationale + + + +## Consequences + +### Positive + +- + +### Negative / trade-offs + +- + +## Follow-up actions + +- [ ] Action or linked issue + +## References + + diff --git a/decisions/README.md b/decisions/README.md new file mode 100644 index 00000000..ee5dca04 --- /dev/null +++ b/decisions/README.md @@ -0,0 +1,35 @@ +# Architecture Decision Records + +Architecture Decision Records (ADRs) capture consequential, durable decisions affecting MinimalLambda public API, package boundaries, or overall architecture. + +Use an ADR when a decision: + +- Defines or materially changes public API +- Establishes a package or versioning boundary +- Commits the project to a major architectural integration +- Has multiple credible alternatives with long-term consequences +- Would be expensive or disruptive to reverse after release + +Do not use ADRs for routine implementation details, test organization, middleware behavior that follows existing framework semantics, or choices easily changed without public impact. Record those in plans, issues, code, or user documentation instead. + +Create ADRs from [`ADR_TEMPLATE.md`](./ADR_TEMPLATE.md). Number them sequentially and use a short noun-phrase filename: + +```text +ADR-001-durable-handler-integration-model.md +``` + +ADR metadata lives in YAML front matter. Use lowercase status values: `proposed`, `accepted`, +`deprecated`, or `superseded`. Keep relationship fields machine-readable: use YAML lists for +`supersedes` and `null` when `superseded_by` does not apply. + +## Records + +- [ADR-001: Durable handler integration model](./ADR-001-durable-handler-integration-model.md) +- [ADR-002: Durable package and source-generation ownership](./ADR-002-durable-package-and-source-generation-ownership.md) +- [ADR-003: Durable pipeline and adapter ownership](./ADR-003-durable-pipeline-and-adapter-ownership.md) +- [ADR-004: Durable handler signature and diagnostics contract](./ADR-004-durable-handler-signature-and-diagnostics-contract.md) +- [ADR-005: Defer dedicated durable project templates](./ADR-005-durable-project-template-scope.md) + +## Supporting records + +- [Durable Execution dependency and support matrix](./durable-dependency-support-matrix.md) diff --git a/decisions/durable-dependency-support-matrix.md b/decisions/durable-dependency-support-matrix.md new file mode 100644 index 00000000..47f8f36a --- /dev/null +++ b/decisions/durable-dependency-support-matrix.md @@ -0,0 +1,54 @@ +# Durable Execution dependency and support matrix + +**Verified:** 2026-08-01 + +## Selected dependencies + +| Package | Selected version | Supported asset | Purpose | +| ---------------------------------------- | ---------------- | --------------- | --------------------------------------------- | +| `Amazon.Lambda.DurableExecution` | `1.0.0` | `net10.0` | Runtime and DE001-DE004 analyzers | +| `Amazon.Lambda.DurableExecution.Testing` | `1.0.0` | `net10.0` | In-memory workflow testing | +| `Amazon.Lambda.Tools` | `7.0.0` minimum | `net10.0` | Durable deployment and invocation CLI support | + +Consumers must reference `MinimalLambda`, `MinimalLambda.DurableExecution`, and +`Amazon.Lambda.DurableExecution` directly. Direct AWS reference activates AWS analyzers; direct +MinimalLambda reference activates MinimalLambda source generation. + +## Framework and runtime support + +| Area | `net10.0` / `dotnet10` | Other frameworks/runtimes | +| ------------------------------------- | -------------------------------- | ------------------------- | +| MinimalLambda durable package | Supported | Unsupported | +| Canonical example | Supported | Unsupported | +| Candidate durable template | Deferred from published package | Unsupported | +| Local restore/build and test coverage | Required | Out of scope | +| NativeAOT publish | Experimental local evidence only | Unsupported | +| Managed Durable Execution deployment | Not cloud-verified | Unsupported | + +`MinimalLambda.DurableExecution` targets only `net10.0`. NuGet asset fallback is not a support +claim. Candidate `mlambda-durable` source uses managed `dotnet10`; it is deferred from the published template package and existing ordinary templates remain unchanged. + +## Evidence boundary + +Verified locally: + +- Package metadata, source generation, serializer roots, and unit/integration tests. +- Standard-template packing, installation, generation, restore, and build. +- Candidate durable template source is excluded from published template-package contents. +- Durable package and testing package dependency graph. + +Requires AWS cloud verification: + +- Durable function creation and qualified invocation. +- Checkpoint/replay, wait/suspension, callback, failure, retention, and IAM paths. +- Managed hosting and NativeAOT under replay. + +Do not describe durable execution as cloud-verified or production-ready NativeAOT until those tests +run. + +## Sources + +- [Amazon.Lambda.DurableExecution 1.0.0](https://www.nuget.org/packages/Amazon.Lambda.DurableExecution/1.0.0) +- [Amazon.Lambda.DurableExecution.Testing 1.0.0](https://www.nuget.org/packages/Amazon.Lambda.DurableExecution.Testing/1.0.0) +- [AWS durable supported runtimes](https://docs.aws.amazon.com/lambda/latest/dg/durable-supported-runtimes.html) +- [AWS durable infrastructure configuration](https://docs.aws.amazon.com/lambda/latest/dg/durable-getting-started-iac.html) diff --git a/docs/features/durable-execution.md b/docs/features/durable-execution.md new file mode 100644 index 00000000..17dd4325 --- /dev/null +++ b/docs/features/durable-execution.md @@ -0,0 +1,189 @@ +# Durable Execution + +Use `MapDurableHandler` to build typed workflows on [AWS Lambda Durable Execution](https://docs.aws.amazon.com/durable-execution/getting-started/key-concepts/). + +!!! warning "Experimental" + + `MinimalLambda.DurableExecution` and its NativeAOT support are experimental. APIs may change before a stable release. + +## Install packages + +Target `net10.0`. Reference all three packages directly: + +```bash +dotnet add package MinimalLambda --version 2.6.0-beta.2 +dotnet add package MinimalLambda.DurableExecution --version 2.6.0-beta.2 +dotnet add package Amazon.Lambda.DurableExecution --version 1.0.0 +``` + +`MinimalLambda.DurableExecution` releases with `MinimalLambda`; use matching versions. Direct `MinimalLambda` reference supplies source generator for `MapHandler` and `MapDurableHandler`. Direct AWS package reference supplies DE001-DE004 analyzers, which do not flow through transitive dependencies. + +NuGet fallback may select an asset for another target framework, but only `net10.0` is supported. + +## Deploy durable function + +`MapDurableHandler` does not configure AWS infrastructure. Deployment must configure Lambda `DurableConfig`, durable IAM permissions, and a qualified target. Use sample [package recipe](https://github.com/LayeredCraft/minimal-lambda/tree/main/examples/MinimalLambda.Example.DurableExecution#package-for-deployment); it produces Lambda ZIP and deploys with Amazon.Lambda.Tools durable defaults. Custom roles require equivalent durable permissions plus application-specific permissions. Versions, aliases, and `$LATEST` are supported qualified targets; prefer immutable versions or aliases for stable routing. + +No managed-service deployment was run. Consult current [AWS Durable Execution deployment documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-getting-started.html) before production use. + +## Build typed workflow + +Following complete `Program.cs` is maintained as [canonical Durable Execution sample](https://github.com/LayeredCraft/minimal-lambda/tree/main/examples/MinimalLambda.Example.DurableExecution): + +```csharp title="Program.cs" +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); + +lambda.MapDurableHandler(async ( + [FromEvent] OrderRequest request, + IDurableContext durable, + [FromServices] IOrderService orders) => +{ + var step = await durable.StepAsync( + (_, cancellationToken) => orders.ProcessAsync(request.OrderId, cancellationToken), + name: "process-order"); + + return new OrderResult( + step.Message, + durable.ExecutionContext.DurableExecutionArn, + durable.LambdaContext.AwsRequestId); +}); + +await lambda.RunAsync(); + +internal sealed record OrderRequest(string OrderId); + +internal sealed record OrderResult(string Message, string ExecutionArn, string AwsRequestId); + +internal sealed record ProcessOrderStepResult(string Message); + +internal interface IOrderService +{ + Task ProcessAsync(string orderId, CancellationToken cancellationToken); +} + +internal sealed class OrderService : IOrderService +{ + public Task ProcessAsync( + string orderId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ProcessOrderStepResult($"Order {orderId} processed")); + } +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(OrderRequest))] +[JsonSerializable(typeof(OrderResult))] +[JsonSerializable(typeof(ProcessOrderStepResult))] +internal partial class DurableExampleJsonContext : JsonSerializerContext; +``` + +`StepAsync` checkpoints result. Step body can still be retried, so external side effects must be idempotent. Checkpointing does not guarantee exactly-once execution. + +## Handler contract + +Durable handlers support two return forms: + +| Form | Purpose | +| --------------- | ----------------------------- | +| `Task` | Workflow with no typed result | +| `Task` | Workflow with typed result | + +`[FromEvent] TInput` and AWS `IDurableContext` are optional; a handler may use either, both, or neither. Each can occur at most once. Parameter order is unrestricted. Other parameters use normal handler binding rules, but `ref`, `in`, and `out` parameters are unsupported. Types referenced by handler parameters or output must be accessible from generated code and cannot contain unbound type parameters. See [Dependency Injection](../guides/dependency-injection.md) for service lifetimes. + +Do not expose `DurableExecutionInvocationInput`, `DurableExecutionInvocationOutput`, streams, or AWS client in high-level handler. MinimalLambda directly deserializes and serializes hidden outer envelopes through its configured Lambda serializer; durable envelopes are not available through `IEventFeature` or `IResponseFeature`. It also owns raw-stream hosting, middleware, DI scope, and physical invocation context. AWS runtime owns workflow payloads, `IDurableContext`, checkpoints, replay, suspension, waits, and durable status/result mapping. + +### Cancellation + +AWS operation callbacks receive SDK-linked cancellation tokens. A durable handler that explicitly declares `CancellationToken` receives `ILambdaInvocationContext.CancellationToken`; it owns resulting physical-invocation failure and retry behavior. It is not a durable-operation token. + +### Execution and invocation metadata + +Logical durable execution can span many physical Lambda invocations. Use: + +- `durable.ExecutionContext.DurableExecutionArn` for logical execution identity. +- `durable.LambdaContext` for AWS metadata from current physical invocation. +- injected `ILambdaInvocationContext` for MinimalLambda metadata and services from current physical invocation. + +When only `IDurableContext` is available, recover exact MinimalLambda context: + +```csharp +using MinimalLambda.DurableExecution; + +ILambdaInvocationContext invocation = durable.GetInvocationContext(); +``` + +Invocation context and scoped DI belong to one physical invocation. Replay creates new invocation and scope. Never store logical workflow state in scoped service. + +## Serialization and AOT checklist + +One registered `ILambdaSerializer` handles MinimalLambda outer envelopes and AWS inner values. For source-generated JSON and AOT: + +- [ ] Register context with `AddLambdaSerializerWithContext()`. +- [ ] Add `DurableExecutionInvocationInput` root. +- [ ] Add `DurableExecutionInvocationOutput` root. +- [ ] Add the event type if the handler declares one. +- [ ] For event-less handlers, add `[JsonSerializable(typeof(object))]`; generated adapters use it for ignored workflow input. +- [ ] For `Task`, add `TOutput` when required by the configured serializer. +- [ ] Add every operation payload, result, and state root used by steps, callbacks, invokes, child workflows, waits, maps, or parallel branches. +- [ ] Publish intended runtime and architecture with NativeAOT enabled; restore/build alone does not compile native code. + +The generator does not inspect serializer contexts. Ensure that the context registered for the Lambda serializer includes the outer envelopes and every payload, result, and state type your workflow uses. + +Local source-based `net10.0` NativeAOT publish passes. This proves local publishing only. Managed cloud integration remains unverified, and Durable Execution NativeAOT support remains experimental. Successful local publish is not evidence of deployment, IAM, replay, or managed-service behavior. See [AWS NativeAOT guidance](https://docs.aws.amazon.com/lambda/latest/dg/dotnet-native-aot.html) and project [support matrix](https://github.com/LayeredCraft/minimal-lambda/blob/main/decisions/durable-dependency-support-matrix.md). + +## Make replay safe + +AWS rebuilds workflow state by replaying code around checkpointed operations. + +- Keep step and callback side effects idempotent. +- Use `durable.Logger` or operation context logger for workflow logs. AWS logger suppresses messages while re-deriving checkpointed operations; ambient loggers and `Console.WriteLine` repeat on replay. +- Do not use invocation-scoped memory or DI service as durable state. +- Expect middleware, scope construction, and invocation-level telemetry to run once per physical invocation, including replay. + +Durable middleware should call and await `next` once, preserve exceptions, and avoid response fabrication or short-circuiting. MinimalLambda does not enforce these rules: skipped `next` can produce an empty response, repeated `next` reruns the adapter, and swallowed failures can change AWS-visible behavior. Replay-safe logging, metrics, tracing, and invocation-scoped observation fit. Apply these constraints when using [Middleware](../guides/middleware.md) or [OpenTelemetry](open_telemetry.md). + +## Test locally + +Build canonical sample: + +```bash +dotnet build examples/MinimalLambda.Example.DurableExecution/MinimalLambda.Example.DurableExecution.csproj +``` + +Split tests by ownership: + +- Use [MinimalLambda host/integration tests](../guides/testing.md) for generated adapter, middleware, DI, serializer identity, and outer stream roundtrip. Durable envelope feature access is intentionally unsupported. +- Use `Amazon.Lambda.DurableExecution.Testing` for workflow operations, suspension, waits, and replay. Follow [AWS durable testing guide](https://docs.aws.amazon.com/lambda/latest/dg/durable-testing.html). + +`dotnet run` is not local workflow runner; executable expects Lambda Runtime API. Local engine and integration tests do not prove IAM, deployment, managed-runtime behavior, retention, or cloud service integration. + +## Troubleshooting + +| Symptom | Fix | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LH0007` | Use `Task` or `Task`, value parameters, and closed types accessible to generated adapter code. | +| Runtime `InvalidOperationException` at `MapDurableHandler` | Compile-time interceptor did not replace fallback stub. Keep direct `MinimalLambda` reference and project interceptor/source-generator configuration. | +| AWS DE001-DE004 absent | Add direct `Amazon.Lambda.DurableExecution` reference; analyzer assets are not transitive. | +| Duplicate logs, metrics, or DI work | Replay caused another physical invocation. Use AWS replay-aware logger for workflow logs and make invocation observation replay-safe. | +| `dotnet run` cannot execute workflow | Use builds/tests or deploy configured durable function; process expects Lambda Runtime API. | + +## Raw-envelope escape hatch + +For raw envelope access, custom `IAmazonLambda`, protocol diagnostics, or new AWS overloads, replace `MapDurableHandler` with low-level `MapHandler` plus `DurableFunction.WrapAsync`. Never register both paths. See [package raw-envelope guide](https://github.com/LayeredCraft/minimal-lambda/tree/main/src/MinimalLambda.DurableExecution) for full code and serializer requirements. + +Related: [Handler Registration](../guides/handler-registration.md), [Middleware](../guides/middleware.md), [Dependency Injection](../guides/dependency-injection.md), and [Testing](../guides/testing.md). diff --git a/docs/features/index.md b/docs/features/index.md index 236163a3..ce4c0292 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -12,6 +12,10 @@ ______________________________________________________________________ The Envelope pattern provides type-safe wrappers for various AWS event sources like SQS, SNS, and API Gateway. Instead of manually parsing JSON, you can work with strongly-typed objects, improving code quality and developer productivity. +### [Durable Execution](./durable-execution.md) + +Build typed, checkpointed AWS Lambda Durable Execution workflows with source-generated handler binding, dependency injection, and replay-aware guidance. + ### [Observability (OpenTelemetry)](./open_telemetry.md) This feature provides comprehensive observability through OpenTelemetry integration. It enables distributed tracing and metrics collection, offering deep insights into your Lambda function's performance and behavior. diff --git a/docs/index.md b/docs/index.md index c0cd1fbe..6bd716e4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ title: '' # MinimalLambda: ASP.NET Core Patterns for AWS Lambda -[![PR Build](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-build.yaml/badge.svg)](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-build.yaml) +[![PR Quality Gates](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-quality.yaml/badge.svg)](https://github.com/LayeredCraft/minimal-lambda/actions/workflows/pr-quality.yaml) [![codecov](https://codecov.io/gh/LayeredCraft/minimal-lambda/graph/badge.svg?token=BWORPTQ0UK)](https://codecov.io/gh/LayeredCraft/minimal-lambda) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/LayeredCraft/minimal-lambda/blob/main/LICENSE) diff --git a/examples/MinimalLambda.Example.DurableExecution/MinimalLambda.Example.DurableExecution.csproj b/examples/MinimalLambda.Example.DurableExecution/MinimalLambda.Example.DurableExecution.csproj new file mode 100644 index 00000000..66442579 --- /dev/null +++ b/examples/MinimalLambda.Example.DurableExecution/MinimalLambda.Example.DurableExecution.csproj @@ -0,0 +1,24 @@ + + + Exe + net10.0 + preview + enable + enable + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + MinimalLambda.Example.DurableExecution + false + + + + + + + \ No newline at end of file diff --git a/examples/MinimalLambda.Example.DurableExecution/Program.cs b/examples/MinimalLambda.Example.DurableExecution/Program.cs new file mode 100644 index 00000000..be57e66b --- /dev/null +++ b/examples/MinimalLambda.Example.DurableExecution/Program.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); + +lambda.MapDurableHandler(async ( + [FromEvent] OrderRequest request, + IDurableContext durable, + [FromServices] IOrderService orders) => +{ + var step = await durable.StepAsync( + (_, cancellationToken) => orders.ProcessAsync(request.OrderId, cancellationToken), + name: "process-order"); + + return new OrderResult( + step.Message, + durable.ExecutionContext.DurableExecutionArn, + durable.LambdaContext.AwsRequestId); +}); + +await lambda.RunAsync(); + +internal sealed record OrderRequest(string OrderId); + +internal sealed record OrderResult(string Message, string ExecutionArn, string AwsRequestId); + +internal sealed record ProcessOrderStepResult(string Message); + +internal interface IOrderService +{ + Task ProcessAsync(string orderId, CancellationToken cancellationToken); +} + +internal sealed class OrderService : IOrderService +{ + public Task ProcessAsync( + string orderId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ProcessOrderStepResult($"Order {orderId} processed")); + } +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(OrderRequest))] +[JsonSerializable(typeof(OrderResult))] +[JsonSerializable(typeof(ProcessOrderStepResult))] +internal partial class DurableExampleJsonContext : JsonSerializerContext; diff --git a/examples/MinimalLambda.Example.DurableExecution/README.md b/examples/MinimalLambda.Example.DurableExecution/README.md new file mode 100644 index 00000000..c904be60 --- /dev/null +++ b/examples/MinimalLambda.Example.DurableExecution/README.md @@ -0,0 +1,86 @@ +# MinimalLambda durable execution example + +Typed durable handler with dependency injection, execution metadata, checkpointed step result, and source-generated JSON serialization. + +```bash +dotnet build examples/MinimalLambda.Example.DurableExecution/MinimalLambda.Example.DurableExecution.csproj +``` + +`dotnet run` expects Lambda Runtime API and is not a standalone local workflow runner. + +## Package for deployment + +Install Amazon.Lambda.Tools. From this directory, produce deployment ZIP: + +```bash +dotnet lambda package \ + --configuration Release \ + --output-package artifacts/MinimalLambda.Example.DurableExecution.zip +``` + +This sample uses Amazon.Lambda.Tools deployment defaults rather than SAM. Deploy with Amazon.Lambda.Tools 7.0.0 or later; `aws-lambda-tools-defaults.json` supplies managed `dotnet10`, `durable-execution-timeout`, retention, and `function-publish` settings. Supply a function name and execution role with `AWSLambdaBasicDurableExecutionRolePolicy` plus application-specific permissions: + +```bash +dotnet lambda deploy-function \ + --package artifacts/MinimalLambda.Example.DurableExecution.zip \ + --function-role arn:aws:iam:::role/ +``` + +AWS configures `DurableConfig` from durable deployment settings. Follow AWS [Durable Execution deployment guide](https://docs.aws.amazon.com/lambda/latest/dg/durable-getting-started.html) for current IAM and deployment requirements. Durable invocations support a published version, alias, or `$LATEST`; prefer immutable version or alias for stable routing. Start and poll an execution with Amazon.Lambda.Tools 7.0.0 or later: + +```bash +dotnet lambda invoke-function : \ + --invoke-mode DurableExecution \ + --payload '{"OrderId":"order-123"}' +``` + +Consult current [AWS Durable Execution documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-getting-started.html) for deployment and IAM requirements. No AWS deployment was run for this example. + +## Advanced low-level escape hatch + +`MapDurableHandler` is preferred because normal handler stays typed and does not expose protocol envelopes or AWS client. If direct protocol control is required, replace that mapping (do not add a second mapping) with low-level `MapHandler` wiring: + +```csharp +using Amazon.Lambda; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); +lambda.MapHandler( + async ( + [FromEvent] DurableExecutionInvocationInput envelope, + ILambdaInvocationContext invocation, + [FromServices] IAmazonLambda client) => + await DurableFunction.WrapAsync( + LowLevelWorkflowAsync, + envelope, + invocation, + client)); + +static async Task LowLevelWorkflowAsync( + OrderRequest request, + IDurableContext durable) +{ + var step = await durable.StepAsync( + (_, _) => Task.FromResult( + new ProcessOrderStepResult($"Order {request.OrderId} processed")), + name: "process-order"); + + return new OrderResult( + step.Message, + durable.ExecutionContext.DurableExecutionArn, + durable.LambdaContext.AwsRequestId); +} + +await lambda.RunAsync(); +``` + +Keep `DurableExecutionInvocationInput`, `DurableExecutionInvocationOutput`, handler input/output, and step result as explicit `JsonSerializable` roots in either approach. + +Step bodies can be retried. Keep external side effects idempotent; checkpointing a step result does not provide exactly-once execution. diff --git a/examples/MinimalLambda.Example.DurableExecution/aws-lambda-tools-defaults.json b/examples/MinimalLambda.Example.DurableExecution/aws-lambda-tools-defaults.json new file mode 100644 index 00000000..f409de47 --- /dev/null +++ b/examples/MinimalLambda.Example.DurableExecution/aws-lambda-tools-defaults.json @@ -0,0 +1,16 @@ +{ + "Information": [ + "Defaults for packaging and deploying the executable MinimalLambda durable example with Amazon.Lambda.Tools 7.", + "Run dotnet lambda help for deployment command options." + ], + "configuration": "Release", + "framework": "net10.0", + "package-type": "Zip", + "function-runtime": "dotnet10", + "function-handler": "MinimalLambda.Example.DurableExecution", + "function-memory-size": 512, + "function-timeout": 30, + "function-publish": true, + "durable-execution-timeout": 86400, + "durable-retention-period": 7 +} diff --git a/global.json b/global.json index 625d93d8..865d9da8 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "rollForward": "latestMinor", - "version": "11.0.100-preview.3.26207.106" + "version": "11.0.100-preview.6.26359.118" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/mkdocs.yml b/mkdocs.yml index dace9c5e..2db8e269 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Features: - features/index.md - Envelopes: features/envelopes.md + - Durable Execution: features/durable-execution.md - OpenTelemetry: features/open_telemetry.md - Advanced (Coming Soon): - advanced/index.md diff --git a/scripts/dry-run-release-manifests.sh b/scripts/dry-run-release-manifests.sh new file mode 100755 index 00000000..9e04c252 --- /dev/null +++ b/scripts/dry-run-release-manifests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d "${TMPDIR:-/tmp}/minimal-lambda-release.XXXXXX") +cleanup() { + rm -rf "$temporary" +} +trap cleanup EXIT INT TERM + +# Template packing stamps source files. Copy checkout first so interruption, hard kill, or +# concurrent runs cannot alter active working tree. +work_root="$temporary/repository" +mkdir -p "$work_root" +rsync -a \ + --exclude .git \ + --exclude .cache \ + --exclude artifacts \ + --exclude bin \ + --exclude obj \ + --exclude __pycache__ \ + "$repo_root/" "$work_root/" + +artifacts="$temporary/artifacts" +version=91.2.3-preview.456 + +cd "$work_root" + +dotnet restore MinimalLambda.Packages.slnf --tl:off +dotnet build MinimalLambda.Packages.slnf \ + --tl:off \ + --configuration Release \ + --no-restore \ + -p:Version="$version" +dotnet pack MinimalLambda.Packages.slnf \ + --tl:off \ + --configuration Release \ + --no-build \ + --output "$artifacts/packages" \ + -p:Version="$version" + +python3 scripts/validate-release-artifacts.py \ + --lane core \ + --tag "v$version" \ + --artifacts "$artifacts/packages" + +python3 scripts/validate-release-artifacts.py \ + --lane core-preview \ + --expected-version "$version" \ + --preview-run-number 456 \ + --artifacts "$artifacts/packages" + +python3 scripts/test-release-artifact-validator.py + +echo "Shared release manifest validated; active checkout was not packed." diff --git a/scripts/test-package-compatibility.sh b/scripts/test-package-compatibility.sh new file mode 100755 index 00000000..37231d1a --- /dev/null +++ b/scripts/test-package-compatibility.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +FIXTURES="$ROOT/tests/package-compatibility" +OLD_CORE_VERSION=56.0.0-x56-old +CORE_VERSION=56.1.0-x56-core +DURABLE_VERSION=$CORE_VERSION +WORK=$(mktemp -d "${TMPDIR:-/tmp}/minimal-lambda-package-compat.XXXXXX") +FEED="$WORK/feed" +CONSUMERS="$WORK/consumers" +LOGS="$WORK/logs" +CONFIG="$WORK/NuGet.Config" +SUCCESS=0 + +finish() { + status=$? + if [ "$status" -eq 0 ]; then + SUCCESS=1 + fi + + if [ "$SUCCESS" -eq 1 ] && [ "${PACKAGE_COMPAT_KEEP_WORK:-0}" != "1" ]; then + rm -rf "$WORK" + else + printf '\nPackage compatibility artifacts: %s\n' "$WORK" >&2 + fi +} +trap finish EXIT + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +run_logged() { + local name=$1 + shift + printf '\n==> %s\n' "$name" + "$@" 2>&1 | tee "$LOGS/$name.log" +} + +assert_warning_free() { + local log=$1 + if grep -Eiq '(^|[ :])warning([[:space:]]+[A-Z]+[0-9]{4})?:' "$log"; then + fail "warning found in $log" + fi +} + +assert_generated_once() { + local project_dir=$1 + local target=$2 + local count + [ -d "$project_dir/obj/generated/$target" ] || fail "generated output missing for $(basename "$project_dir")/$target" + count=$(find "$project_dir/obj/generated/$target" -type f -name 'MinimalLambda.DurableHandlers.g.cs' | wc -l | tr -d ' ') + [ "$count" = "1" ] || fail "expected one durable generated file for $(basename "$project_dir")/$target, found $count" +} + +pack() { + local name=$1 + local project=$2 + local version=$3 + shift 3 + run_logged "build-$name" dotnet build "$project" \ + --configuration Release \ + --nologo \ + "/p:Version=$version" \ + "/p:GeneratePackageOnBuild=false" \ + "$@" + run_logged "pack-$name" dotnet pack "$project" \ + --configuration Release \ + --no-build \ + --output "$FEED" \ + --nologo \ + "/p:Version=$version" \ + "/p:GeneratePackageOnBuild=false" \ + "$@" +} + +restore_consumer() { + local name=$1 + local project=$2 + local core_version=$3 + shift 3 + run_logged "restore-$name" dotnet restore "$project" \ + --configfile "$CONFIG" \ + --force \ + --no-cache \ + --nologo \ + --tl:off \ + "/p:CorePackageVersion=$core_version" \ + "/p:DurablePackageVersion=$DURABLE_VERSION" \ + "$@" +} + +build_consumer() { + local name=$1 + local project=$2 + local project_dir + project_dir=$(dirname "$project") + run_logged "build-$name-net10.0" dotnet build "$project" \ + --configuration Release \ + --framework net10.0 \ + --no-restore \ + --nologo \ + "/p:CorePackageVersion=$CORE_VERSION" \ + "/p:DurablePackageVersion=$DURABLE_VERSION" \ + "/p:EmitCompilerGeneratedFiles=true" \ + "/p:CompilerGeneratedFilesOutputPath=$project_dir/obj/generated/net10.0" + assert_warning_free "$LOGS/build-$name-net10.0.log" + assert_generated_once "$project_dir" net10.0 +} + +case "${PACKAGE_COMPAT_RID:-}" in + '') + machine=$(uname -m) + case "$(uname -s):$machine" in + Linux:x86_64|Linux:amd64) RID=linux-x64 ;; + Linux:aarch64|Linux:arm64) RID=linux-arm64 ;; + Darwin:x86_64|Darwin:amd64) RID=osx-x64 ;; + Darwin:arm64|Darwin:aarch64) RID=osx-arm64 ;; + *) fail "cannot determine host RID; set PACKAGE_COMPAT_RID" ;; + esac + ;; + *) RID=$PACKAGE_COMPAT_RID ;; +esac + +mkdir -p "$FEED" "$CONSUMERS" "$LOGS" "$WORK/packages" "$WORK/dotnet-home" "$WORK/http-cache" "$WORK/plugins-cache" +export DOTNET_NOLOGO=1 +export DOTNET_CLI_HOME="$WORK/dotnet-home" +export NUGET_PACKAGES="$WORK/packages" +export NUGET_HTTP_CACHE_PATH="$WORK/http-cache" +export NUGET_PLUGINS_CACHE_PATH="$WORK/plugins-cache" + +printf 'Working directory: %s\n' "$WORK" +printf 'NativeAOT RID: %s\n' "$RID" + +pack abstractions-old "$ROOT/src/MinimalLambda.Abstractions/MinimalLambda.Abstractions.csproj" "$OLD_CORE_VERSION" +pack core-old "$ROOT/src/MinimalLambda/MinimalLambda.csproj" "$OLD_CORE_VERSION" +pack abstractions-compatible "$ROOT/src/MinimalLambda.Abstractions/MinimalLambda.Abstractions.csproj" "$CORE_VERSION" +pack core-compatible "$ROOT/src/MinimalLambda/MinimalLambda.csproj" "$CORE_VERSION" +pack durable "$ROOT/src/MinimalLambda.DurableExecution/MinimalLambda.DurableExecution.csproj" "$DURABLE_VERSION" + +python3 - "$FEED" "$OLD_CORE_VERSION" "$CORE_VERSION" "$DURABLE_VERSION" <<'PY' +import sys +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +feed = Path(sys.argv[1]) +old, core, durable = sys.argv[2:] +expected = { + f"MinimalLambda.Abstractions.{old}.nupkg": ("MinimalLambda.Abstractions", old, "abstractions"), + f"MinimalLambda.{old}.nupkg": ("MinimalLambda", old, "core"), + f"MinimalLambda.Abstractions.{core}.nupkg": ("MinimalLambda.Abstractions", core, "abstractions"), + f"MinimalLambda.{core}.nupkg": ("MinimalLambda", core, "core"), + f"MinimalLambda.DurableExecution.{durable}.nupkg": ("MinimalLambda.DurableExecution", durable, "durable"), +} +actual = {path.name for path in feed.glob("*.nupkg") if not path.name.endswith(".snupkg")} +if actual != set(expected): + raise SystemExit(f"nupkg set mismatch: expected {sorted(expected)}, got {sorted(actual)}") + + +def children(element, name): + return [child for child in element if child.tag.rsplit("}", 1)[-1] == name] + + +def child(element, name): + matches = children(element, name) + if len(matches) != 1: + raise AssertionError(f"expected one {name}, found {len(matches)}") + return matches[0] + + +def text(element, name): + return (child(element, name).text or "").strip() + + +def assert_libs(names, package_id, frameworks): + expected_libs = { + f"lib/{tfm}/{package_id}.dll" for tfm in frameworks + } | { + f"lib/{tfm}/{package_id}.xml" for tfm in frameworks + } + actual_libs = {name for name in names if name.startswith("lib/")} + if actual_libs != expected_libs: + raise AssertionError(f"{package_id} lib assets mismatch: {sorted(actual_libs)}") + + +for filename, (package_id, version, kind) in expected.items(): + path = feed / filename + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + if names.count("README.md") != 1: + raise AssertionError(f"{filename}: expected one root README.md") + nuspec_name = f"{package_id}.nuspec" + if names.count(nuspec_name) != 1: + raise AssertionError(f"{filename}: expected one {nuspec_name}") + + generator_assets = [ + name for name in names if Path(name).name == "MinimalLambda.SourceGenerators.dll" + ] + build_assets = [ + name for name in names + if name.split("/", 1)[0].lower().startswith("build") + ] + expected_generator = ["analyzers/dotnet/cs/MinimalLambda.SourceGenerators.dll"] if kind == "core" else [] + expected_build = ["build/MinimalLambda.targets", "buildTransitive/MinimalLambda.targets"] if kind == "core" else [] + if generator_assets != expected_generator or sorted(build_assets) != sorted(expected_build): + raise AssertionError( + f"{filename}: unexpected generator/build assets " + f"{generator_assets}/{build_assets}" + ) + + if kind in {"core", "abstractions"}: + assert_libs(names, package_id, ("net8.0", "net9.0", "net10.0", "net11.0")) + else: + assert_libs(names, package_id, ("net10.0",)) + + root = ET.fromstring(archive.read(nuspec_name)) + metadata = child(root, "metadata") + if text(metadata, "id") != package_id: + raise AssertionError(f"{filename}: package id mismatch") + if text(metadata, "version") != version: + raise AssertionError(f"{filename}: package version mismatch") + if text(metadata, "readme") != "README.md": + raise AssertionError(f"{filename}: package readme mismatch") + + dependency_groups = children(child(metadata, "dependencies"), "group") + if kind == "durable": + tfms = {group.attrib.get("targetFramework") for group in dependency_groups} + if len(dependency_groups) != 1 or tfms != {"net10.0"}: + raise AssertionError(f"durable dependency TFMs mismatch: {tfms}") + for group in dependency_groups: + dependencies = children(group, "dependency") + by_id = {dependency.attrib.get("id"): dependency for dependency in dependencies} + if len(dependencies) != 2 or len(by_id) != 2 or set(by_id) != {"MinimalLambda", "Amazon.Lambda.DurableExecution"}: + raise AssertionError(f"durable dependency set mismatch: {set(by_id)}") + minimal = by_id["MinimalLambda"] + if minimal.attrib.get("version") != core: + raise AssertionError(f"durable minimum core mismatch: {minimal.attrib}") + if minimal.attrib.get("exclude") != "Build,Analyzers": + raise AssertionError(f"durable core dependency exclusion mismatch: {minimal.attrib}") + aws = by_id["Amazon.Lambda.DurableExecution"] + if aws.attrib.get("version") != "1.0.0": + raise AssertionError(f"durable AWS dependency version mismatch: {aws.attrib}") + if aws.attrib.get("exclude") != "Build,Analyzers": + raise AssertionError(f"durable AWS dependency exclusion mismatch: {aws.attrib}") + elif kind == "core": + if {group.attrib.get("targetFramework") for group in dependency_groups} != { + "net8.0", "net9.0", "net10.0", "net11.0" + }: + raise AssertionError(f"{filename}: core dependency TFMs mismatch") + for group in dependency_groups: + abstractions = [ + dependency + for dependency in children(group, "dependency") + if dependency.attrib.get("id") == "MinimalLambda.Abstractions" + ] + if len(abstractions) != 1 or abstractions[0].attrib.get("version") != version: + raise AssertionError(f"{filename}: abstractions dependency mismatch") + if abstractions[0].attrib.get("exclude") != "Build,Analyzers": + raise AssertionError(f"{filename}: abstractions dependency exclusion mismatch") + +if durable != core: + raise AssertionError("durable and core versions must match") +print("Package archives and nuspecs match shared-version contract.") +PY + +cp -R "$FIXTURES/." "$CONSUMERS/" +if grep -R -n ' + + + + + + + + + + + + + + + +''', encoding="utf-8") +PY + +TYPED="$CONSUMERS/TypedConsumer/TypedConsumer.csproj" +TASK="$CONSUMERS/TaskConsumer/TaskConsumer.csproj" +OLD="$CONSUMERS/OldCoreConsumer/OldCoreConsumer.csproj" +AOT="$CONSUMERS/AotConsumer/AotConsumer.csproj" +INVALID="$CONSUMERS/InvalidSignatureConsumer/InvalidSignatureConsumer.csproj" + +restore_consumer typed "$TYPED" "$CORE_VERSION" +restore_consumer task "$TASK" "$CORE_VERSION" +restore_consumer invalid-signature "$INVALID" "$CORE_VERSION" + +python3 - "$NUGET_PACKAGES" "$CORE_VERSION" "$DURABLE_VERSION" \ + "$CONSUMERS/TypedConsumer/obj/project.assets.json" \ + "$CONSUMERS/TaskConsumer/obj/project.assets.json" <<'PY' +import json +import sys +from pathlib import Path + +packages = Path(sys.argv[1]).resolve() +core, durable = sys.argv[2:4] +for filename in sys.argv[4:]: + path = Path(filename) + data = json.loads(path.read_text(encoding="utf-8")) + package_folders = {str(Path(folder.rstrip("/\\")).resolve()) for folder in data["packageFolders"]} + if package_folders != {str(packages)}: + raise SystemExit(f"{path}: package cache is not isolated: {package_folders}") + expected = {f"MinimalLambda/{core}", f"MinimalLambda.DurableExecution/{durable}"} + targets = [value for name, value in data["targets"].items() if name == "net10.0"] + if not targets or not any(expected <= set(target) for target in targets): + raise SystemExit(f"{path}: wrong MinimalLambda versions for net10.0") +print("Positive consumers resolved exact synthetic versions from isolated cache.") +PY + +rm -rf "$CONSUMERS/TypedConsumer/obj/generated" "$CONSUMERS/TaskConsumer/obj/generated" +build_consumer typed "$TYPED" +build_consumer task "$TASK" + +rm -f "$LOGS/build-invalid-signature-net10.0.log" +set +e +dotnet build "$INVALID" \ + --configuration Release \ + --framework net10.0 \ + --no-restore \ + --nologo \ + "/p:CorePackageVersion=$CORE_VERSION" \ + "/p:DurablePackageVersion=$DURABLE_VERSION" \ + >"$LOGS/build-invalid-signature-net10.0.log" 2>&1 +invalid_status=$? +set -e +cat "$LOGS/build-invalid-signature-net10.0.log" +[ "$invalid_status" -ne 0 ] || fail "invalid-signature consumer unexpectedly built" +invalid_ids=$(grep -Eo 'LH[0-9]{4}' "$LOGS/build-invalid-signature-net10.0.log" | sort -u | tr '\n' ' ' | sed 's/ $//') +[ "$invalid_ids" = "LH0007" ] || fail "invalid-signature consumer emitted unexpected generator diagnostics: ${invalid_ids:-none}" +grep -Fq "LH0007" "$LOGS/build-invalid-signature-net10.0.log" || fail "invalid-signature consumer did not emit LH0007" + +rm -f "$LOGS/restore-old-core.log" +set +e +dotnet restore "$OLD" \ + --configfile "$CONFIG" \ + --force \ + --no-cache \ + --nologo \ + --tl:off \ + "/flp:logfile=$LOGS/restore-old-core.log;verbosity=normal" \ + "/p:CorePackageVersion=$OLD_CORE_VERSION" \ + "/p:DurablePackageVersion=$DURABLE_VERSION" \ + >/dev/null 2>&1 +old_status=$? +set -e +cat "$LOGS/restore-old-core.log" +[ "$old_status" -ne 0 ] || fail "old-core restore unexpectedly succeeded" +old_ids=$(grep -Eo 'NU[0-9]{4}' "$LOGS/restore-old-core.log" | sort -u | tr '\n' ' ' | sed 's/ $//') +[ "$old_ids" = "NU1605" ] || fail "old-core restore failed with unexpected NuGet diagnostics: ${old_ids:-none}" +grep -Fq "$OLD_CORE_VERSION" "$LOGS/restore-old-core.log" || fail "old-core restore log lacks old version" +grep -Fq "$CORE_VERSION" "$LOGS/restore-old-core.log" || fail "old-core restore log lacks durable minimum version" + +restore_consumer aot "$AOT" "$CORE_VERSION" --runtime "$RID" +python3 - "$CONSUMERS/AotConsumer/obj/project.assets.json" "$CORE_VERSION" "$DURABLE_VERSION" "$NUGET_PACKAGES" <<'PY' +import json +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +core, durable = sys.argv[2:4] +packages = Path(sys.argv[4]).resolve() +data = json.loads(path.read_text(encoding="utf-8")) +folders = {str(Path(folder.rstrip("/\\")).resolve()) for folder in data["packageFolders"]} +if folders != {str(packages)}: + raise SystemExit(f"AOT package cache is not isolated: {folders}") +expected = {f"MinimalLambda/{core}", f"MinimalLambda.DurableExecution/{durable}"} +if not any(expected <= set(target) for target in data["targets"].values()): + raise SystemExit("AOT consumer did not resolve exact synthetic versions") +print("AOT consumer resolved exact synthetic versions from isolated cache.") +PY + +rm -rf "$CONSUMERS/AotConsumer/obj/generated" +run_logged publish-aot dotnet publish "$AOT" \ + --configuration Release \ + --runtime "$RID" \ + --self-contained true \ + --no-restore \ + --nologo \ + "/p:CorePackageVersion=$CORE_VERSION" \ + "/p:DurablePackageVersion=$DURABLE_VERSION" \ + "/p:UseLdClassicXCodeLinker=false" \ + "/p:EmitCompilerGeneratedFiles=true" \ + "/p:CompilerGeneratedFilesOutputPath=$CONSUMERS/AotConsumer/obj/generated/net10.0" +assert_warning_free "$LOGS/publish-aot.log" +assert_generated_once "$CONSUMERS/AotConsumer" net10.0 + +printf '\nPackage compatibility matrix passed (RID %s).\n' "$RID" diff --git a/scripts/test-release-artifact-validator.py b/scripts/test-release-artifact-validator.py new file mode 100644 index 00000000..e6c7195d --- /dev/null +++ b/scripts/test-release-artifact-validator.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Focused negative tests for release artifact validation.""" + +from __future__ import annotations + +import importlib.util +import tempfile +import zipfile +from pathlib import Path + +SCRIPT = Path(__file__).with_name("validate-release-artifacts.py") +spec = importlib.util.spec_from_file_location("release_validator", SCRIPT) +assert spec and spec.loader +validator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(validator) + +VERSION = "81.2.3-rc.7" +PACKAGE_ID = "MinimalLambda.DurableExecution" + + +def expect_failure(name: str, action, contains: str) -> None: + try: + action() + except ValueError as error: + if contains not in str(error): + raise AssertionError(f"{name}: wrong error: {error}") from error + print(f"passed negative test: {name}: {error}") + return + raise AssertionError(f"{name}: validation unexpectedly passed") + + +def write_template_package(path: Path) -> None: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("content/templates/mlambda-durable/Program.cs", "// deferred") + + +def main() -> None: + if validator.version_from_tag("core", f"v{VERSION}") != VERSION: + raise AssertionError("standard release tag did not produce package version") + expect_failure( + "separate durable release tag", + lambda: validator.version_from_tag("core", f"durable-v{VERSION}"), + "expected v", + ) + + with tempfile.TemporaryDirectory(prefix="minimal-lambda-validator-") as temporary: + template_package = Path(temporary) / "MinimalLambda.Templates.nupkg" + write_template_package(template_package) + expect_failure( + "deferred durable template content", + lambda: validator.validate_templates_content(template_package), + "must not ship deferred mlambda-durable template", + ) + + original_lookup = validator.nuget_version_exists + validator.nuget_version_exists = lambda package_id, version: package_id == PACKAGE_ID + try: + expect_failure( + "existing NuGet version collision", + lambda: validator.validate_no_nuget_collisions({PACKAGE_ID}, VERSION), + "already exists", + ) + finally: + validator.nuget_version_exists = original_lookup + + +if __name__ == "__main__": + main() diff --git a/scripts/validate-release-artifacts.py b/scripts/validate-release-artifacts.py new file mode 100755 index 00000000..9fe9bc5a --- /dev/null +++ b/scripts/validate-release-artifacts.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Validate release tags and NuGet artifact manifests before trusted publishing.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +CORE_PACKAGE_IDS = frozenset( + { + "MinimalLambda", + "MinimalLambda.Abstractions", + "MinimalLambda.DurableExecution", + "MinimalLambda.Envelopes", + "MinimalLambda.Envelopes.Alb", + "MinimalLambda.Envelopes.ApiGateway", + "MinimalLambda.Envelopes.CloudWatchLogs", + "MinimalLambda.Envelopes.Kafka", + "MinimalLambda.Envelopes.Kinesis", + "MinimalLambda.Envelopes.KinesisFirehose", + "MinimalLambda.Envelopes.Sns", + "MinimalLambda.Envelopes.Sqs", + "MinimalLambda.OpenTelemetry", + "MinimalLambda.Templates", + "MinimalLambda.Testing", + } +) +CORE_SYMBOL_PACKAGE_IDS = CORE_PACKAGE_IDS - {"MinimalLambda.Templates"} +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$" +) + + +def fail(message: str) -> None: + raise ValueError(message) + + +def version_from_tag(lane: str, tag: str) -> str: + prefix = "v" + if not tag.startswith(prefix): + fail(f"{lane} lane rejects tag {tag!r}; expected {prefix}") + version = tag.removeprefix(prefix) + if not SEMVER.fullmatch(version): + fail(f"malformed {lane} release tag {tag!r}; expected {prefix}") + return version + + +def local_name(element: ET.Element) -> str: + return element.tag.rsplit("}", 1)[-1] + + +def read_identity(package: Path) -> tuple[str, str, ET.Element]: + try: + with zipfile.ZipFile(package) as archive: + nuspecs = [name for name in archive.namelist() if name.endswith(".nuspec")] + if len(nuspecs) != 1: + fail(f"{package.name}: expected one nuspec, found {len(nuspecs)}") + root = ET.fromstring(archive.read(nuspecs[0])) + except (zipfile.BadZipFile, ET.ParseError) as error: + fail(f"{package.name}: unreadable package metadata: {error}") + + metadata = next((item for item in root.iter() if local_name(item) == "metadata"), None) + if metadata is None: + fail(f"{package.name}: nuspec has no metadata") + package_id = next((item.text for item in metadata if local_name(item) == "id"), None) + version = next((item.text for item in metadata if local_name(item) == "version"), None) + if not package_id or not version: + fail(f"{package.name}: nuspec identity is incomplete") + return package_id, version, root + + +def validate_templates_content(package: Path) -> None: + try: + with zipfile.ZipFile(package) as archive: + durable_template_entries = [ + name for name in archive.namelist() + if "templates/mlambda-durable/" in name + ] + except zipfile.BadZipFile as error: + fail(f"{package.name}: unreadable template package: {error}") + + if durable_template_entries: + fail( + "MinimalLambda.Templates must not ship deferred mlambda-durable template; " + f"found={durable_template_entries}" + ) + + +def nuget_version_exists(package_id: str, version: str) -> bool: + url = ( + "https://api.nuget.org/v3-flatcontainer/" + f"{package_id.lower()}/index.json" + ) + try: + with urllib.request.urlopen(url, timeout=30) as response: + payload = json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return False + raise + versions = payload.get("versions", []) + return version.casefold() in {str(item).casefold() for item in versions} + + +def validate_no_nuget_collisions(package_ids: set[str] | frozenset[str], version: str) -> None: + collisions = [package_id for package_id in sorted(package_ids) if nuget_version_exists(package_id, version)] + if collisions: + fail(f"NuGet package/version already exists for {version}: {', '.join(collisions)}") + print(f"validated NuGet availability: {len(package_ids)} package IDs at {version}") + + +def validate_artifacts( + lane: str, + artifacts: Path, + expected_version: str | None, + preview_run_number: str | None, +) -> tuple[set[str] | frozenset[str], str]: + if not artifacts.is_dir(): + fail(f"artifact directory does not exist: {artifacts}") + + files = sorted(path for path in artifacts.rglob("*") if path.is_file()) + unexpected_files = [path.name for path in files if not path.name.endswith((".nupkg", ".snupkg"))] + if unexpected_files: + fail(f"unexpected artifact files: {', '.join(unexpected_files)}") + + primary = [path for path in files if path.name.endswith(".nupkg") and not path.name.endswith(".snupkg")] + symbols = [path for path in files if path.name.endswith(".snupkg")] + identities: dict[str, tuple[str, Path, ET.Element]] = {} + for package in primary: + package_id, version, nuspec = read_identity(package) + if package_id in identities: + fail(f"duplicate package ID: {package_id}") + identities[package_id] = (version, package, nuspec) + + expected_ids = CORE_PACKAGE_IDS + actual_ids = set(identities) + if actual_ids != expected_ids: + missing = sorted(expected_ids - actual_ids) + extra = sorted(actual_ids - expected_ids) + fail(f"package ID collision/omission; missing={missing}, unexpected={extra}") + + versions = {identity[0] for identity in identities.values()} + if len(versions) != 1: + fail(f"package versions differ: {sorted(versions)}") + actual_version = next(iter(versions)) + if expected_version is not None and actual_version != expected_version: + fail(f"artifact version {actual_version!r} does not match expected version {expected_version!r}") + if lane == "core-preview": + if preview_run_number is None: + fail("core-preview validation requires --preview-run-number") + if not re.fullmatch(rf"\d+\.\d+\.\d+-preview\.{re.escape(preview_run_number)}", actual_version): + fail(f"preview version {actual_version!r} does not end in -preview.{preview_run_number}") + + expected_primary_names = {f"{package_id}.{actual_version}.nupkg" for package_id in expected_ids} + actual_primary_names = {path.name for path in primary} + if actual_primary_names != expected_primary_names: + fail(f"primary filenames differ; expected={sorted(expected_primary_names)}, actual={sorted(actual_primary_names)}") + + expected_symbol_ids = CORE_SYMBOL_PACKAGE_IDS + symbol_identities: dict[str, tuple[str, Path]] = {} + for package in symbols: + package_id, version, _ = read_identity(package) + if package_id in symbol_identities: + fail(f"duplicate symbol package ID: {package_id}") + symbol_identities[package_id] = (version, package) + if set(symbol_identities) != expected_symbol_ids: + missing = sorted(expected_symbol_ids - set(symbol_identities)) + extra = sorted(set(symbol_identities) - expected_symbol_ids) + fail(f"symbol package ID collision/omission; missing={missing}, unexpected={extra}") + for package_id, (version, path) in symbol_identities.items(): + if version != actual_version: + fail(f"{path.name}: symbol version {version!r} does not match {actual_version!r}") + expected_name = f"{package_id}.{actual_version}.snupkg" + if path.name != expected_name: + fail(f"symbol filename differs; expected={expected_name!r}, actual={path.name!r}") + + validate_templates_content(identities["MinimalLambda.Templates"][1]) + + manifest = ", ".join(f"{package_id}@{identities[package_id][0]}" for package_id in sorted(identities)) + print(f"validated {lane} manifest: {manifest}; symbols={len(symbols)}") + return expected_ids, actual_version + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--lane", choices=("core", "core-preview"), required=True) + parser.add_argument("--tag") + parser.add_argument("--artifacts", type=Path) + parser.add_argument("--tag-only", action="store_true") + parser.add_argument("--preview-run-number") + parser.add_argument("--expected-version") + parser.add_argument("--check-nuget", action="store_true") + args = parser.parse_args() + + try: + expected_version = args.expected_version + if args.lane == "core": + if not args.tag: + fail(f"{args.lane} validation requires --tag") + tagged_version = version_from_tag(args.lane, args.tag) + if expected_version is not None and expected_version != tagged_version: + fail(f"explicit version {expected_version!r} does not match tag version {tagged_version!r}") + expected_version = tagged_version + elif args.tag: + fail("core-preview lane does not accept a release tag") + + if args.tag_only: + if args.lane == "core-preview": + fail("--tag-only is invalid for core-preview") + print(f"validated {args.lane} tag: {args.tag} -> {expected_version}") + return 0 + if args.artifacts is None: + fail("artifact validation requires --artifacts") + package_ids, actual_version = validate_artifacts( + args.lane, + args.artifacts, + expected_version, + args.preview_run_number, + ) + if args.check_nuget: + validate_no_nuget_collisions(package_ids, actual_version) + except (OSError, ValueError, urllib.error.URLError, zipfile.BadZipFile, ET.ParseError) as error: + print(f"release validation failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills-lock.json b/skills-lock.json index f76e4538..09d0b070 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,48 @@ { "version": 1, "skills": { + "cavecrew": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/cavecrew/SKILL.md", + "computedHash": "c5527c994fbd4c22b36714e3b124a0f167a533d114ab164fb1d35e2123533917" + }, + "caveman": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman/SKILL.md", + "computedHash": "59e1fe0d3eeb4189ee5c467efde567672e5cacb41f157c477a6152ca907d44ea" + }, + "caveman-commit": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-commit/SKILL.md", + "computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00" + }, + "caveman-compress": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-compress/SKILL.md", + "computedHash": "52f2301832b376a765b0ed02445c8bf05874b624052bf9a9c3861d9c3dfcee4b" + }, + "caveman-help": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-help/SKILL.md", + "computedHash": "c76fd4aa86ad557eee62aacbd4b9dd46499fe3913910e7d596d20d094296b984" + }, + "caveman-review": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-review/SKILL.md", + "computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1" + }, + "caveman-stats": { + "source": "JuliusBrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman-stats/SKILL.md", + "computedHash": "57c7db449641379e2afd1389fdb17cec8f33d9c8e25b1d28093937b4431895e8" + }, "git-workflow": { "source": "LayeredCraft/skills", "sourceType": "github", diff --git a/skills/minimal-lambda/SKILL.md b/skills/minimal-lambda/SKILL.md index 6cbca853..f15560fa 100644 --- a/skills/minimal-lambda/SKILL.md +++ b/skills/minimal-lambda/SKILL.md @@ -1,6 +1,6 @@ --- name: minimal-lambda -description: Work effectively with MinimalLambda, the Lambda-first .NET hosting framework in this repo and in client projects. Use this skill whenever the user asks to build, debug, migrate, test, document, scaffold, template, package, or review code using MinimalLambda APIs, `dotnet new mlambda` templates, envelopes, middleware, lifecycle hooks, source-generated handlers, AOT/trimming, OpenTelemetry, or MinimalLambda.Testing. Trigger even when the user only mentions AWS Lambda with Minimal API-style .NET patterns, MapHandler, FromEvent, LambdaApplication, MinimalLambda package names, MinimalLambda.Templates, or adding a Lambda to an existing solution. +description: Work effectively with MinimalLambda, the Lambda-first .NET hosting framework in this repo and in client projects. Use this skill whenever the user asks to build, debug, migrate, test, document, scaffold, template, package, or review code using MinimalLambda APIs, `dotnet new mlambda` templates, envelopes, middleware, lifecycle hooks, source-generated handlers, AOT/trimming, OpenTelemetry, or MinimalLambda.Testing. Trigger even when the user only mentions AWS Lambda with Minimal API-style .NET patterns, MapHandler, FromEvent, LambdaApplication, MinimalLambda package names, MinimalLambda.Templates, Durable Execution, MapDurableHandler, IDurableContext, or adding a Lambda to an existing solution. --- # MinimalLambda skill @@ -12,6 +12,7 @@ Use this skill to give agents enough MinimalLambda project context without loadi 1. Identify task area: - client project setup/package/config/template usage or `dotnet new mlambda` → read `references/client-project-setup.md` - app setup/handler/DI/lifecycle → read `references/core-hosting.md` and `references/best-practices.md` + - AWS Lambda Durable Execution, `MapDurableHandler`, `IDurableContext`, replay, or checkpoints → read `references/durable-execution.md` before general handler/cancellation advice - handler shape/unit-testable handlers → read `references/patterns/handler-patterns.md` - middleware/features/context → read `references/core-hosting.md` and `references/patterns/middleware-patterns.md` - lifecycle hooks (`OnInit`/`OnShutdown`) → read `references/core-hosting.md` and @@ -44,8 +45,9 @@ await lambda.RunAsync(); Core pieces: - `LambdaApplication.CreateBuilder()` creates standard .NET host/config/DI defaults. -- `MapHandler(...)` registers one Lambda handler. Source generator intercepts it at compile time. -- `[FromEvent]` marks deserialized event payload. At most one payload parameter. +- `MapHandler(...)` registers one ordinary Lambda handler. Source generator intercepts it at compile time. +- `MapDurableHandler(...)` registers one durable workflow with stricter signature, cancellation, replay, middleware, and serializer rules; read `references/durable-execution.md`. +- `[FromEvent]` marks deserialized event payload. Ordinary handlers allow at most one; durable handlers allow zero or one. `IDurableContext` is optional for durable handlers. - Other handler parameters resolve from DI/context/keyed services/cancellation token. - Middleware wraps invocation pipeline via inline `UseMiddleware(...)` or class `UseMiddleware()`. - `OnInit(...)` runs once during cold start; `OnShutdown(...)` runs during teardown. @@ -71,7 +73,7 @@ Read `references/best-practices.md` before giving architectural advice. - Allow simple inline logic in `Program.cs` when logic is tiny and Lambda remains easy to read. - Extract middleware classes only when middleware is complex, reusable, stateful, or worth testing separately. -- Prefer `CancellationToken` in async handlers and downstream calls. +- Prefer `CancellationToken` in ordinary async handlers and downstream calls. A durable root handler may declare one only for physical invocation cancellation; use SDK-provided durable-operation callback tokens for durable steps. - Prefer scoped services for per-invocation state; singleton for reusable clients/caches. - Avoid storing scoped services in singletons. - Prefer typed records/responses/envelopes over anonymous response contracts. @@ -84,7 +86,8 @@ Read `references/best-practices.md` before giving architectural advice. Before final answer or patch: -- Does code compile with source generation? `MapHandler` signature has 0 or 1 `[FromEvent]`. +- Does code compile with source generation? `MapHandler` has 0 or 1 `[FromEvent]`; `MapDurableHandler` has 0 or 1 `[FromEvent]`, 0 or 1 `IDurableContext`, and returns `Task`/`Task`. +- Does durable code treat optional root `CancellationToken` as physical invocation cancellation, use SDK operation-callback tokens for durable steps, register explicit serializer roots, and obey replay-safe middleware rules? - Does runtime call only one handler mapping path? - Are packages matched (`MinimalLambda.Testing` same version as `MinimalLambda`)? - Are envelope package/type and AWS trigger type aligned? diff --git a/skills/minimal-lambda/references/client-project-setup.md b/skills/minimal-lambda/references/client-project-setup.md index 92ace57d..f4abc1fc 100644 --- a/skills/minimal-lambda/references/client-project-setup.md +++ b/skills/minimal-lambda/references/client-project-setup.md @@ -12,7 +12,7 @@ dotnet new mlambda -n MyLambda dotnet new mlambda-aot -n MyAotLambda ``` -Templates create `src/` and `test/` projects, include `aws-lambda-tools-defaults.json`, use inline handlers, and test through `MinimalLambda.Testing`. +Templates create source and test projects, include `aws-lambda-tools-defaults.json`, use inline handlers, and test through `MinimalLambda.Testing`. When adding a function to an existing repository or solution folder, create the solution first if needed and generate into the current directory with `-o .`: diff --git a/skills/minimal-lambda/references/durable-execution.md b/skills/minimal-lambda/references/durable-execution.md new file mode 100644 index 00000000..776ace92 --- /dev/null +++ b/skills/minimal-lambda/references/durable-execution.md @@ -0,0 +1,115 @@ +# Durable Execution + +Read this before applying ordinary handler, cancellation, middleware, serializer, or testing advice to +`MapDurableHandler`. + +## Packages and targets + +Reference all three packages directly: + +```bash +dotnet add package MinimalLambda +dotnet add package MinimalLambda.DurableExecution +dotnet add package Amazon.Lambda.DurableExecution --version 1.0.0 +``` + +Direct `MinimalLambda` reference activates MinimalLambda source generator. Direct AWS package +reference activates DE001-DE004 analyzers; runtime dependency otherwise arrives transitively. +Initial supported target is .NET 10. Treat NativeAOT durable deployment as experimental +until project documentation records cloud evidence. + +## Handler contract + +Durable handler must have an exact `Task` or `Task` return. It may have: + +- zero or one `[FromEvent] TInput` workflow input; +- zero or one exact AWS `IDurableContext`; +- optional `ILambdaContext`, `ILambdaInvocationContext`, ordinary DI, keyed DI, or optional DI + parameters. + +Parameter order does not matter. Unannotated parameters are DI; input is never inferred. + +```csharp +lambda.MapDurableHandler(async ( + [FromEvent] OrderRequest request, + IDurableContext durable, + IOrderService orders) => +{ + return await durable.StepAsync( + (_, cancellationToken) => orders.ProcessAsync(request, cancellationToken)); +}); +``` + +Do not use synchronous, `ValueTask`, custom-awaitable, raw `Stream`, or outer durable envelope forms +in high-level durable handler. Use ordinary `MapHandler` plus `DurableFunction.WrapAsync` when raw +envelope or explicit-client control is required. + +## Cancellation + +A durable root handler can explicitly declare `CancellationToken`; MinimalLambda binds it to +`ILambdaInvocationContext.CancellationToken`. It represents physical Lambda-invocation cancellation, so +near-timeout cancellation can fault root workflow and become terminal `FAILED`; handler owns resulting +failure/retry consequences. It is not an AWS durable-operation token. + +Use cancellation token supplied by durable operation callback (`StepAsync`, callback, map/parallel, +child workflow, and related APIs) for durable step work. `WrapAsync` exposes no lifecycle-token hook. + +## Context and DI + +AWS durable context carries MinimalLambda invocation context as `LambdaContext`. Durable envelopes are +serialized directly through the configured Lambda serializer; they are not available through +`IEventFeature` or `IResponseFeature`. + +```csharp +ILambdaInvocationContext invocation = durable.GetInvocationContext(); +``` + +Returned object is exact MinimalLambda context. Prefer injecting `ILambdaInvocationContext` directly +when handler needs it. Keep context objects at Lambda edge; pass domain values into services. + +DI scope is per physical invocation and recreated on replay. Do not assume scoped service state +survives logical execution. + +## Replay and middleware + +Middleware wraps each physical Lambda invocation and runs again on replay. It cannot depend on raw +checkpoint transport or typed workflow input. + +Durable-compatible middleware should call and await `next` once, preserve exceptions, avoid response +short-circuits, and limit work to replay-safe observation, logging, metrics, or invocation-scoped +behavior. MinimalLambda does not enforce these rules; skipped or repeated `next` and swallowed failures +can change AWS-visible behavior. Do not reuse response caching, ordinary response fabrication, or +exception-to-response translation middleware unchanged. AWS owns replay, checkpoints, suspension, +waits, and durable status mapping. + +## Serialization and AOT + +Same registered `ILambdaSerializer` handles MinimalLambda outer envelopes and AWS inner durable +values. For source-generated JSON, explicitly declare at least: + +- `DurableExecutionInvocationInput`; +- `DurableExecutionInvocationOutput`; +- workflow `TInput`; +- workflow `TOutput` for `Task`. + +Also explicitly register payload/result/state types used inside steps, callbacks, invokes, child +workflows, waits, maps, and parallel branches. Generator cannot discover types hidden in operation +bodies or referenced libraries. + +## Testing split + +Use MinimalLambda host/integration tests to prove generated adapter, middleware, DI, exact serializer +identity, and outer stream roundtrip. Use +`Amazon.Lambda.DurableExecution.Testing` to prove workflow operations, suspension, and replay. Local +runner does not prove IAM, deployment, managed runtime, or cloud service behavior. + +## Low-level escape hatch + +```csharp +lambda.MapHandler( + ([FromEvent] DurableExecutionInvocationInput envelope, ILambdaInvocationContext invocation) => + DurableFunction.WrapAsync(Workflow, envelope, invocation)); +``` + +Use this only for raw envelope, explicit AWS client, protocol diagnostics, or SDK capabilities not yet +represented by high-level adapter. diff --git a/src/AotCompatibility.TestApp/AotCompatibility.TestApp.csproj b/src/AotCompatibility.TestApp/AotCompatibility.TestApp.csproj index 544db0c0..39ba9afc 100644 --- a/src/AotCompatibility.TestApp/AotCompatibility.TestApp.csproj +++ b/src/AotCompatibility.TestApp/AotCompatibility.TestApp.csproj @@ -12,30 +12,14 @@ false - - - - - - - - - - - - + + + + + - - - - - - - - + \ No newline at end of file diff --git a/src/AotCompatibility.TestApp/Program.cs b/src/AotCompatibility.TestApp/Program.cs index 139ec4e7..b1ed2f2c 100644 --- a/src/AotCompatibility.TestApp/Program.cs +++ b/src/AotCompatibility.TestApp/Program.cs @@ -1,3 +1,3 @@ -// See https://aka.ms/new-console-template for more information +// This executable exists only to root MinimalLambda assemblies for Native AOT publish analysis. -Console.WriteLine("Hello, World!"); +return; diff --git a/src/MinimalLambda.DurableExecution/DurableContextExtensions.cs b/src/MinimalLambda.DurableExecution/DurableContextExtensions.cs new file mode 100644 index 00000000..f4e87c5b --- /dev/null +++ b/src/MinimalLambda.DurableExecution/DurableContextExtensions.cs @@ -0,0 +1,44 @@ +using Amazon.Lambda.DurableExecution; + +namespace MinimalLambda.DurableExecution; + +/// +/// Provides MinimalLambda invocation context access for AWS Lambda durable execution contexts. +/// +public static class DurableContextExtensions +{ + extension(IDurableContext context) + { + /// + /// Gets the MinimalLambda invocation context associated with this durable execution. + /// + /// + /// MinimalLambda supplies its physical invocation context as the durable context's Lambda context + /// when adapting a durable handler. This method preserves that exact context instance. AWS owns + /// replay and creates a new physical Lambda invocation for a replay, so this context and its + /// dependency-injection scope must not be treated as logical workflow state. Its cancellation token + /// represents the physical invocation; using it to cancel the root workflow can produce a terminal + /// durable failure. Prefer cancellation tokens supplied to durable operation callbacks. + /// + /// + /// Exact instance stored in + /// . + /// + /// + /// Thrown when the durable context is . + /// + /// + /// Thrown when is not a MinimalLambda + /// . + /// + /// + public ILambdaInvocationContext GetInvocationContext() + { + ArgumentNullException.ThrowIfNull(context); + + return context.LambdaContext as ILambdaInvocationContext + ?? throw new InvalidOperationException( + "MinimalLambda invocation context is not available on this durable context."); + } + } +} diff --git a/src/MinimalLambda.DurableExecution/MapDurableHandlerLambdaApplicationExtensions.cs b/src/MinimalLambda.DurableExecution/MapDurableHandlerLambdaApplicationExtensions.cs new file mode 100644 index 00000000..6f6036f2 --- /dev/null +++ b/src/MinimalLambda.DurableExecution/MapDurableHandlerLambdaApplicationExtensions.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace MinimalLambda.Builder; + +/// +/// Provides durable handler registration extensions for +/// . +/// +[ExcludeFromCodeCoverage] +public static class MapDurableHandlerLambdaApplicationExtensions +{ + extension(ILambdaInvocationBuilder application) + { + /// + /// Registers an AWS Lambda Durable Execution handler with automatic dependency injection and + /// serialization. + /// + /// + /// + /// Source generation creates wiring code that resolves handler dependencies and adapts the + /// handler to the AWS Lambda Durable Execution protocol. A handler can optionally declare a + /// workflow input and an AWS IDurableContext. + /// It returns or . Invocation contexts and + /// dependency-injection services can be additional parameters. + /// + /// + /// Invocation contexts, dependency-injection scopes, and middleware belong to one physical + /// Lambda invocation. Middleware runs again when AWS replays a workflow. AWS owns durable + /// context creation, checkpoints, replay, suspension, and durable status mapping; + /// MinimalLambda owns physical invocation hosting, dependency injection, middleware, outer + /// envelope serialization, and root handler cancellation tokens supplied by the physical + /// invocation. Durable operation callbacks receive distinct SDK cancellation tokens for step work. + /// + /// + /// A compile-time interceptor must replace this call; invoking this fallback directly at + /// runtime is unsupported and throws . + /// + /// + /// + /// Durable handler delegate that will be intercepted and replaced at compile time by the source + /// generator. + /// + /// + /// Current instance for method chaining. + /// + /// + /// Thrown if the call was not replaced by source-generated code at compile time. + /// + /// + /// + public ILambdaInvocationBuilder MapDurableHandler(Delegate handler) + { + Debug.Fail("This method should have been intercepted at compile time!"); + throw new InvalidOperationException("This method is replaced at compile time."); + } + } +} diff --git a/src/MinimalLambda.DurableExecution/MinimalLambda.DurableExecution.csproj b/src/MinimalLambda.DurableExecution/MinimalLambda.DurableExecution.csproj new file mode 100644 index 00000000..b7ad2793 --- /dev/null +++ b/src/MinimalLambda.DurableExecution/MinimalLambda.DurableExecution.csproj @@ -0,0 +1,26 @@ + + + net10.0 + preview + enable + enable + true + true + true + + MinimalLambda.DurableExecution + MinimalLambda.DurableExecution + MinimalLambda.DurableExecution + AWS Lambda Durable Execution integration for MinimalLambda + README.md + + + + + + + + + + + diff --git a/src/MinimalLambda.DurableExecution/README.md b/src/MinimalLambda.DurableExecution/README.md new file mode 100644 index 00000000..0f54b538 --- /dev/null +++ b/src/MinimalLambda.DurableExecution/README.md @@ -0,0 +1,206 @@ +# MinimalLambda.DurableExecution + +Typed AWS Lambda Durable Execution handlers for MinimalLambda. + +> This package is experimental. APIs may change before stable release. + +## Compatibility and installation + +Package ships assets for exactly `net10.0`. NuGet may select a compatible asset for other TFMs, but +those combinations are not supported. + +`MinimalLambda.DurableExecution` releases with `MinimalLambda`; use matching package versions. + +Reference all three packages directly: + +```bash +dotnet add package MinimalLambda --version 2.6.0-beta.2 +dotnet add package MinimalLambda.DurableExecution --version 2.6.0-beta.2 +dotnet add package Amazon.Lambda.DurableExecution --version 1.0.0 +``` + +Direct `MinimalLambda` reference supplies one source generator for both `MapHandler` and +`MapDurableHandler`; no durable generator package exists. Wrapper already depends on AWS runtime, +but NuGet does not flow analyzer assets through transitive dependencies. Direct AWS reference enables +its DE001-DE004 analyzers. + +## Complete typed handler + +```csharp +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); + +lambda.MapDurableHandler(async ( + [FromEvent] OrderRequest request, + IDurableContext durable, + ILambdaInvocationContext invocation, + [FromServices] IOrderService orders) => +{ + var step = await durable.StepAsync( + (_, cancellationToken) => + orders.ProcessAsync(request.OrderId, cancellationToken), + name: "process-order"); + + return new OrderResult( + step.Message, + durable.ExecutionContext.DurableExecutionArn, + invocation.AwsRequestId); +}); + +await lambda.RunAsync(); + +internal sealed record OrderRequest(string OrderId); +internal sealed record OrderResult(string Message, string ExecutionArn, string AwsRequestId); +internal sealed record ProcessOrderStepResult(string Message); + +internal interface IOrderService +{ + Task ProcessAsync( + string orderId, + CancellationToken cancellationToken); +} + +internal sealed class OrderService : IOrderService +{ + public Task ProcessAsync( + string orderId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ProcessOrderStepResult($"Order {orderId} processed")); + } +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(OrderRequest))] +[JsonSerializable(typeof(OrderResult))] +[JsonSerializable(typeof(ProcessOrderStepResult))] +internal partial class DurableJsonContext : JsonSerializerContext; +``` + +Register the serializer roots required by your configured serializer, including the outer input/output +envelopes and every payload, result, or state type used by steps, callbacks, invokes, +child workflows, waits, maps, and parallel branches; `ProcessOrderStepResult` is one representative +step root. Generator cannot discover types hidden inside operation bodies or referenced libraries. +Same registered `ILambdaSerializer` handles MinimalLambda outer envelopes and AWS inner values. +Step bodies can be retried, so keep external side effects idempotent; checkpointed results do not +provide exactly-once execution. + +## Handler, cancellation, and context contract + +Durable handlers return `Task` or `Task`. `[FromEvent] TInput` and AWS `IDurableContext` +are optional; a handler may use either, both, or neither. Each can occur at most once. Parameter order is unrestricted. Other +parameters are resolved using normal handler binding rules. `ref`, `in`, and `out` parameters are +unsupported; handler parameter and output types must be accessible from generated code and closed over +all type parameters. The generator does not validate serializer roots or impose a durable-specific +payload-shape policy. + +A handler that explicitly declares `CancellationToken` receives +`ILambdaInvocationContext.CancellationToken`. It represents physical Lambda-invocation cancellation; +own resulting failure/retry consequences and do not treat it as durable-operation cancellation. AWS +operation callbacks already receive SDK-linked cancellation tokens. + +Inject `ILambdaInvocationContext` as above, or recover exact physical invocation context carried by +AWS durable context: + +```csharp +using MinimalLambda.DurableExecution; + +ILambdaInvocationContext invocation = durable.GetInvocationContext(); +``` + +DI scope and invocation context belong to one physical Lambda invocation. Replay creates another +physical invocation and scope; never keep scoped state as logical workflow state. + +## Replay, middleware, and ownership + +MinimalLambda directly serializes hidden outer envelopes through its configured Lambda serializer; +durable envelopes are not available through `IEventFeature` or `IResponseFeature`. It also owns +raw-stream hosting, middleware, physical invocation context and DI scope. AWS runtime owns workflow +payload handling, checkpoints, replay, suspension, waits, `IDurableContext`, and durable status/result +mapping. + +Middleware wraps each physical Lambda invocation, so it runs again during replay. Durable middleware +should call and await `next` once, preserve exceptions, and avoid ordinary-response short circuits. +MinimalLambda does not enforce those rules: skipped or repeated `next` and swallowed failures can +change AWS-visible behavior. Replay-safe observation, logging, metrics, and invocation-scoped behavior +fit; response caches, response fabrication, and exception-to-response translation do not work unchanged. + +`MapDurableHandler` requires MinimalLambda compile-time interception. If source generation does not +replace mapping call, runtime fallback throws `InvalidOperationException` instead of running handler. + +## Deployment + +`MapDurableHandler` supplies host integration only; deployment must configure Lambda Durable Execution, +its IAM policy, and a qualified function target. Follow [sample package recipe](https://github.com/LayeredCraft/minimal-lambda/tree/main/examples/MinimalLambda.Example.DurableExecution#package-for-deployment) to produce a Lambda ZIP and deploy that ZIP with Amazon.Lambda.Tools durable defaults. For a custom role, grant equivalent durable execution permissions plus application-specific permissions. Versions, aliases, and `$LATEST` are supported qualified targets; prefer immutable versions or aliases for stable routing. + +No managed-service deployment was run for this package. Consult current [AWS Durable Execution deployment documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-getting-started.html) before production use. + +## Testing + +Use MinimalLambda host/integration tests for generated adapter, middleware, DI, serializer identity, +and outer stream roundtrip. Use `Amazon.Lambda.DurableExecution.Testing` for +workflow operations, suspension, waits, and replay. Local tests do not prove IAM, deployment, +managed-runtime behavior, or cloud service integration. + +## Raw-envelope and custom-client escape hatch + +Use low-level `MapHandler` when raw envelope access, custom `IAmazonLambda`, protocol diagnostics, or +new AWS overloads are required. Register custom client before `builder.Build()`: + +```csharp +using Amazon.Lambda; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); +lambda.MapHandler( + ([FromEvent] DurableExecutionInvocationInput envelope, + ILambdaInvocationContext invocation, + [FromServices] IAmazonLambda client) => + DurableFunction.WrapAsync( + LowLevelWorkflowAsync, + envelope, + invocation, + client)); + +await lambda.RunAsync(); + +static Task LowLevelWorkflowAsync( + OrderRequest request, + IDurableContext durable) => + Task.FromResult( + new OrderResult( + $"Order {request.OrderId} processed", + durable.ExecutionContext.DurableExecutionArn, + durable.LambdaContext.AwsRequestId)); +``` + +Replace high-level mapping; do not register both paths. Low-level workflow has AWS signature +`Func>`. Keep same explicit serializer roots. + +## NativeAOT + +Durable NativeAOT support remains experimental. Ordinary restore/build/pack does not compile native +code; validate with publish, for example +`dotnet publish -c Release -r linux-x64 -p:PublishAot=true`. Successful local publish still does not +prove cloud deployment or managed durable behavior. diff --git a/src/MinimalLambda.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/MinimalLambda.SourceGenerators/AnalyzerReleases.Unshipped.md index e69de29b..db90d506 100644 --- a/src/MinimalLambda.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/MinimalLambda.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,5 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +LH0007 | MinimalLambda.Usage | Error | Durable handler signature diagnostics diff --git a/src/MinimalLambda.SourceGenerators/Diagnostics/DiagnosticInfo.cs b/src/MinimalLambda.SourceGenerators/Diagnostics/DiagnosticInfo.cs index b184a51c..c1ace879 100644 --- a/src/MinimalLambda.SourceGenerators/Diagnostics/DiagnosticInfo.cs +++ b/src/MinimalLambda.SourceGenerators/Diagnostics/DiagnosticInfo.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using LayeredCraft.SourceGeneratorTools.Utilities; using Microsoft.CodeAnalysis; diff --git a/src/MinimalLambda.SourceGenerators/Diagnostics/Diagnostics.cs b/src/MinimalLambda.SourceGenerators/Diagnostics/Diagnostics.cs index 462e39ca..dd7fb923 100644 --- a/src/MinimalLambda.SourceGenerators/Diagnostics/Diagnostics.cs +++ b/src/MinimalLambda.SourceGenerators/Diagnostics/Diagnostics.cs @@ -1,5 +1,7 @@ using Microsoft.CodeAnalysis; +#pragma warning disable RS1032 // Diagnostic text is fixed by ADR-004. + namespace MinimalLambda.SourceGenerators; internal static class Diagnostics @@ -47,4 +49,12 @@ internal static class Diagnostics ConfigurationCategory, DiagnosticSeverity.Error, true); + + internal static readonly DiagnosticDescriptor UnsupportedDurableHandlerSignature = new( + "LH0007", + "Unsupported durable handler signature", + "Durable handler signature component '{0}' is not supported; use Task or Task, value parameters, and types accessible to generated adapter code.", + UsageCategory, + DiagnosticSeverity.Error, + true); } diff --git a/src/MinimalLambda.SourceGenerators/Emitters/DurableHandlerEmitter.cs b/src/MinimalLambda.SourceGenerators/Emitters/DurableHandlerEmitter.cs new file mode 100644 index 00000000..d2e50f7c --- /dev/null +++ b/src/MinimalLambda.SourceGenerators/Emitters/DurableHandlerEmitter.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using MinimalLambda.SourceGenerators.Models; + +namespace MinimalLambda.SourceGenerators.Emitters; + +internal static class DurableHandlerEmitter +{ + private const string DurableHandlerTemplateFile = "Templates/MapDurableHandler.scriban"; + + internal static void Emit( + SourceProductionContext context, + ImmutableArray infos) + { + if (infos.Length == 0) + return; + + var sortedInfos = infos + .OrderBy(static info => info.TreeOrdinal) + .ThenBy(static info => info.MapCallLocation.TextSpan.Start) + .ToImmutableArray(); + var code = TemplateHelper.Render( + DurableHandlerTemplateFile, + new { TemplateHelper.GeneratedCodeAttribute, MapDurableHandlerCalls = sortedInfos }); + + context.AddSource("MinimalLambda.DurableHandlers.g.cs", code); + } +} diff --git a/src/MinimalLambda.SourceGenerators/GeneratorContext.cs b/src/MinimalLambda.SourceGenerators/GeneratorContext.cs index bed00271..3931e6b1 100644 --- a/src/MinimalLambda.SourceGenerators/GeneratorContext.cs +++ b/src/MinimalLambda.SourceGenerators/GeneratorContext.cs @@ -10,14 +10,19 @@ internal class GeneratorContext internal SemanticModel SemanticModel { get; } internal SyntaxNode Node { get; } - internal GeneratorContext(GeneratorSyntaxContext context, CancellationToken cancellationToken) + internal GeneratorContext(GeneratorSyntaxContext context, CancellationToken cancellationToken) : + this(context.Node, context.SemanticModel, cancellationToken) { } + + internal GeneratorContext( + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken) { - Node = context.Node; - SemanticModel = context.SemanticModel; + Node = node; + SemanticModel = semanticModel; CancellationToken = cancellationToken; WellKnownTypes = - SourceGenerators.WellKnownTypes.WellKnownTypes.GetOrCreate( - context.SemanticModel.Compilation); + SourceGenerators.WellKnownTypes.WellKnownTypes.GetOrCreate(semanticModel.Compilation); } } diff --git a/src/MinimalLambda.SourceGenerators/MinimalLambdaGenerator.cs b/src/MinimalLambda.SourceGenerators/MinimalLambdaGenerator.cs index e2aab07e..47b7c5a5 100644 --- a/src/MinimalLambda.SourceGenerators/MinimalLambdaGenerator.cs +++ b/src/MinimalLambda.SourceGenerators/MinimalLambdaGenerator.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using MinimalLambda.SourceGenerators.Emitters; @@ -49,6 +50,12 @@ compilation is CSharpCompilation .Select(static (c, _) => (MapHandlerMethodInfo)c) .Collect(); + var durableHandlerModels = registrationCalls + .Where(static c => c is DurableMethodInfo) + .Select(static (c, _) => (DurableMethodInfo)c); + + var validDurableHandlerCalls = durableHandlerModels.WhereNoErrors().Collect(); + var onInitHandlerCalls = registrationCalls .WhereNoErrors() .Where(static c => c is LifecycleMethodInfo { MethodType: MethodType.OnInit }) @@ -64,14 +71,20 @@ compilation is CSharpCompilation var middlewareTCallsCollected = useMiddlewareTCalls.WhereNoErrors().Collect(); context.RegisterSourceOutput( - registrationCalls, + registrationCalls.Where(static call => call is not DurableMethodInfo), (ctx, call) => call.DiagnosticInfos.ForEach(d => d.ReportDiagnostic(ctx))); + context.RegisterSourceOutput( + durableHandlerModels, + (ctx, call) => + call.DiagnosticInfos.ForEach(diagnostic => diagnostic.ReportDiagnostic(ctx))); + context.RegisterSourceOutput( useMiddlewareTCalls, (ctx, call) => call.DiagnosticInfos.ForEach(d => d.ReportDiagnostic(ctx))); context.RegisterSourceOutput(invocationHandlerCalls, InvocationHandlerEmitter.Emit); + context.RegisterSourceOutput(validDurableHandlerCalls, DurableHandlerEmitter.Emit); context.RegisterSourceOutput(onInitHandlerCalls, LifecycleHandlerEmitter.Emit); context.RegisterSourceOutput(onShutdownHandlerCalls, LifecycleHandlerEmitter.Emit); context.RegisterSourceOutput(middlewareTCallsCollected, MiddlewareClassEmitter.Emit); diff --git a/src/MinimalLambda.SourceGenerators/Models/Handlers/DurableMethodInfo.cs b/src/MinimalLambda.SourceGenerators/Models/Handlers/DurableMethodInfo.cs new file mode 100644 index 00000000..5b107b4d --- /dev/null +++ b/src/MinimalLambda.SourceGenerators/Models/Handlers/DurableMethodInfo.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using LayeredCraft.SourceGeneratorTools.Types; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using MinimalLambda.SourceGenerators.Extensions; +using WellKnownType = MinimalLambda.SourceGenerators.WellKnownTypes.WellKnownTypeData.WellKnownType; + +namespace MinimalLambda.SourceGenerators.Models; + +internal sealed record DurableHandlerParameterInfo( + int Ordinal, + string Name, + string GloballyQualifiedType, + string Assignment, + ParameterSource Source, + bool IsEvent, + bool IsFromKeyedService, + string? KeyedServicesKey, + LocationInfo? LocationInfo); + +internal sealed record DurableMethodInfo( + string InterceptableLocationAttribute, + string DelegateCastType, + string? HandlerDelegateType, + EquatableArray ParameterAssignments, + string? InputType, + bool HasOutput, + string? OutputType, + bool HasAnyFromKeyedServices, + LocationInfo MapCallLocation, + LocationInfo HandlerArgumentLocation, + int TreeOrdinal, + EquatableArray DiagnosticInfos, + MethodType MethodType = MethodType.MapDurableHandler) : IMethodInfo; + +internal static class DurableMethodInfoExtensions +{ + private static IEnumerable ReportMultipleEvents( + IReadOnlyList eventOrdinals, + IReadOnlyList parameters, + LocationInfo fallback, + GeneratorContext context) + { + var eventAttribute = new Lazy(() => + context.WellKnownTypes.Get(WellKnownType.MinimalLambda_Builder_FromEventAttribute) + .QualifiedNullableName); + + return eventOrdinals + .Skip(1) + .Select(ordinal => DiagnosticInfo.Create( + Diagnostics.MultipleParametersUseAttribute, + GetParameterLocation(parameters[ordinal], fallback), + [eventAttribute.Value])); + } + + extension(DurableMethodInfo) + { + internal static DurableMethodInfo Create( + IMethodSymbol methodSymbol, + IArgumentOperation handlerArgument, + GeneratorContext context) + { + if (!InterceptableLocationInfo.TryGet(context, out var interceptableLocation)) + throw new InvalidOperationException("Unable to get interceptable location"); + + var invocationLocation = LocationInfo.Create(context.Node) + ?? throw new InvalidOperationException("Durable mapping has no source location"); + var handlerSyntax = GetHandlerSyntax(handlerArgument); + var handlerArgumentLocation = LocationInfo.Create(handlerSyntax) ?? invocationLocation; + var declaration = + methodSymbol + .DeclaringSyntaxReferences + .Select(reference => reference.GetSyntax(context.CancellationToken)) + .FirstOrDefault(); + var returnLocation = GetReturnLocation(declaration) ?? handlerArgumentLocation; + var compilation = context.SemanticModel.Compilation; + var mapTreeOrdinal = GetTreeOrdinal(context.Node.SyntaxTree, compilation); + + var diagnostics = new List(); + var parameters = methodSymbol.Parameters; + var durable = new bool[parameters.Length]; + var candidates = new List(); + var durableContextOrdinals = new List(); + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var reserved = context.WellKnownTypes.IsType( + parameter.Type, + WellKnownType.Amazon_Lambda_DurableExecution_IDurableContext, + WellKnownType.Amazon_Lambda_Core_ILambdaContext, + WellKnownType.MinimalLambda_ILambdaInvocationContext); + durable[i] = context.WellKnownTypes.IsType( + parameter.Type, + WellKnownType.Amazon_Lambda_DurableExecution_IDurableContext); + if (durable[i]) + durableContextOrdinals.Add(i); + if (!reserved && parameter.IsFromEvent(context)) + candidates.Add(i); + } + + var inputOrdinal = candidates.Count == 0 ? -1 : candidates[0]; + var assignments = new List(parameters.Length); + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var parameterLocation = GetParameterLocation(parameter, handlerArgumentLocation); + if (parameter.RefKind != RefKind.None) + diagnostics.Add( + CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + parameterLocation, + $"{parameter.RefKind.ToString().ToLowerInvariant()} {parameter.Name}")); + + if (!IsAccessibleFromGeneratedAdapter(parameter.Type)) + diagnostics.Add( + CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + parameterLocation, + parameter.Type.QualifiedNullableName)); + + var source = ParameterSource.Services; + var assignment = string.Empty; + var isKeyed = false; + string? key = null; + + if (i == inputOrdinal) + { + source = ParameterSource.Event; + assignment = "input"; + } + else if (durable[i]) + { + source = ParameterSource.DurableContext; + assignment = "durableContext"; + } + else if (context.WellKnownTypes.IsType( + parameter.Type, + WellKnownType.Amazon_Lambda_Core_ILambdaContext, + WellKnownType.MinimalLambda_ILambdaInvocationContext)) + { + source = ParameterSource.Context; + assignment = "context"; + } + else if (context.WellKnownTypes.IsType( + parameter.Type, + WellKnownType.System_Threading_CancellationToken)) + { + source = ParameterSource.CancellationToken; + assignment = "context.CancellationToken"; + } + else + { + var diResult = parameter.GetDiParameterAssignment(context); + if (diResult.IsSuccess) + { + assignment = diResult.Value!.Assignment; + key = diResult.Value!.Key; + isKeyed = key is not null; + source = isKeyed ? ParameterSource.KeyedServices : ParameterSource.Services; + } + else if (diResult.Error is { } error) + { + diagnostics.Add(error); + } + } + + assignments.Add( + new DurableHandlerParameterInfo( + parameter.Ordinal, + parameter.Name, + parameter.Type.QualifiedNullableName, + assignment, + source, + i == inputOrdinal, + isKeyed, + key, + parameterLocation)); + } + + diagnostics.AddRange( + ReportMultipleEvents(candidates, parameters, handlerArgumentLocation, context)); + diagnostics.AddRange( + durableContextOrdinals + .Skip(1) + .Select(ordinal => CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + GetParameterLocation(parameters[ordinal], handlerArgumentLocation), + "IDurableContext (only one parameter is supported)"))); + + var hasOutput = false; + ITypeSymbol? outputType = null; + var validReturn = false; + if (methodSymbol.RefKind != RefKind.None) + diagnostics.Add( + CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + returnLocation, + $"{methodSymbol.RefKind.ToString().ToLowerInvariant()} {methodSymbol.ReturnType.QualifiedNullableName}")); + + if (SymbolEqualityComparer.Default.Equals( + methodSymbol.ReturnType, + context.WellKnownTypes.Get(WellKnownType.System_Threading_Tasks_Task))) + validReturn = true; + else if (methodSymbol.ReturnType is INamedTypeSymbol namedReturn + && namedReturn.Arity == 1 + && SymbolEqualityComparer.Default.Equals( + namedReturn.OriginalDefinition, + context.WellKnownTypes.Get(WellKnownType.System_Threading_Tasks_Task_T))) + { + hasOutput = true; + outputType = namedReturn.TypeArguments[0]; + validReturn = true; + + if (!IsAccessibleFromGeneratedAdapter(outputType)) + diagnostics.Add( + CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + returnLocation, + outputType.QualifiedNullableName)); + } + + if (!validReturn) + diagnostics.Add( + CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + returnLocation, + methodSymbol.ReturnType.QualifiedNullableName)); + + var handlerDelegateType = GetHandlerDelegateType( + handlerArgument, + methodSymbol, + compilation, + context.CancellationToken, + out var unsupportedDelegateType); + if (unsupportedDelegateType is not null) + diagnostics.Add( + CreateDiagnostic( + Diagnostics.UnsupportedDurableHandlerSignature, + handlerArgumentLocation, + unsupportedDelegateType.QualifiedNullableName)); + + return new DurableMethodInfo( + InterceptableLocationAttribute: interceptableLocation.Attribute, + DelegateCastType: methodSymbol.GetCastableSignature(), + HandlerDelegateType: handlerDelegateType, + ParameterAssignments: assignments.ToEquatableArray(), + InputType: + inputOrdinal < 0 + ? "global::System.Object" + : parameters[inputOrdinal].Type.QualifiedNullableName, + HasOutput: hasOutput, + OutputType: outputType?.QualifiedNullableName, + HasAnyFromKeyedServices: assignments.Any(parameter => parameter.IsFromKeyedService), + MapCallLocation: invocationLocation, + HandlerArgumentLocation: handlerArgumentLocation, + TreeOrdinal: mapTreeOrdinal, + DiagnosticInfos: diagnostics.ToEquatableArray()); + } + } + + private static string? GetHandlerDelegateType( + IArgumentOperation handlerArgument, + IMethodSymbol methodSymbol, + Compilation compilation, + CancellationToken cancellationToken, + out ITypeSymbol? unsupportedDelegateType) + { + unsupportedDelegateType = null; + var operation = UnwrapHandlerOperation(handlerArgument.Value); + + if (operation is IFieldReferenceOperation field + && operation.Type is not INamedTypeSymbol { TypeKind: TypeKind.Delegate, }) + operation = + GetFieldInitializerOperation(field.Field, compilation, cancellationToken) is + { } initializer + ? UnwrapHandlerOperation(initializer) + : operation; + + var hasExplicitDelegateType = + operation is IConversionOperation { IsImplicit: false } + or IDelegateCreationOperation { IsImplicit: false } + or IFieldReferenceOperation; + + if (!hasExplicitDelegateType) + return null; + + if (operation.Type is not INamedTypeSymbol { TypeKind: TypeKind.Delegate, } delegateType) + { + if (operation is IConversionOperation + { + IsImplicit: false, Type.SpecialType: SpecialType.System_Delegate, + }) + unsupportedDelegateType = operation.Type; + + return null; + } + + if (!IsAccessibleDelegateType(delegateType, compilation) + || !HasMatchingInvokeSignature(delegateType, methodSymbol)) + { + unsupportedDelegateType = delegateType; + return null; + } + + return delegateType.QualifiedNullableName; + } + + private static IOperation UnwrapHandlerOperation(IOperation operation) + { + while (operation is IParenthesizedOperation parenthesized) + operation = parenthesized.Operand; + + if (operation is IConversionOperation { IsImplicit: true } conversion) + operation = conversion.Operand; + + while (operation is IParenthesizedOperation parenthesized) + operation = parenthesized.Operand; + + return operation; + } + + private static IOperation? GetFieldInitializerOperation( + IFieldSymbol field, + Compilation compilation, + CancellationToken cancellationToken) => + field + .DeclaringSyntaxReferences + .Select(reference => reference.GetSyntax(cancellationToken)) + .OfType() + .Where(declarator => declarator.Initializer is not null) + .Select(declarator => + compilation + .GetSemanticModel(declarator.SyntaxTree) + .GetOperation(declarator.Initializer!.Value, cancellationToken)) + .FirstOrDefault(operation => operation is not null); + + private static bool IsAccessibleDelegateType( + INamedTypeSymbol delegateType, + Compilation compilation) + { + if (delegateType.IsAnonymousType + || delegateType.IsFileLocal + || ContainsTypeParameter(delegateType)) + return false; + + for (INamedTypeSymbol? current = delegateType; + current is not null; + current = current.ContainingType) + if (!compilation.IsSymbolAccessibleWithin(current, compilation.Assembly)) + return false; + + return true; + } + + private static bool HasMatchingInvokeSignature( + INamedTypeSymbol delegateType, + IMethodSymbol methodSymbol) + { + var invoke = delegateType.DelegateInvokeMethod; + if (invoke is null + || invoke.RefKind != methodSymbol.RefKind + || !SymbolEqualityComparer.Default.Equals(invoke.ReturnType, methodSymbol.ReturnType) + || invoke.Parameters.Length != methodSymbol.Parameters.Length) + return false; + + return invoke + .Parameters + .Zip( + methodSymbol.Parameters, + static (delegateParameter, methodParameter) => + delegateParameter.RefKind == methodParameter.RefKind + && SymbolEqualityComparer.Default.Equals( + delegateParameter.Type, + methodParameter.Type)) + .All(static matches => matches); + } + + private static bool IsAccessibleFromGeneratedAdapter(ITypeSymbol type) => + !ContainsTypeParameter(type) && IsAccessibleFromGeneratedAdapterCore(type); + + private static bool IsAccessibleFromGeneratedAdapterCore(ITypeSymbol type) => + type switch + { + IArrayTypeSymbol array => IsAccessibleFromGeneratedAdapterCore(array.ElementType), + IPointerTypeSymbol => false, + IFunctionPointerTypeSymbol => false, + INamedTypeSymbol named => IsNamedTypeAccessibleFromGeneratedAdapter(named), + ITypeParameterSymbol => false, + _ => !type.IsRefLikeType, + }; + + private static bool IsNamedTypeAccessibleFromGeneratedAdapter(INamedTypeSymbol type) + { + if (type.IsAnonymousType || type.IsRefLikeType) + return false; + + for (INamedTypeSymbol? current = type; + current is not null; + current = current.ContainingType) + if (current.IsFileLocal + || current.DeclaredAccessibility is not (Accessibility.Public + or Accessibility.Internal + or Accessibility.ProtectedOrInternal)) + return false; + + return type.TypeArguments.All(IsAccessibleFromGeneratedAdapter); + } + + private static bool ContainsTypeParameter(ITypeSymbol type) => + type switch + { + ITypeParameterSymbol => true, + IArrayTypeSymbol array => ContainsTypeParameter(array.ElementType), + IPointerTypeSymbol pointer => ContainsTypeParameter(pointer.PointedAtType), + IFunctionPointerTypeSymbol => true, + INamedTypeSymbol named => (named.ContainingType is not null + && ContainsTypeParameter(named.ContainingType)) + || named.TypeArguments.Any(ContainsTypeParameter), + _ => false, + }; + + private static DiagnosticInfo CreateDiagnostic( + DiagnosticDescriptor descriptor, + LocationInfo location, + params object?[] arguments) => + new(descriptor, location, arguments); + + private static SyntaxNode GetHandlerSyntax(IArgumentOperation argument) => + argument.Syntax is ArgumentSyntax argumentSyntax + ? argumentSyntax.Expression + : argument.Syntax; + + private static LocationInfo? GetReturnLocation(SyntaxNode? declaration) => + declaration switch + { + MethodDeclarationSyntax method => LocationInfo.Create(method.ReturnType), + LocalFunctionStatementSyntax local => LocationInfo.Create(local.ReturnType), + LambdaExpressionSyntax lambda => LocationInfo.Create(lambda.ArrowToken.GetLocation()), + AnonymousMethodExpressionSyntax anonymous => LocationInfo.Create( + anonymous.DelegateKeyword.GetLocation()), + _ => null, + }; + + private static LocationInfo GetParameterLocation( + IParameterSymbol parameter, + LocationInfo fallback) => + parameter + .DeclaringSyntaxReferences + .Select(reference => LocationInfo.Create(reference.GetSyntax())) + .FirstOrDefault(location => location is not null) + ?? fallback; + + internal static int GetTreeOrdinal(SyntaxTree tree, Compilation compilation) + { + var ordinal = 0; + foreach (var candidate in compilation.SyntaxTrees) + { + if (ReferenceEquals(candidate, tree)) + return ordinal; + ordinal++; + } + + return int.MaxValue; + } +} diff --git a/src/MinimalLambda.SourceGenerators/Models/Handlers/MethodType.cs b/src/MinimalLambda.SourceGenerators/Models/Handlers/MethodType.cs index bd7d1986..a92f2fde 100644 --- a/src/MinimalLambda.SourceGenerators/Models/Handlers/MethodType.cs +++ b/src/MinimalLambda.SourceGenerators/Models/Handlers/MethodType.cs @@ -3,6 +3,7 @@ namespace MinimalLambda.SourceGenerators.Models; internal enum MethodType { MapHandler, + MapDurableHandler, OnInit, OnShutdown, UseMiddlewareT, diff --git a/src/MinimalLambda.SourceGenerators/Models/Shared/ParameterSource.cs b/src/MinimalLambda.SourceGenerators/Models/Shared/ParameterSource.cs index abec383b..55f93db5 100644 --- a/src/MinimalLambda.SourceGenerators/Models/Shared/ParameterSource.cs +++ b/src/MinimalLambda.SourceGenerators/Models/Shared/ParameterSource.cs @@ -4,6 +4,7 @@ internal enum ParameterSource { Event, Context, + DurableContext, CancellationToken, KeyedServices, Services, diff --git a/src/MinimalLambda.SourceGenerators/SyntaxProviders/HandlerSyntaxProvider.cs b/src/MinimalLambda.SourceGenerators/SyntaxProviders/HandlerSyntaxProvider.cs index 91adcb31..2e9850d0 100644 --- a/src/MinimalLambda.SourceGenerators/SyntaxProviders/HandlerSyntaxProvider.cs +++ b/src/MinimalLambda.SourceGenerators/SyntaxProviders/HandlerSyntaxProvider.cs @@ -20,7 +20,10 @@ namespace MinimalLambda.SourceGenerators; internal static class HandlerSyntaxProvider { - private static readonly string[] TargetMethodNames = ["MapHandler", "OnInit", "OnShutdown"]; + private static readonly string[] TargetMethodNames = + [ + "MapHandler", "MapDurableHandler", "OnInit", "OnShutdown" + ]; internal static bool Predicate(SyntaxNode node, CancellationToken _) => !node.IsGeneratedFile() @@ -29,19 +32,29 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => internal static IMethodInfo? Transformer( GeneratorSyntaxContext syntaxContext, + CancellationToken cancellationToken) => + Transform(syntaxContext.Node, syntaxContext.SemanticModel, cancellationToken); + + internal static IMethodInfo? Transform( + SyntaxNode node, + SemanticModel semanticModel, CancellationToken cancellationToken) { - var context = new GeneratorContext(syntaxContext, cancellationToken); + var context = new GeneratorContext(node, semanticModel, cancellationToken); if (!TryGetInvocationOperation(context, out var targetOperation)) return null; - if (!targetOperation.TryGetHandlerMethod(context.SemanticModel, out var method)) + if (!targetOperation.TryGetHandlerMethod( + context.SemanticModel, + out var method, + out var handlerArgument)) return null; return targetOperation.TargetMethod.Name switch { "MapHandler" => MapHandlerMethodInfo.Create(method, context), + "MapDurableHandler" => DurableMethodInfo.Create(method, handlerArgument, context), "OnInit" => LifecycleMethodInfo.CreateForInit(method, context), "OnShutdown" => LifecycleMethodInfo.CreateForShutdown(method, context), var methodName => throw new InvalidOperationException($"Unknown method '{methodName}"), @@ -56,21 +69,13 @@ private static bool TryGetInvocationOperation( var operation = context.SemanticModel.GetOperation(context.Node, context.CancellationToken); - if (operation is IInvocationOperation - { - TargetMethod.ContainingNamespace: - { - Name: "Builder", - ContainingNamespace - : { Name: "MinimalLambda", ContainingNamespace.IsGlobalNamespace: true, }, - }, - } targetOperation - && targetOperation.TargetMethod.ContainingAssembly.Name == "MinimalLambda" - && targetOperation.TryGetRouteHandlerArgument(out var routeHandlerParameter) - && routeHandlerParameter is { Parameter.Type: { } delegateType } - && SymbolEqualityComparer.Default.Equals( - delegateType, - context.WellKnownTypes.Get(WellKnownType.System_Delegate))) + if (operation is IInvocationOperation targetOperation + && targetOperation.TargetMethod.GetDeclaredMethod() is { } declaredMethod + && IsKnownTarget(declaredMethod) + && targetOperation.TryGetRouteHandlerArgument( + declaredMethod, + context.WellKnownTypes.Get(WellKnownType.System_Delegate), + out _)) { invocationOperation = targetOperation; return true; @@ -82,12 +87,22 @@ private static bool TryGetInvocationOperation( private static bool TryGetHandlerMethod( this IInvocationOperation invocation, SemanticModel semanticModel, - [NotNullWhen(true)] out IMethodSymbol? method) + [NotNullWhen(true)] out IMethodSymbol? method, + [NotNullWhen(true)] out IArgumentOperation? handlerArgument) { method = null; - if (invocation.TryGetRouteHandlerArgument(out var argument)) + handlerArgument = null; + var declaredMethod = invocation.TargetMethod.GetDeclaredMethod(); + var delegateType = semanticModel.Compilation.GetTypeByMetadataName("System.Delegate"); + + if (delegateType is not null + && invocation.TryGetRouteHandlerArgument( + declaredMethod, + delegateType, + out var argument)) { method = ResolveMethodFromOperation(argument, semanticModel); + handlerArgument = argument; return method is not null; } @@ -118,13 +133,23 @@ private static bool TryGetHandlerMethod( private static bool TryGetRouteHandlerArgument( this IInvocationOperation invocation, + IMethodSymbol declaredMethod, + ITypeSymbol delegateType, [NotNullWhen(true)] out IArgumentOperation? argumentOperation) { argumentOperation = null; - var routeHandlerArgumentOrdinal = invocation.Arguments.Length - 1; + var handlerParameter = declaredMethod.Parameters.FirstOrDefault(parameter => + SymbolEqualityComparer.Default.Equals(parameter.Type, delegateType)); + + if (handlerParameter is null) + return false; + + var targetOrdinal = invocation.TargetMethod.ReducedFrom is null + ? handlerParameter.Ordinal + : handlerParameter.Ordinal - 1; foreach (var argument in invocation.Arguments) - if (argument.Parameter?.Ordinal == routeHandlerArgumentOrdinal) + if (argument.Parameter?.Ordinal == targetOrdinal) { argumentOperation = argument; return true; @@ -133,6 +158,32 @@ private static bool TryGetRouteHandlerArgument( return false; } + private static IMethodSymbol GetDeclaredMethod(this IMethodSymbol method) => + method.ReducedFrom ?? method; + + private static bool IsKnownTarget(IMethodSymbol method) => + method.ContainingNamespace is + { + Name: "Builder", + ContainingNamespace: + { + Name: "MinimalLambda", ContainingNamespace.IsGlobalNamespace: true, + }, + } + && (method.Name switch + { + "MapDurableHandler" => method is + { + ContainingAssembly.Name: "MinimalLambda.DurableExecution", + } + && (method.ContainingType.Name == "MapDurableHandlerLambdaApplicationExtensions" + || method.ContainingType.ContainingType?.Name + == "MapDurableHandlerLambdaApplicationExtensions"), + "MapHandler" or "OnInit" or "OnShutdown" => method.ContainingAssembly.Name + == "MinimalLambda", + _ => false, + }); + private static IOperation? ResolveDeclarationOperation( ISymbol symbol, SemanticModel? semanticModel) => diff --git a/src/MinimalLambda.SourceGenerators/Templates/MapDurableHandler.scriban b/src/MinimalLambda.SourceGenerators/Templates/MapDurableHandler.scriban new file mode 100644 index 00000000..c14d9396 --- /dev/null +++ b/src/MinimalLambda.SourceGenerators/Templates/MapDurableHandler.scriban @@ -0,0 +1,85 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + {{ generated_code_attribute.value }} + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + {{ generated_code_attribute.value }} + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + {{~ for call in map_durable_handler_calls ~}} + {{ call.interceptable_location_attribute }} + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor{{ for.index }}( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, {{ if call.handler_delegate_type }}({{ call.handler_delegate_type }})null!{{ else }}{{ call.delegate_cast_type }}{{ end }}); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync<{{ call.input_type }}{{ if call.has_output }}, {{ call.output_type }}{{ end }}>( + (input, durableContext) => + { + {{~ if call.has_any_from_keyed_services ~}} + if (context.ServiceProvider.GetService() is not IServiceProviderIsKeyedService) + { + throw new InvalidOperationException($"Unable to resolve service referenced by {nameof(FromKeyedServicesAttribute)}. The service provider doesn't support keyed services."); + } + {{~ end ~}} + {{~ for handler_arg in call.parameter_assignments ~}} + var arg{{ for.index }} = {{ handler_arg.assignment }}; + {{~ end ~}} + return castHandler.Invoke({{ for arg in call.parameter_assignments }}arg{{ for.index }}{{ if !for.last }}, {{ end }}{{ end }}); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + {{~ end ~}} + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypeData.cs b/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypeData.cs index f26050e4..b0cbd214 100644 --- a/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypeData.cs +++ b/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypeData.cs @@ -42,6 +42,10 @@ public enum WellKnownType System_AttributeUsageAttribute, System_Collections_Generic_Dictionary_2, Amazon_Lambda_Core_ILambdaContext, + Amazon_Lambda_DurableExecution_IDurableContext, + Amazon_Lambda_DurableExecution_DurableFunction, + Amazon_Lambda_DurableExecution_DurableExecutionInvocationInput, + Amazon_Lambda_DurableExecution_DurableExecutionInvocationOutput, System_Action, System_Func, System_IAsyncDisposable, @@ -57,6 +61,13 @@ public enum WellKnownType MinimalLambda_Builder_MiddlewareConstructorAttribute, System_Boolean, MinimalLambda_ILambdaMiddleware, + System_Text_Json_Serialization_JsonSerializableAttribute, + System_Text_Json_Serialization_JsonSerializerContext, + Amazon_Lambda_Core_ILambdaSerializer, + Microsoft_Extensions_DependencyInjection_SerializerServiceCollectionExtensions, + MinimalLambda_Builder_LambdaApplicationBuilder, + MinimalLambda_Builder_BuilderLambdaApplicationExtensions, + MinimalLambda_Builder_LambdaApplication, } public static readonly string[] WellKnownTypeNames = @@ -89,6 +100,10 @@ public enum WellKnownType "System.AttributeUsageAttribute", "System.Collections.Generic.Dictionary`2", "Amazon.Lambda.Core.ILambdaContext", + "Amazon.Lambda.DurableExecution.IDurableContext", + "Amazon.Lambda.DurableExecution.DurableFunction", + "Amazon.Lambda.DurableExecution.DurableExecutionInvocationInput", + "Amazon.Lambda.DurableExecution.DurableExecutionInvocationOutput", "System.Action", "System.Func", "System.IAsyncDisposable", @@ -104,5 +119,12 @@ public enum WellKnownType "MinimalLambda.Builder.MiddlewareConstructorAttribute", "System.Boolean", "MinimalLambda.ILambdaMiddleware", + "System.Text.Json.Serialization.JsonSerializableAttribute", + "System.Text.Json.Serialization.JsonSerializerContext", + "Amazon.Lambda.Core.ILambdaSerializer", + "Microsoft.Extensions.DependencyInjection.SerializerServiceCollectionExtensions", + "MinimalLambda.Builder.LambdaApplicationBuilder", + "MinimalLambda.Builder.BuilderLambdaApplicationExtensions", + "MinimalLambda.Builder.LambdaApplication", ]; } diff --git a/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypes.cs b/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypes.cs index 960a06d7..27079259 100644 --- a/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypes.cs +++ b/src/MinimalLambda.SourceGenerators/WellKnownTypes/WellKnownTypes.cs @@ -96,6 +96,11 @@ private INamedTypeSymbol GetAndCache(int index) if (types.Length == 1) return types[0]; + if (metadataName.StartsWith("Amazon.Lambda.DurableExecution.", StringComparison.Ordinal)) + foreach (var type in types) + if (type.ContainingAssembly.Identity.Name == "Amazon.Lambda.DurableExecution") + return type; + // Multiple types match the name. This is most likely caused by someone reusing the // namespace + type name in their apps or libraries. // Workaround this situation by prioritizing types in System and Microsoft assemblies. diff --git a/src/MinimalLambda.Templates/MinimalLambda.Templates.csproj b/src/MinimalLambda.Templates/MinimalLambda.Templates.csproj index f5b7f88d..d1ceb53c 100644 --- a/src/MinimalLambda.Templates/MinimalLambda.Templates.csproj +++ b/src/MinimalLambda.Templates/MinimalLambda.Templates.csproj @@ -22,7 +22,9 @@ + Exclude="templates\**\bin\**;templates\**\obj\**;templates\Directory.Build.props;templates\Directory.Build.targets;templates\Directory.Packages.props;templates\mlambda-durable\**" + PackagePath="content" + TargetPath="templates\%(RecursiveDir)%(Filename)%(Extension)" /> - - + + diff --git a/src/MinimalLambda.Templates/templates/mlambda-durable/.template.config/template.json b/src/MinimalLambda.Templates/templates/mlambda-durable/.template.config/template.json new file mode 100644 index 00000000..d79fbcce --- /dev/null +++ b/src/MinimalLambda.Templates/templates/mlambda-durable/.template.config/template.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "MinimalLambda", + "classifications": ["AWS", "Lambda", "Durable Execution"], + "name": "MinimalLambda AWS Lambda Durable Execution Function", + "identity": "MinimalLambda.Templates.DurableExecutionFunction.CSharp", + "groupIdentity": "MinimalLambda.Templates.DurableExecutionFunction", + "shortName": "mlambda-durable", + "tags": { "language": "C#", "type": "project" }, + "sourceName": "BlueprintBaseName.1", + "preferNameDirectory": true, + "symbols": { + "profile": { + "type": "parameter", + "description": "AWS credentials profile stored in aws-lambda-tools-defaults.json.", + "datatype": "string", + "replaces": "DefaultProfile", + "defaultValue": "" + }, + "region": { + "type": "parameter", + "description": "AWS region stored in aws-lambda-tools-defaults.json.", + "datatype": "string", + "replaces": "DefaultRegion", + "defaultValue": "" + } + }, + "primaryOutputs": [{ "path": "./src/BlueprintBaseName.1/BlueprintBaseName.1.csproj" }] +} diff --git a/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/BlueprintBaseName.1.csproj b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/BlueprintBaseName.1.csproj new file mode 100644 index 00000000..bc6a0eec --- /dev/null +++ b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/BlueprintBaseName.1.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + preview + enable + enable + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + false + + + + + + + + \ No newline at end of file diff --git a/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/Program.cs b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/Program.cs new file mode 100644 index 00000000..3775a78e --- /dev/null +++ b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/Program.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); + +lambda.MapDurableHandler(async ( + [FromEvent] GreetingRequest request, + IDurableContext durable, + [FromServices] GreetingService greetings) => +{ + var result = await durable.StepAsync( + (_, cancellationToken) => greetings.CreateAsync(request.Name, cancellationToken), + name: "create-greeting"); + + return new GreetingResponse(result.Message, durable.ExecutionContext.DurableExecutionArn); +}); + +await lambda.RunAsync(); + +internal sealed record GreetingRequest(string Name); + +internal sealed record GreetingResponse(string Message, string ExecutionArn); + +internal sealed record GreetingStepResult(string Message); + +internal sealed class GreetingService +{ + public Task CreateAsync(string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new GreetingStepResult($"Hello, {name}!")); + } +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(GreetingRequest))] +[JsonSerializable(typeof(GreetingResponse))] +[JsonSerializable(typeof(GreetingStepResult))] +internal partial class BlueprintBaseName__1JsonContext : JsonSerializerContext; diff --git a/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/README.md b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/README.md new file mode 100644 index 00000000..d056b766 --- /dev/null +++ b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/README.md @@ -0,0 +1,57 @@ +# BlueprintBaseName.1 + +MinimalLambda AWS Lambda Durable Execution function. + +## Prerequisites + +- .NET SDK 10.0 or later. +- AWS credentials for deployment. +- Amazon.Lambda.Tools 7.0.0 or later. + +## Restore and build + +```bash +dotnet restore +dotnet build +``` + +## Durable handler + +`Program.cs` uses inline `MapDurableHandler` registration. `[FromEvent]` input and +`IDurableContext` are optional; durable handlers return `Task` or `Task`. + +A root `CancellationToken` parameter receives physical Lambda-invocation cancellation. It is not a +Durable Execution operation token; use cancellation supplied to durable operation callbacks for step work. + +Keep `DurableExecutionInvocationInput`, `DurableExecutionInvocationOutput`, workflow input/output, +and operation result types as explicit `JsonSerializable` roots. + +## Deploy + +`aws-lambda-tools-defaults.json` sets managed `dotnet10` runtime, durable execution defaults, and `function-publish: true`. Durable invocations require a qualified identifier: a published version, alias, or `$LATEST`. Prefer a published version or alias for stable routing. +`dotnet lambda deploy-function` creates an execution role when one does not exist and attaches AWS +managed policy `AWSLambdaBasicDurableExecutionRolePolicy`. If you supply a custom role with +`--function-role`, attach that policy before deployment; add application-specific permissions as needed. +Deployment and invocation permissions follow current [AWS Durable Execution documentation](https://docs.aws.amazon.com/lambda/latest/dg/durable-getting-started.html). + +```bash +dotnet lambda deploy-function + +# Custom role: +# dotnet lambda deploy-function --function-role arn:aws:iam::123456789012:role/durable-execution-role +``` + +## Invoke + +Use Amazon.Lambda.Tools durable mode to start and poll a durable execution. Pass a qualified function identifier, such as an ARN with a version or alias: + +```bash +dotnet lambda invoke-function : \ + --invoke-mode DurableExecution \ + --payload '{"Name":"Ada"}' +``` + +## Central Package Management + +If repository uses Central Package Management, move generated package versions to +`Directory.Packages.props` and remove `Version="..."` from project references. diff --git a/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/aws-lambda-tools-defaults.json b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/aws-lambda-tools-defaults.json new file mode 100644 index 00000000..b5bfe381 --- /dev/null +++ b/src/MinimalLambda.Templates/templates/mlambda-durable/src/BlueprintBaseName.1/aws-lambda-tools-defaults.json @@ -0,0 +1,17 @@ +{ + "Information": [ + "Defaults for deploying a MinimalLambda Durable Execution function.", + "Run dotnet lambda help for command options." + ], + "profile": "DefaultProfile", + "region": "DefaultRegion", + "configuration": "Release", + "framework": "net10.0", + "function-runtime": "dotnet10", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "BlueprintBaseName.1", + "function-publish": true, + "durable-execution-timeout": 86400, + "durable-retention-period": 7 +} diff --git a/src/MinimalLambda/Core/Context/LambdaInvocationContext.cs b/src/MinimalLambda/Core/Context/LambdaInvocationContext.cs index db671ae8..3bacce4d 100644 --- a/src/MinimalLambda/Core/Context/LambdaInvocationContext.cs +++ b/src/MinimalLambda/Core/Context/LambdaInvocationContext.cs @@ -66,6 +66,9 @@ public async ValueTask DisposeAsync() public int MemoryLimitInMB => _lambdaContext.MemoryLimitInMB; + public ILambdaSerializer Serializer => + _lambdaContext.Serializer ?? ServiceProvider.GetRequiredService(); + public TimeSpan RemainingTime => _lambdaContext.RemainingTime; public string TenantId => _lambdaContext.TenantId; diff --git a/tasks/BuildTasks.yml b/tasks/BuildTasks.yml index efa9de9a..cbb30bf0 100644 --- a/tasks/BuildTasks.yml +++ b/tasks/BuildTasks.yml @@ -63,4 +63,10 @@ tasks: desc: Validates the AOT compatibility of the libraries silent: true cmds: - - dotnet publish src/AotCompatibility.TestApp/AotCompatibility.TestApp.csproj /p:TreatWarningsAsErrors=true \ No newline at end of file + - dotnet publish src/AotCompatibility.TestApp/AotCompatibility.TestApp.csproj /p:TreatWarningsAsErrors=true + + package-compat: + desc: Validates packed durable package consumers on net10.0 + silent: true + cmds: + - bash scripts/test-package-compatibility.sh \ No newline at end of file diff --git a/tasks/LocalDevTasks.yml b/tasks/LocalDevTasks.yml index fdda65ea..24a38a8c 100644 --- a/tasks/LocalDevTasks.yml +++ b/tasks/LocalDevTasks.yml @@ -72,3 +72,8 @@ tasks: - echo "📖 Building Docs" - uv run zensical build -f mkdocs.yml - echo "✅ Docs built" + + release-dry-run: + desc: Pack and validate isolated core and durable release manifests + cmds: + - ./scripts/dry-run-release-manifests.sh diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 472578fb..9a5a63a8 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -2,6 +2,9 @@ true true + + $(NoWarn);NU1608 \ No newline at end of file diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/DocumentationContractTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/DocumentationContractTests.cs new file mode 100644 index 00000000..e74e778e --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/DocumentationContractTests.cs @@ -0,0 +1,105 @@ +using System.Xml.Linq; + +namespace MinimalLambda.DurableExecution.UnitTests; + +public class DocumentationContractTests +{ + private static readonly XDocument Documentation = XDocument.Load( + Path.Combine(AppContext.BaseDirectory, "MinimalLambda.DurableExecution.xml")); + + [Fact] + public void GetInvocationContext_HasRequiredXmlDocumentation() + { + // Arrange + const string memberId = + "M:MinimalLambda.DurableExecution.DurableContextExtensions.GetInvocationContext(Amazon.Lambda.DurableExecution.IDurableContext)"; + + // Act + var documentation = ResolveDocumentation(memberId); + + // Assert + Text(documentation, "summary") + .Should() + .Be( + "Gets the MinimalLambda invocation context associated with this durable execution."); + Text(documentation, "remarks") + .Should() + .Contain("physical invocation context") + .And + .Contain("creates a new physical Lambda invocation for a replay") + .And + .Contain("using it to cancel the root workflow can produce a terminal durable failure") + .And + .Contain("Prefer cancellation tokens supplied to durable operation callbacks."); + Text(documentation, "returns").Should().Contain("Exact instance stored in ."); + ExceptionTypes(documentation) + .Should() + .BeEquivalentTo("T:System.ArgumentNullException", "T:System.InvalidOperationException"); + } + + [Fact] + public void MapDurableHandler_HasRequiredXmlDocumentation() + { + // Arrange + const string memberId = + "M:MinimalLambda.Builder.MapDurableHandlerLambdaApplicationExtensions.MapDurableHandler(MinimalLambda.Builder.ILambdaInvocationBuilder,System.Delegate)"; + + // Act + var documentation = ResolveDocumentation(memberId); + + // Assert + Text(documentation, "summary") + .Should() + .Be( + "Registers an AWS Lambda Durable Execution handler with automatic dependency injection and serialization."); + Text(documentation, "remarks") + .Should() + .Contain("A compile-time interceptor must replace this call;") + .And + .Contain("can optionally declare a") + .And + .Contain("returns") + .And + .Contain("Middleware runs again when AWS replays a workflow") + .And + .Contain( + "AWS owns durable context creation, checkpoints, replay, suspension, and durable status mapping") + .And + .Contain("MinimalLambda owns physical invocation hosting") + .And + .Contain("root handler cancellation tokens supplied by the physical invocation") + .And + .Contain( + "Durable operation callbacks receive distinct SDK cancellation tokens for step work"); + Text(documentation, "param") + .Should() + .Contain( + "Durable handler delegate that will be intercepted and replaced at compile time"); + documentation.Element("param")?.Attribute("name")?.Value.Should().Be("handler"); + Text(documentation, "returns").Should().Contain("Current instance for method chaining."); + ExceptionTypes(documentation).Should().Equal("T:System.InvalidOperationException"); + } + + private static XElement ResolveDocumentation(string memberId) + { + var publicMember = Documentation + .Descendants("member") + .Single(element => element.Attribute("name")?.Value == memberId); + var inheritedMemberId = publicMember.Element("inheritdoc")?.Attribute("cref")?.Value; + + inheritedMemberId.Should().NotBeNullOrWhiteSpace(); + return Documentation + .Descendants("member") + .Single(element => element.Attribute("name")?.Value == inheritedMemberId); + } + + private static string Text(XElement member, string elementName) => + string.Join( + " ", + member.Element(elementName)!.Value.Split( + (char[]?)null, + StringSplitOptions.RemoveEmptyEntries)); + + private static IEnumerable ExceptionTypes(XElement member) => + member.Elements("exception").Select(element => element.Attribute("cref")!.Value); +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/DurableContextExtensionsTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/DurableContextExtensionsTests.cs new file mode 100644 index 00000000..37d38d41 --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/DurableContextExtensionsTests.cs @@ -0,0 +1,52 @@ +using Amazon.Lambda.Core; + +namespace MinimalLambda.DurableExecution.UnitTests; + +public class DurableContextExtensionsTests +{ + [Fact] + public void GetInvocationContext_WithNullContext_ThrowsArgumentNullException() + { + // Arrange + IDurableContext context = null!; + + // Act + var act = context.GetInvocationContext; + + // Assert + act.Should().Throw().WithParameterName(nameof(context)); + } + + [Fact] + public void GetInvocationContext_WithForeignLambdaContext_ThrowsInvalidOperationException() + { + // Arrange + var context = Substitute.For(); + context.LambdaContext.Returns(Substitute.For()); + + // Act + var act = context.GetInvocationContext; + + // Assert + act + .Should() + .Throw() + .WithMessage( + "MinimalLambda invocation context is not available on this durable context."); + } + + [Fact] + public void GetInvocationContext_WithMinimalLambdaContext_ReturnsExactInstance() + { + // Arrange + var invocationContext = Substitute.For(); + var context = Substitute.For(); + context.LambdaContext.Returns(invocationContext); + + // Act + var result = context.GetInvocationContext(); + + // Assert + result.Should().BeSameAs(invocationContext); + } +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/DurableWorkflowTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/DurableWorkflowTests.cs new file mode 100644 index 00000000..337e85cc --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/DurableWorkflowTests.cs @@ -0,0 +1,157 @@ +using Amazon.Lambda.DurableExecution.Testing; +using Microsoft.Extensions.Logging; + +namespace MinimalLambda.DurableExecution.UnitTests; + +/// +/// Exercises AWS durable workflow semantics through public local-runner seams. +/// Generated MinimalLambda adapter, middleware, serializer, and outer-envelope coverage lives in +/// MinimalLambda.Testing.UnitTests.DurableLambdaTests; these tests do not claim combined host/replay E2E. +/// +public class DurableWorkflowTests +{ + [Fact] + public async Task Workflow_Succeeds() + { + var stepProbe = new StepExecutionProbe(); + await using var runner = CreateRunner(stepProbe); + + var result = await runner.RunAsync( + "order-42", + cancellationToken: TestContext.Current.CancellationToken); + + result.EnsureSucceeded(); + result.Result.Should().Be("completed-order-42"); + result.GetStep("load-order").GetResult().Should().Be("order-42"); + } + + [Fact] + public async Task Workflow_FailingStep_ReturnsFailure() + { + await using var runner = new DurableTestRunner(async (_, context) => + { + await context.StepAsync( + async (_, _) => + { + await Task.CompletedTask; + throw new InvalidOperationException("expected workflow failure"); + }, + name: "failing-step"); + return "unreachable"; + }); + + var result = await runner.RunAsync( + "ignored", + cancellationToken: TestContext.Current.CancellationToken); + + result.IsFailed.Should().BeTrue(); + result.Error.Should().NotBeNull(); + result.Error!.ErrorType.Should().Be(typeof(InvalidOperationException).FullName); + result.Error.ErrorMessage.Should().Contain("expected workflow failure"); + result.GetStep("failing-step").Status.Should().Be(OperationStatus.Failed); + } + + [Fact] + public async Task Workflow_ReplaySkipsCompletedStepBody() + { + var stepProbe = new StepExecutionProbe(); + var workflowInvocationCount = 0; + await using var runner = CreateRunner( + stepProbe, + onWorkflowInvocation: () => workflowInvocationCount++); + + var result = await runner.RunAsync( + "order-42", + cancellationToken: TestContext.Current.CancellationToken); + + result.EnsureSucceeded(); + workflowInvocationCount.Should().BeGreaterThan(1); + stepProbe.Count.Should().Be(1); + } + + [Fact] + public async Task Workflow_WaitSuspendsAndResumes_WithSkippedTime() + { + await using var runner = CreateRunner(new StepExecutionProbe()); + + var result = await runner.RunAsync( + "order-42", + cancellationToken: TestContext.Current.CancellationToken); + + result.EnsureSucceeded(); + result.InvocationCount.Should().NotBeNull(); + result.InvocationCount!.Value.Should().BeGreaterThan(1); + var wait = result.GetStep("approval-window"); + wait.Kind.Should().Be(OperationKind.Wait); + wait.Status.Should().Be(OperationStatus.Succeeded); + } + + [Fact] + public async Task Workflow_ReplaySafeLogger_EmitsEachMessageOnce() + { + var logger = new CapturingLogger(); + await using var runner = CreateRunner(new StepExecutionProbe(), logger); + + var result = await runner.RunAsync( + "order-42", + cancellationToken: TestContext.Current.CancellationToken); + + result.EnsureSucceeded(); + result.InvocationCount.Should().NotBeNull(); + result.InvocationCount!.Value.Should().BeGreaterThan(1); + logger.Messages.Should().Equal("workflow-start", "step-body", "after-step", "after-wait"); + } + + private static DurableTestRunner CreateRunner( + StepExecutionProbe stepProbe, + CapturingLogger? logger = null, + Action? onWorkflowInvocation = null) => + new( + async (input, context) => + { + onWorkflowInvocation?.Invoke(); + if (logger is not null) + context.ConfigureLogger(new LoggerConfig { CustomLogger = logger }); + + context.Logger.LogInformation("workflow-start"); + var order = await context.StepAsync( + async (stepContext, _) => + { + stepProbe.Record(); + stepContext.Logger.LogInformation("step-body"); + await Task.CompletedTask; + return input; + }, + name: "load-order"); + context.Logger.LogInformation("after-step"); + + await context.WaitAsync(TimeSpan.FromDays(1), name: "approval-window"); + context.Logger.LogInformation("after-wait"); + return $"completed-{order}"; + }, + new TestRunnerOptions { SkipTime = true }); + + private sealed class StepExecutionProbe + { + public int Count { get; private set; } + + public void Record() => Count++; + } + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Messages.Add(formatter(state, exception)); + } +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/MapDurableHandlerLambdaApplicationExtensionsTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/MapDurableHandlerLambdaApplicationExtensionsTests.cs new file mode 100644 index 00000000..3a6ddf26 --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/MapDurableHandlerLambdaApplicationExtensionsTests.cs @@ -0,0 +1,30 @@ +using MinimalLambda.Builder; + +namespace MinimalLambda.DurableExecution.UnitTests; + +public class MapDurableHandlerLambdaApplicationExtensionsTests +{ + [Fact] + public void MapDurableHandler_WhenNotIntercepted_ThrowsFallbackException() + { + // Arrange + var application = Substitute.For(); + Action handler = () => { }; + + // Act + var act = () => application.MapDurableHandler(handler); + + // Assert +#if DEBUG + act + .Should() + .Throw() + .WithMessage("*This method should have been intercepted at compile time!*"); +#else + act + .Should() + .Throw() + .WithMessage("This method is replaced at compile time."); +#endif + } +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/MinimalLambda.DurableExecution.UnitTests.csproj b/tests/MinimalLambda.DurableExecution.UnitTests/MinimalLambda.DurableExecution.UnitTests.csproj new file mode 100644 index 00000000..243fc60d --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/MinimalLambda.DurableExecution.UnitTests.csproj @@ -0,0 +1,40 @@ + + + net10.0 + preview + enable + enable + false + false + true + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + \ No newline at end of file diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/PackageAssemblyTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/PackageAssemblyTests.cs new file mode 100644 index 00000000..77142542 --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/PackageAssemblyTests.cs @@ -0,0 +1,35 @@ +using System.Reflection; + +namespace MinimalLambda.DurableExecution.UnitTests; + +public class PackageAssemblyTests +{ + [Fact] + public void DurablePackageAssembly_IsAvailable() + { + // Act + var assembly = Assembly.Load("MinimalLambda.DurableExecution"); + + // Assert + assembly.GetName().Name.Should().Be("MinimalLambda.DurableExecution"); + } + + [Fact] + public void AwsDurableExecutionSurface_IsAvailable() + { + // Act + var publicTypes = new[] + { + typeof(DurableFunction), + typeof(IDurableContext), + typeof(DurableExecutionInvocationInput), + typeof(DurableExecutionInvocationOutput), + }; + + // Assert + publicTypes + .Should() + .AllSatisfy(type => + type.Assembly.GetName().Name.Should().Be("Amazon.Lambda.DurableExecution")); + } +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/PackageIsolationTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/PackageIsolationTests.cs new file mode 100644 index 00000000..fd1e4054 --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/PackageIsolationTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using System.Xml.Linq; +using MinimalLambda.Builder; + +namespace MinimalLambda.DurableExecution.UnitTests; + +public class PackageIsolationTests +{ + private const string AwsDurableAssemblyName = "Amazon.Lambda.DurableExecution"; + + [Fact] + public void CoreAssemblyReferenceGraph_DoesNotReferenceAwsDurableExecution() + { + // Arrange + var coreAssemblies = MinimalLambdaReferenceGraph(typeof(LambdaApplication).Assembly); + + // Act + var references = coreAssemblies.SelectMany(assembly => assembly.GetReferencedAssemblies()); + + // Assert + references.Should().NotContain(reference => reference.Name == AwsDurableAssemblyName); + } + + [Fact] + public void DurableAssembly_ReferencesAwsDurableExecution() + { + // Act + var references = typeof(DurableContextExtensions).Assembly.GetReferencedAssemblies(); + + // Assert + references.Should().ContainSingle(reference => reference.Name == AwsDurableAssemblyName); + } + + [Fact] + public void CoreProjectMetadata_DoesNotDependOnAwsDurableExecution() + { + // Act + var references = PackageReferences("src/MinimalLambda/MinimalLambda.csproj"); + + // Assert + references.Should().NotContain(AwsDurableAssemblyName); + } + + [Fact] + public void DurableProjectMetadata_DependsOnAwsDurableExecution() + { + // Act + var references = PackageReferences( + "src/MinimalLambda.DurableExecution/MinimalLambda.DurableExecution.csproj"); + + // Assert + references.Should().Contain(AwsDurableAssemblyName); + } + + private static IReadOnlyCollection MinimalLambdaReferenceGraph(Assembly root) + { + var assemblies = + new Dictionary(StringComparer.Ordinal) + { + [root.GetName().Name!] = root, + }; + var pending = new Queue(); + pending.Enqueue(root); + + while (pending.TryDequeue(out var assembly)) + foreach (var reference in assembly + .GetReferencedAssemblies() + .Where(reference => + reference.Name?.StartsWith("MinimalLambda", StringComparison.Ordinal) == true)) + { + if (assemblies.ContainsKey(reference.Name!)) + continue; + + var referencedAssembly = Assembly.Load(reference); + assemblies.Add(reference.Name!, referencedAssembly); + pending.Enqueue(referencedAssembly); + } + + return assemblies.Values; + } + + private static string[] PackageReferences(string relativeProjectPath) + { + var project = XDocument.Load(Path.Combine(RepositoryRoot(), relativeProjectPath)); + return project + .Descendants("PackageReference") + .Select(element => element.Attribute("Include")?.Value) + .Where(value => value is not null) + .Cast() + .ToArray(); + } + + private static string RepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + if (File.Exists(Path.Combine(directory.FullName, "MinimalLambda.sln"))) + return directory.FullName; + + throw new InvalidOperationException("Repository root was not found."); + } +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/PublicApiTests.cs b/tests/MinimalLambda.DurableExecution.UnitTests/PublicApiTests.cs new file mode 100644 index 00000000..0702f4f9 --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/PublicApiTests.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using MinimalLambda.Builder; + +namespace MinimalLambda.DurableExecution.UnitTests; + +public class PublicApiTests +{ + [Fact] + public void MapDurableHandler_HasExpectedPublicExtensionSignature() + { + // Act + var method = typeof(MapDurableHandlerLambdaApplicationExtensions).GetMethod( + "MapDurableHandler", + BindingFlags.Public | BindingFlags.Static); + + // Assert + typeof(MapDurableHandlerLambdaApplicationExtensions) + .Namespace + .Should() + .Be("MinimalLambda.Builder"); + typeof(MapDurableHandlerLambdaApplicationExtensions) + .Assembly + .GetName() + .Name + .Should() + .Be("MinimalLambda.DurableExecution"); + method.Should().NotBeNull(); + method!.ReturnType.Should().Be(); + method.IsDefined(typeof(ExtensionAttribute), false).Should().BeTrue(); + method + .GetParameters() + .Select(parameter => parameter.ParameterType) + .Should() + .Equal(typeof(ILambdaInvocationBuilder), typeof(Delegate)); + } + + [Fact] + public void GetInvocationContext_HasExpectedPublicExtensionSignature() + { + // Act + var method = typeof(DurableContextExtensions).GetMethod( + "GetInvocationContext", + BindingFlags.Public | BindingFlags.Static); + + // Assert + typeof(DurableContextExtensions).Namespace.Should().Be("MinimalLambda.DurableExecution"); + typeof(DurableContextExtensions) + .Assembly + .GetName() + .Name + .Should() + .Be("MinimalLambda.DurableExecution"); + method.Should().NotBeNull(); + method!.ReturnType.Should().Be(); + method.IsDefined(typeof(ExtensionAttribute), false).Should().BeTrue(); + method + .GetParameters() + .Select(parameter => parameter.ParameterType) + .Should() + .Equal(typeof(IDurableContext)); + } +} diff --git a/tests/MinimalLambda.DurableExecution.UnitTests/xunit.runner.json b/tests/MinimalLambda.DurableExecution.UnitTests/xunit.runner.json new file mode 100644 index 00000000..c2f84268 --- /dev/null +++ b/tests/MinimalLambda.DurableExecution.UnitTests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} diff --git a/tests/MinimalLambda.OpenTelemetry.UnitTests/MiddlewareOpenTelemetryExtensionsTest.cs b/tests/MinimalLambda.OpenTelemetry.UnitTests/MiddlewareOpenTelemetryExtensionsTest.cs index c188a4a2..34d50562 100644 --- a/tests/MinimalLambda.OpenTelemetry.UnitTests/MiddlewareOpenTelemetryExtensionsTest.cs +++ b/tests/MinimalLambda.OpenTelemetry.UnitTests/MiddlewareOpenTelemetryExtensionsTest.cs @@ -26,7 +26,7 @@ public void UseOpenTelemetryTracing_WithNoTracerProvider_ThrowsInvalidOperationE ILambdaInvocationBuilder builder) { // Arrange - serviceProvider.GetService(typeof(TracerProvider)).Returns(null); + serviceProvider.GetService(typeof(TracerProvider)).Returns(null!); builder.Services.Returns(serviceProvider); // Act diff --git a/tests/MinimalLambda.OpenTelemetry.UnitTests/OnShutdownOpenTelemetryExtensionsTests.cs b/tests/MinimalLambda.OpenTelemetry.UnitTests/OnShutdownOpenTelemetryExtensionsTests.cs index 84c752e8..c24d3649 100644 --- a/tests/MinimalLambda.OpenTelemetry.UnitTests/OnShutdownOpenTelemetryExtensionsTests.cs +++ b/tests/MinimalLambda.OpenTelemetry.UnitTests/OnShutdownOpenTelemetryExtensionsTests.cs @@ -31,7 +31,7 @@ public void OnShutdownFlushTracer_ThrowsOnNoTracerProviderRegistered() // Arrange var mockApp = Substitute.For(); var mockServiceProvider = Substitute.For(); - mockServiceProvider.GetService(typeof(TracerProvider)).Returns(null); + mockServiceProvider.GetService(typeof(TracerProvider)).Returns(null!); mockApp.Services.Returns(mockServiceProvider); // Act @@ -54,7 +54,7 @@ public void OnShutdownFlushTracer_ShouldNotThrowOnNoILoggerFactoryRegistered() mockServiceProvider .GetService(typeof(TracerProvider)) .Returns(Substitute.For()); - mockServiceProvider.GetService(typeof(ILoggerFactory)).Returns(null); + mockServiceProvider.GetService(typeof(ILoggerFactory)).Returns(null!); mockApp.Services.Returns(mockServiceProvider); // Act @@ -189,7 +189,7 @@ public void OnShutdownFlushMeter_ThrowsOnNoMeterProviderRegistered() // Arrange var mockApp = Substitute.For(); var mockServiceProvider = Substitute.For(); - mockServiceProvider.GetService(typeof(MeterProvider)).Returns(null); + mockServiceProvider.GetService(typeof(MeterProvider)).Returns(null!); mockApp.Services.Returns(mockServiceProvider); // Act @@ -212,7 +212,7 @@ public void OnShutdownFlushMeter_ShouldNotThrowOnNoILoggerFactoryRegistered() mockServiceProvider .GetService(typeof(MeterProvider)) .Returns(Substitute.For()); - mockServiceProvider.GetService(typeof(ILoggerFactory)).Returns(null); + mockServiceProvider.GetService(typeof(ILoggerFactory)).Returns(null!); mockApp.Services.Returns(mockServiceProvider); // Act diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerDiagnosticTests.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerDiagnosticTests.cs new file mode 100644 index 00000000..0f10413f --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerDiagnosticTests.cs @@ -0,0 +1,496 @@ +#if MINIMALLAMBDA_DURABLE +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using MinimalLambda.SourceGenerators.Models; + +namespace MinimalLambda.SourceGenerators.UnitTests; + +public class DurableHandlerDiagnosticTests +{ + [Fact] + public void AllowsHandlersToOmitTheEventAndDurableContext() + { + var model = TransformSingle( + """ + using System.Threading.Tasks; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static Task Handle() => Task.CompletedTask; + """); + + model.DiagnosticInfos.Should().BeEmpty(); + model.InputType.Should().Be("global::System.Object"); + model.ParameterAssignments.Should().BeEmpty(); + } + + [Fact] + public void TreatsOptionalDurableContextAndEventAsSpecialBindings() + { + var model = TransformSingle( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + """); + + model.DiagnosticInfos.Should().BeEmpty(); + model + .ParameterAssignments + .Select(parameter => parameter.Source) + .Should() + .Equal(ParameterSource.Event, ParameterSource.DurableContext); + } + + [Fact] + public void ReportsMultipleDurableContextsAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static Task Handle(IDurableContext first, IDurableContext second) => Task.CompletedTask; + """, + includeDurableReferences: true); + + // Act + var result = driver.GetRunResult(); + + // Assert + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void TreatsCancellationTokenAsSpecialBinding() + { + var model = TransformSingle( + """ + using System.Threading; + using System.Threading.Tasks; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static Task Handle(CancellationToken cancellationToken) => Task.CompletedTask; + """); + + model.DiagnosticInfos.Should().BeEmpty(); + model + .ParameterAssignments + .Should() + .ContainSingle() + .Which + .Should() + .Match(parameter => + parameter.Source == ParameterSource.CancellationToken + && parameter.Assignment == "context.CancellationToken"); + } + + [Fact] + public void ReportsInaccessibleHandlerTypesAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Handlers + { + private sealed record Request; + + internal static void Map(ILambdaInvocationBuilder app) => + app.MapDurableHandler(Handle); + + private static Task Handle([FromEvent] Request request) => Task.CompletedTask; + } + """, + includeDurableReferences: true); + + // Act + var result = driver.GetRunResult(); + + // Assert + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsInaccessibleOutputTypeAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Handlers + { + private sealed record Result; + + internal static void Map(ILambdaInvocationBuilder app) => + app.MapDurableHandler(Handle); + + private static Task Handle() => Task.FromResult(new Result()); + } + """, + includeDurableReferences: true); + + // Act + var result = driver.GetRunResult(); + + // Assert + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsUnboundTypeParametersAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Handlers + { + internal static void Map(ILambdaInvocationBuilder app) => + app.MapDurableHandler(Handle); + + private static Task Handle([FromEvent] T request) => Task.CompletedTask; + } + """, + includeDurableReferences: true); + + // Act + var result = driver.GetRunResult(); + + // Assert + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsByReferenceParametersAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System; + using System.Threading.Tasks; + using MinimalLambda.Builder; + + delegate Task RefHandler(ref int value); + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler((RefHandler)Handle); + static Task Handle(ref int value) => Task.CompletedTask; + """, + includeDurableReferences: true); + + // Act + var result = driver.GetRunResult(); + + // Assert + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsFileLocalContainingTypeAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + file static class Handlers + { + public sealed record Request; + + internal static void Map(ILambdaInvocationBuilder app) => + app.MapDurableHandler(Handle); + + private static Task Handle([FromEvent] Request request) => Task.CompletedTask; + } + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsPointerParameterAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static unsafe class Handlers + { + internal static void Map(ILambdaInvocationBuilder app) => + app.MapDurableHandler(Handle); + + private static Task Handle([FromEvent] int* request) => Task.CompletedTask; + } + """, + includeDurableReferences: true, + allowUnsafe: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsRefLikeParameterAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Handlers + { + internal ref struct Request; + + internal static void Map(ILambdaInvocationBuilder app) => + app.MapDurableHandler(Handle); + + private static Task Handle([FromEvent] Request request) => Task.CompletedTask; + } + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsEveryEventParameterAfterFirst() + { + var model = TransformSingle( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static Task Handle([FromEvent] string first, [Event] int second) => Task.CompletedTask; + """); + + model + .DiagnosticInfos + .Select(diagnostic => diagnostic.DiagnosticDescriptor.Id) + .Should() + .Equal("LH0002"); + } + + [Fact] + public void ReportsInaccessibleExplicitCustomDelegateAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + Entry.Map(); + + internal static class Entry + { + private delegate Task DurableHandler(string input, IDurableContext durable); + + internal static void Map() + { + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler((DurableHandler)Handle); + } + + private static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + } + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsCustomDelegateWrappedAsDelegateAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System; + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + delegate Task DurableHandler(string input); + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler((Delegate)(DurableHandler)Handle); + static Task Handle([FromEvent] string input) => Task.CompletedTask; + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsMismatchedExplicitDelegateAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + delegate Task ContravariantHandler(string input); + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler((ContravariantHandler)Handle); + static Task Handle([FromEvent] object input) => Task.CompletedTask; + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsAnonymousOutputTypeAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(() => Task.FromResult(new { Value = 42 })); + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsRefReturnAndSuppressesDurableAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + Entry.Map(); + + internal delegate ref Task RefHandler(); + + internal static class Entry + { + private static Task task = Task.CompletedTask; + + internal static void Map() + { + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler((RefHandler)Handle); + } + + private static ref Task Handle() => ref task; + } + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "LH0007"); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ReportsOnlyUnsupportedReturnFamilies() + { + var diagnostics = Generate( + """ + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static int Handle() => 42; + """); + + diagnostics.Should().ContainSingle(); + diagnostics[0].Id.Should().Be("LH0007"); + } + + private static DurableMethodInfo TransformSingle(string source) + { + var (_, compilation) = GeneratorTestHelpers.GenerateFromSource( + source, + includeDurableReferences: true); + var invocation = + compilation + .SyntaxTrees + .Single() + .GetRoot() + .DescendantNodes() + .OfType() + .Single(node => + node + .Expression + .ToString() + .EndsWith("MapDurableHandler", StringComparison.Ordinal)); + return HandlerSyntaxProvider + .Transform( + invocation, + compilation.GetSemanticModel(invocation.SyntaxTree), + CancellationToken.None) + .Should() + .BeOfType() + .Subject; + } + + private static Diagnostic[] Generate(string source) + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + source, + includeDurableReferences: true); + return driver.GetRunResult().Diagnostics.ToArray(); + } +} +#endif diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerDiscoveryTests.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerDiscoveryTests.cs new file mode 100644 index 00000000..ef7eb9ac --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerDiscoveryTests.cs @@ -0,0 +1,213 @@ +#if MINIMALLAMBDA_DURABLE +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using MinimalLambda.SourceGenerators.Models; +using WellKnownType = MinimalLambda.SourceGenerators.WellKnownTypes.WellKnownTypeData.WellKnownType; + +namespace MinimalLambda.SourceGenerators.UnitTests; + +public class DurableHandlerDiscoveryTests +{ + public static TheoryData SupportedHandlerForms => + new() + { + "async () => await Task.CompletedTask", + "async () => { await Task.CompletedTask; }", + "HandleAsync", + "LocalHandler", + "Handler", + }; + + [Theory] + [MemberData(nameof(SupportedHandlerForms))] + public void RecognizesSupportedHandlerForms(string handler) + { + var source = $$""" + using System; + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Program + { + private static readonly Delegate Handler = (Func)HandleAsync; + + private static Task HandleAsync() => Task.CompletedTask; + + public static void Main() + { + Task LocalHandler() => Task.CompletedTask; + var lambda = LambdaApplication.CreateBuilder().Build(); + lambda.MapDurableHandler({{handler}}); + } + } + """; + + TransformDurableCall(source, includeDurableReferences: true) + .Should() + .BeOfType(); + } + + [Fact] + public void EmitsDurableAdapterForKnownExtensionMethod() + { + const string source = """ + using System.Threading.Tasks; + using MinimalLambda; + using MinimalLambda.Builder; + + var lambda = LambdaApplication.CreateBuilder().Build(); + lambda.MapDurableHandler(() => Task.CompletedTask); + """; + + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + source, + includeDurableReferences: true); + + driver + .GetRunResult() + .GeneratedTrees + .Should() + .ContainSingle(tree => tree.FilePath.EndsWith( + "MinimalLambda.DurableHandlers.g.cs", + StringComparison.Ordinal)); + } + + [Fact] + public void IgnoresSameNamedMethodFromConsumerAssemblyWithoutDurableReferences() + { + const string source = """ + using System; + using MinimalLambda; + using MinimalLambda.Builder; + + namespace MinimalLambda.Builder + { + internal static class MapDurableHandlerLambdaApplicationExtensions + { + extension(ILambdaInvocationBuilder builder) + { + public void MapDurableHandler(Delegate handler) { } + } + } + } + + internal static class Program + { + public static void Main() + { + var lambda = LambdaApplication.CreateBuilder().Build(); + lambda.MapDurableHandler(() => { }); + } + } + """; + + TransformDurableCall(source, includeDurableReferences: false).Should().BeNull(); + + var (driver, _) = GeneratorTestHelpers.GenerateFromSource(source); + driver.GetRunResult().Diagnostics.Should().BeEmpty(); + driver.GetRunResult().GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ResolvesAwsDurableSymbolsFromConsumerCompilation() + { + const string source = """ + using MinimalLambda; + + _ = LambdaApplication.CreateBuilder(); + """; + var (_, compilation) = GeneratorTestHelpers.GenerateFromSource( + source, + includeDurableReferences: true); + var types = WellKnownTypes.WellKnownTypes.GetOrCreate(compilation); + + WellKnownType[] durableTypes = + [ + WellKnownType.Amazon_Lambda_DurableExecution_IDurableContext, + WellKnownType.Amazon_Lambda_DurableExecution_DurableFunction, + WellKnownType.Amazon_Lambda_DurableExecution_DurableExecutionInvocationInput, + WellKnownType.Amazon_Lambda_DurableExecution_DurableExecutionInvocationOutput, + ]; + + foreach (var durableType in durableTypes) + types + .Get(durableType) + .ContainingAssembly + .Name + .Should() + .Be("Amazon.Lambda.DurableExecution"); + } + + [Fact] + public void PrefersAwsDurableAssemblyWhenConsumerSpoofsMetadataName() + { + const string source = """ + namespace Amazon.Lambda.DurableExecution + { + internal interface IDurableContext { } + } + """; + var (_, compilation) = GeneratorTestHelpers.GenerateFromSource( + source, + includeDurableReferences: true); + var types = WellKnownTypes.WellKnownTypes.GetOrCreate(compilation); + + types + .Get(WellKnownType.Amazon_Lambda_DurableExecution_IDurableContext) + .ContainingAssembly + .Name + .Should() + .Be("Amazon.Lambda.DurableExecution"); + } + + [Fact] + public void OrdinaryMapHandlerStillUsesOrdinaryModel() + { + const string source = """ + using MinimalLambda; + using MinimalLambda.Builder; + + var lambda = LambdaApplication.CreateBuilder().Build(); + lambda.MapHandler(() => "ok"); + """; + + var (_, compilation) = GeneratorTestHelpers.GenerateFromSource(source); + var invocation = FindInvocation(compilation, "MapHandler"); + + HandlerSyntaxProvider + .Transform( + invocation, + compilation.GetSemanticModel(invocation.SyntaxTree), + CancellationToken.None) + .Should() + .BeOfType(); + } + + private static IMethodInfo? TransformDurableCall(string source, bool includeDurableReferences) + { + var (_, compilation) = GeneratorTestHelpers.GenerateFromSource( + source, + includeDurableReferences: includeDurableReferences); + var invocation = FindInvocation(compilation, "MapDurableHandler"); + + return HandlerSyntaxProvider.Transform( + invocation, + compilation.GetSemanticModel(invocation.SyntaxTree), + CancellationToken.None); + } + + private static InvocationExpressionSyntax FindInvocation( + Compilation compilation, + string methodName) => + compilation + .SyntaxTrees + .Single() + .GetRoot() + .DescendantNodes() + .OfType() + .Single(invocation => + invocation.Expression.ToString().EndsWith(methodName, StringComparison.Ordinal)); +} +#endif diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerEmitterTests.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerEmitterTests.cs new file mode 100644 index 00000000..77ea4b68 --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableHandlerEmitterTests.cs @@ -0,0 +1,348 @@ +#if MINIMALLAMBDA_DURABLE +using AwesomeAssertions; +using Microsoft.CodeAnalysis; + +namespace MinimalLambda.SourceGenerators.UnitTests; + +public class DurableHandlerEmitterTests +{ + [Fact] + public Task EmitsTaskAdapterThatCompiles() => + GeneratorTestHelpers.Verify( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + + static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + """, + includeDurableReferences: true); + + [Fact] + public Task PreservesAccessibleCustomDelegateType() => + GeneratorTestHelpers.Verify( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler((DurableHandler)Handle); + + static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + + public delegate Task DurableHandler(string input, IDurableContext durable); + """, + includeDurableReferences: true); + + [Fact] + public Task PreservesCustomDelegateHiddenByReadonlyDelegateField() => + GeneratorTestHelpers.Verify( + """ + using System; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + internal delegate Task DurableHandler([FromEvent] string input, IDurableContext durable); + + internal static class Program + { + private static readonly Delegate Handler = (DurableHandler)Handle; + + internal static void Main() + { + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handler); + } + + private static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + } + """, + includeDurableReferences: true); + + [Fact] + public Task EmitsLambdaLocalFunctionAndLegacyEventFormsThatCompile() => + GeneratorTestHelpers.Verify( + """ + using System.Threading.Tasks; + using Amazon.Lambda.Core; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(async ([FromEvent] string input, IDurableContext durable) => await Task.CompletedTask); + app.MapDurableHandler(async ([FromEvent] int input, IDurableContext durable) => + { + await Task.Yield(); + }); + app.MapDurableHandler(Local); + app.MapDurableHandler(Legacy); + + Task Local([FromEvent] long input, IDurableContext durable) => Task.CompletedTask; + static Task Legacy( + [Event] decimal input, + ILambdaContext first, + IDurableContext durable, + ILambdaInvocationContext second, + ILambdaContext third) => Task.CompletedTask; + """, + includeDurableReferences: true); + + [Fact] + public Task EmitsNullableClosedNestedGenericAndConstructedGenericMethodThatCompile() => + GeneratorTestHelpers.Verify( + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle.Nested>); + + static Task> Handle( + [FromEvent] Container.Nested input, + IDurableContext durable) where T : class => Task.FromResult(new Dictionary()); + + internal sealed class Container + { + internal sealed class Nested { } + } + """, + includeDurableReferences: true); + + [Fact] + public Task EmitsTaskOfTAdapterThatCompiles() => + GeneratorTestHelpers.Verify( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + + static Task Handle([FromEvent] string input, IDurableContext durable) => Task.FromResult(input.Length); + """, + includeDurableReferences: true); + + [Fact] + public Task EmitsOrderedContextDiAndKeyedBindingsInsideWorkflowClosure() => + GeneratorTestHelpers.Verify( + """ + using System.Threading.Tasks; + using Amazon.Lambda.Core; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + + static Task Handle( + IService required, + ILambdaInvocationContext invocation, + [FromEvent] string input, + ILambdaContext lambda, + IService? optional = null, + IDurableContext durable = null!, + [FromKeyedServices("key")] IService keyed = null!) => Task.FromResult(input); + + interface IService { } + """, + includeDurableReferences: true); + + [Fact] + public void SuppressesInvalidAdapterButEmitsValidAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Valid); + app.MapDurableHandler(Invalid); + + static Task Valid([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + static string Invalid([FromEvent] string input, IDurableContext durable) => input; + """, + includeDurableReferences: true); + + var result = driver.GetRunResult(); + result.Diagnostics.Select(diagnostic => diagnostic.Id).Should().Contain("LH0007"); + var durableSource = GetDurableSource(result); + durableSource.Should().Contain("MapDurableHandlerInterceptor0"); + durableSource.Should().NotContain("MapDurableHandlerInterceptor1"); + durableSource.Should().Contain("global::System.Threading.Tasks.Task (string arg0"); + durableSource.Should().NotContain("string (string arg0"); + } + + [Fact] + public void DoesNotRequireExplicitSerializerRoots() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Text.Json.Serialization; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + var builder = LambdaApplication.CreateBuilder(); + builder.Services.AddLambdaSerializerWithContext(); + var app = builder.Build(); + app.MapDurableHandler(Handle); + + static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + + [JsonSerializable(typeof(string))] + abstract partial class AppJsonContext : JsonSerializerContext + { + protected AppJsonContext() : base(null) { } + } + """, + includeDurableReferences: true); + + driver.GetRunResult().Diagnostics.Should().BeEmpty(); + } + + [Fact] + public Task MultipleDurableRegistrationsCoexistWithOrdinaryHandler() => + GeneratorTestHelpers.Verify( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapHandler(() => "ordinary"); + app.MapDurableHandler(First); + app.MapDurableHandler(Second); + app.MapDurableHandler(First); + + static Task First([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + static Task Second([FromEvent] int input, IDurableContext durable) => Task.FromResult(input); + """, + includeDurableReferences: true); + + [Fact] + public void EmitsDirectEnvelopeAdapter() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + static Task Handle([FromEvent] int input, IDurableContext durable) => Task.FromResult(input.ToString()); + """, + includeDurableReferences: true); + + var source = GetDurableSource(driver.GetRunResult()); + var wrap = source.IndexOf( + "DurableFunction.WrapAsync(", + StringComparison.Ordinal); + var deserialize = source.IndexOf( + "serializer.Deserialize(", + StringComparison.Ordinal); + var serialize = source.IndexOf( + "serializer.Serialize(output, invocationData.ResponseStream);", + StringComparison.Ordinal); + + source.Should().Contain("var serializer = context.Serializer;"); + source + .Should() + .NotContain( + "context.ServiceProvider.GetRequiredService()"); + deserialize.Should().BeGreaterThanOrEqualTo(0); + wrap.Should().BeGreaterThan(deserialize); + serialize.Should().BeGreaterThan(wrap); + source.Should().NotContain("IEventFeatureProviderFactory"); + source.Should().NotContain("IResponseFeatureProviderFactory"); + source.Should().NotContain("DurableTerminalInfrastructure"); + } + + [Fact] + public void OrdersAdaptersBySyntaxTreeThenMapSpan() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Program + { + public static void Main() + { + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(SecondInFile); + app.MapDurableHandler(FirstInFile); + } + + private static Task SecondInFile([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + private static Task FirstInFile([FromEvent] int input, IDurableContext durable) => Task.CompletedTask; + } + """, + includeDurableReferences: true, + additionalSources: + [ + ("EarlierDiscovery.cs", """ + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using MinimalLambda; + using MinimalLambda.Builder; + + internal static class Other + { + internal static void Map() + { + var app = LambdaApplication.CreateBuilder().Build(); + app.MapDurableHandler(Handle); + } + + private static Task Handle([FromEvent] long input, IDurableContext durable) => Task.CompletedTask; + } + """), + ]); + + var result = driver.GetRunResult(); + result.Diagnostics.Should().BeEmpty(); + var source = GetDurableSource(result); + var first = source.IndexOf("Task (string arg0", StringComparison.Ordinal); + var second = source.IndexOf("Task (int arg0", StringComparison.Ordinal); + var third = source.IndexOf("Task (long arg0", StringComparison.Ordinal); + first.Should().BeGreaterThan(-1); + second.Should().BeGreaterThan(first); + third.Should().BeGreaterThan(second); + } + + private static string GetDurableSource(GeneratorDriverRunResult result) => + result + .Results + .SelectMany(generator => generator.GeneratedSources) + .Single(source => source.HintName == "MinimalLambda.DurableHandlers.g.cs") + .SourceText + .ToString(); +} +#endif diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/DurableSerializerDiagnosticTests.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableSerializerDiagnosticTests.cs new file mode 100644 index 00000000..ee8fe10f --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/DurableSerializerDiagnosticTests.cs @@ -0,0 +1,37 @@ +#if MINIMALLAMBDA_DURABLE +using AwesomeAssertions; + +namespace MinimalLambda.SourceGenerators.UnitTests; + +public class DurableSerializerDiagnosticTests +{ + [Fact] + public void DoesNotInspectSerializerContextRoots() + { + var (driver, _) = GeneratorTestHelpers.GenerateFromSource( + """ + using System.Text.Json.Serialization; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + var builder = LambdaApplication.CreateBuilder(); + builder.Services.AddLambdaSerializerWithContext(); + var app = builder.Build(); + app.MapDurableHandler(Handle); + static Task Handle([FromEvent] string input, IDurableContext durable) => Task.CompletedTask; + + [JsonSerializable(typeof(string))] + abstract partial class AppJsonContext : JsonSerializerContext + { + protected AppJsonContext() : base(null) { } + } + """, + includeDurableReferences: true); + + driver.GetRunResult().Diagnostics.Should().BeEmpty(); + } +} +#endif diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/GeneratorTestHelpers.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/GeneratorTestHelpers.cs index 8b5c2356..bfd64e05 100644 --- a/tests/MinimalLambda.SourceGenerators.UnitTests/GeneratorTestHelpers.cs +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/GeneratorTestHelpers.cs @@ -1,6 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using Amazon.Lambda.Core; +#if MINIMALLAMBDA_DURABLE +using Amazon.Lambda.DurableExecution; +#endif using Amazon.Lambda.RuntimeSupport; using Amazon.Lambda.Serialization.SystemTextJson; using AwesomeAssertions; @@ -16,31 +19,56 @@ namespace MinimalLambda.SourceGenerators.UnitTests; internal static class GeneratorTestHelpers { - internal static Task Verify(string source, int expectedTrees = -1) + internal static Task Verify( + string source, + int expectedTrees = -1, + bool includeDurableReferences = false, + IReadOnlyList<(string FilePath, string Source)>? additionalSources = null, + IReadOnlyCollection? expectedDiagnosticIds = null) { - var (driver, originalCompilation) = GenerateFromSource(source); + var (driver, originalCompilation) = GenerateFromSource( + source, + includeDurableReferences: includeDurableReferences, + additionalSources: additionalSources); driver.Should().NotBeNull(); var result = driver.GetRunResult(); - result - .Diagnostics - .Should() - .BeEmpty( - "code should be generated without errors, but found:\n" - + string.Join( - "\n---\n", - result.Diagnostics.Select(e => - $" - {e.Id}: {e.GetMessage()} at {e.Location}"))); + if (expectedDiagnosticIds is null) + { + result + .Diagnostics + .Should() + .BeEmpty( + "code should be generated without errors, but found:\n" + + string.Join( + "\n---\n", + result.Diagnostics.Select(e => + $" - {e.Id}: {e.GetMessage()} at {e.Location}"))); + } + else + { + result + .Diagnostics + .Select(diagnostic => diagnostic.Id) + .Should() + .BeEquivalentTo(expectedDiagnosticIds); + result + .Diagnostics + .Should() + .OnlyContain(diagnostic => diagnostic.Severity != DiagnosticSeverity.Error); + } // Reparse generated trees with the same parse options as the original compilation // to ensure consistent syntax tree features (e.g., InterceptorsNamespaces) var parseOptions = originalCompilation.SyntaxTrees.First().Options; var reparsedTrees = result .GeneratedTrees - .Select(tree => - CSharpSyntaxTree.ParseText(tree.GetText(), (CSharpParseOptions)parseOptions)) + .Select(tree => CSharpSyntaxTree.ParseText( + tree.GetText(), + (CSharpParseOptions)parseOptions, + tree.FilePath)) .ToArray(); // Add generated trees to original compilation @@ -88,7 +116,11 @@ internal static Task Verify(string source, int expectedTrees = -1) internal static (GeneratorDriver driver, Compilation compilation) GenerateFromSource( string source, Dictionary? diagnosticsToSuppress = null, - LanguageVersion languageVersion = LanguageVersion.CSharp14) + LanguageVersion languageVersion = LanguageVersion.CSharp14, + bool includeDurableReferences = false, + IReadOnlyList<(string FilePath, string Source)>? additionalSources = null, + bool treatWarningsAsErrors = false, + bool allowUnsafe = false) { IEnumerable> features = [ @@ -100,7 +132,14 @@ internal static (GeneratorDriver driver, Compilation compilation) GenerateFromSo .WithLanguageVersion(languageVersion) .WithFeatures(features); - var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions, "InputFile.cs"); + var syntaxTrees = new List + { + CSharpSyntaxTree.ParseText(source, parseOptions, "InputFile.cs"), + }; + if (additionalSources is not null) + syntaxTrees.AddRange( + additionalSources.Select(item => + CSharpSyntaxTree.ParseText(item.Source, parseOptions, item.FilePath))); List references = [ @@ -124,9 +163,25 @@ .. Net80.References.All.ToList(), MetadataReference.CreateFromFile(typeof(ILambdaInvocationContext).Assembly.Location), ]; +#if MINIMALLAMBDA_DURABLE + if (includeDurableReferences) + { + references.Add( + MetadataReference.CreateFromFile( + typeof(MinimalLambda.DurableExecution.DurableContextExtensions).Assembly + .Location)); + references.Add( + MetadataReference.CreateFromFile(typeof(IDurableContext).Assembly.Location)); + } +#endif + var compilationOptions = new CSharpCompilationOptions( OutputKind.ConsoleApplication, - nullableContextOptions: NullableContextOptions.Enable); + nullableContextOptions: NullableContextOptions.Enable, + generalDiagnosticOption: treatWarningsAsErrors + ? ReportDiagnostic.Error + : ReportDiagnostic.Default, + allowUnsafe: allowUnsafe); if (diagnosticsToSuppress is not null) compilationOptions = @@ -134,7 +189,7 @@ .. Net80.References.All.ToList(), var compilation = CSharpCompilation.Create( "Tests", - [syntaxTree], + syntaxTrees, references, compilationOptions); diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/MinimalLambda.SourceGenerators.UnitTests.csproj b/tests/MinimalLambda.SourceGenerators.UnitTests/MinimalLambda.SourceGenerators.UnitTests.csproj index 059260ce..7cbe72bc 100644 --- a/tests/MinimalLambda.SourceGenerators.UnitTests/MinimalLambda.SourceGenerators.UnitTests.csproj +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/MinimalLambda.SourceGenerators.UnitTests.csproj @@ -9,45 +9,50 @@ true - - - - - - + + + + + + - - - - - - - + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + + - - + + **/Scriban/**/*.cs + $(DefineConstants);MINIMALLAMBDA_DURABLE - + - + \ No newline at end of file diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsLambdaLocalFunctionAndLegacyEventFormsThatCompile#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsLambdaLocalFunctionAndLegacyEventFormsThatCompile#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..3c5ed30b --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsLambdaLocalFunctionAndLegacyEventFormsThatCompile#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,179 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (string arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor1( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (int arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor2( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (long arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor3( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (decimal arg0, global::Amazon.Lambda.Core.ILambdaContext arg1, global::Amazon.Lambda.DurableExecution.IDurableContext arg2, global::MinimalLambda.ILambdaInvocationContext arg3, global::Amazon.Lambda.Core.ILambdaContext arg4) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = context; + var arg2 = durableContext; + var arg3 = context; + var arg4 = context; + return castHandler.Invoke(arg0, arg1, arg2, arg3, arg4); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsNullableClosedNestedGenericAndConstructedGenericMethodThatCompile#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsNullableClosedNestedGenericAndConstructedGenericMethodThatCompile#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..85695409 --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsNullableClosedNestedGenericAndConstructedGenericMethodThatCompile#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task.Nested?[]>> (global::Container.Nested.Nested?> arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync.Nested.Nested?>, global::System.Collections.Generic.Dictionary.Nested?[]>>( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsOrderedContextDiAndKeyedBindingsInsideWorkflowClosure#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsOrderedContextDiAndKeyedBindingsInsideWorkflowClosure#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..abbab32b --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsOrderedContextDiAndKeyedBindingsInsideWorkflowClosure#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,86 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (global::IService arg0, global::MinimalLambda.ILambdaInvocationContext arg1, string arg2, global::Amazon.Lambda.Core.ILambdaContext arg3, global::IService? arg4 = default, global::Amazon.Lambda.DurableExecution.IDurableContext arg5 = default, global::IService arg6 = default) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + if (context.ServiceProvider.GetService() is not IServiceProviderIsKeyedService) + { + throw new InvalidOperationException($"Unable to resolve service referenced by {nameof(FromKeyedServicesAttribute)}. The service provider doesn't support keyed services."); + } + var arg0 = context.ServiceProvider.GetRequiredService(); + var arg1 = context; + var arg2 = input; + var arg3 = context; + var arg4 = context.ServiceProvider.GetService(); + var arg5 = durableContext; + var arg6 = context.ServiceProvider.GetKeyedService("key"); + return castHandler.Invoke(arg0, arg1, arg2, arg3, arg4, arg5, arg6); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsTaskAdapterThatCompiles#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsTaskAdapterThatCompiles#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..82174818 --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsTaskAdapterThatCompiles#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (string arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsTaskOfTAdapterThatCompiles#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsTaskOfTAdapterThatCompiles#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..024cf758 --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.EmitsTaskOfTAdapterThatCompiles#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (string arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.MultipleDurableRegistrationsCoexistWithOrdinaryHandler#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.MultipleDurableRegistrationsCoexistWithOrdinaryHandler#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..b6b67b0c --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.MultipleDurableRegistrationsCoexistWithOrdinaryHandler#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,143 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (string arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor1( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (int arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor2( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (string arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.MultipleDurableRegistrationsCoexistWithOrdinaryHandler#MinimalLambda.InvocationHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.MultipleDurableRegistrationsCoexistWithOrdinaryHandler#MinimalLambda.InvocationHandlers.g.verified.cs new file mode 100644 index 00000000..2e8ff95e --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.MultipleDurableRegistrationsCoexistWithOrdinaryHandler#MinimalLambda.InvocationHandlers.g.verified.cs @@ -0,0 +1,75 @@ +//HintName: MinimalLambda.InvocationHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedLambdaInvocationBuilderExtensions + { + private const string EventFeatureProviderKey = "__EventFeatureProvider"; + private const string ResponseFeatureProviderKey = "__ResponseFeatureProvider"; + + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, string () => throw null!); + + application.Handle(InvocationDelegate); + + if (!application.Properties.ContainsKey(ResponseFeatureProviderKey)) + application.Properties[ResponseFeatureProviderKey] = application. + Services.GetRequiredService() + .Create(); + + return application; + + Task InvocationDelegate(ILambdaInvocationContext context) + { + var response = castHandler.Invoke(); + if (context.Features.Get() is not IResponseFeature responseFeature) + { + throw new InvalidOperationException($"Response feature for type 'string' is not available in the collection."); + } + responseFeature.SetResponse(response); + return Task.CompletedTask; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} \ No newline at end of file diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.PreservesAccessibleCustomDelegateType#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.PreservesAccessibleCustomDelegateType#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..7caf5d8f --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.PreservesAccessibleCustomDelegateType#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, (global::DurableHandler)null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.PreservesCustomDelegateHiddenByReadonlyDelegateField#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.PreservesCustomDelegateHiddenByReadonlyDelegateField#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..7caf5d8f --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.PreservesCustomDelegateHiddenByReadonlyDelegateField#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, (global::DurableHandler)null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.WarningOnlyAdapterStillEmitsAndCompiles#MinimalLambda.DurableHandlers.g.verified.cs b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.WarningOnlyAdapterStillEmitsAndCompiles#MinimalLambda.DurableHandlers.g.verified.cs new file mode 100644 index 00000000..82174818 --- /dev/null +++ b/tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/DurableHandlerEmitterTests.WarningOnlyAdapterStillEmitsAndCompiles#MinimalLambda.DurableHandlers.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MinimalLambda.DurableHandlers.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace System.Runtime.CompilerServices +{ + using System.CodeDom.Compiler; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : Attribute + { + public InterceptsLocationAttribute(int version, string data) { } + } +} + +namespace MinimalLambda.Generated +{ + using System; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + using Amazon.Lambda.DurableExecution; + using Microsoft.Extensions.DependencyInjection; + using MinimalLambda; + using MinimalLambda.Builder; + + [global::System.CodeDom.Compiler.GeneratedCode("MinimalLambda.SourceGenerators", "REPLACED")] + file static class GeneratedDurableLambdaInvocationBuilderExtensions + { + [InterceptsLocation(1, "REPLACED")] + internal static ILambdaInvocationBuilder MapDurableHandlerInterceptor0( + this ILambdaInvocationBuilder application, + Delegate handler + ) + { + var castHandler = Utilities.Cast(handler, global::System.Threading.Tasks.Task (string arg0, global::Amazon.Lambda.DurableExecution.IDurableContext arg1) => throw null!); + + application.Handle(InvocationDelegate); + + return application; + + async Task InvocationDelegate(ILambdaInvocationContext context) + { + var invocationData = context.Features.GetRequired(); + var serializer = context.Serializer; + var envelope = serializer.Deserialize( + invocationData.EventStream); + var output = await DurableFunction.WrapAsync( + (input, durableContext) => + { + var arg0 = input; + var arg1 = durableContext; + return castHandler.Invoke(arg0, arg1); + }, + envelope, + context).ConfigureAwait(false); + + invocationData.ResponseStream.SetLength(0L); + serializer.Serialize(output, invocationData.ResponseStream); + invocationData.ResponseStream.Position = 0L; + } + } + } + + file static class Utilities + { + internal static T Cast(Delegate d, T _) where T : Delegate => (T)d; + } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/MinimalLambda.Testing.UnitTests.DurableLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/MinimalLambda.Testing.UnitTests.DurableLambda.csproj new file mode 100644 index 00000000..2720d8f0 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/MinimalLambda.Testing.UnitTests.DurableLambda.csproj @@ -0,0 +1,31 @@ + + + Exe + net10.0 + preview + enable + enable + true + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + false + + + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/Program.cs new file mode 100644 index 00000000..7f72588a --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/Program.cs @@ -0,0 +1,111 @@ +using System.Collections.Concurrent; +using System.Text.Json.Serialization; +using Amazon; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +AWSConfigs.AWSRegion = RegionEndpoint.USEast1.SystemName; + +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); + +lambda.UseMiddleware(async (context, next) => +{ + var probe = context.ServiceProvider.GetRequiredService(); + probe.RecordBefore(); + + try + { + await next(context); + } + finally + { + probe.RecordAfter(); + } +}); + +lambda.MapDurableHandler(HandleAsync); + +await lambda.RunAsync(); + +static Task HandleAsync( + [FromEvent] DurableRequest request, + IDurableContext durable, + ILambdaInvocationContext invocation, + [FromServices] ILambdaSerializer serializer, + [FromServices] IDurableGreetingService service, + [FromServices] DurableMiddlewareProbe probe) +{ + if (request.ShouldFail) + { + throw new InvalidOperationException("durable fixture failure"); + } + + return Task.FromResult( + new DurableResult( + service.CreateMessage(request.Name), + durable.ExecutionContext.DurableExecutionArn, + probe.BeforeCount == 1 && probe.AfterCount == 0, + ReferenceEquals(serializer, invocation.Serializer) + && ReferenceEquals(serializer, durable.LambdaContext.Serializer))); +} + +public class DurableLambda; + +internal sealed record DurableRequest(string Name, bool ShouldFail); + +internal sealed record DurableResult( + string Message, + string ExecutionArn, + bool MiddlewareEntered, + bool SerializerIdentityPreserved); + +internal interface IDurableGreetingService +{ + string CreateMessage(string name); +} + +internal sealed class DurableGreetingService : IDurableGreetingService +{ + public string CreateMessage(string name) => $"Hello {name}!"; +} + +internal sealed class DurableMiddlewareProbe +{ + private readonly ConcurrentQueue events = new(); + private int afterCount; + private int beforeCount; + + public int BeforeCount => Volatile.Read(ref beforeCount); + + public int AfterCount => Volatile.Read(ref afterCount); + + public IReadOnlyCollection Events => events.ToArray(); + + public void RecordBefore() + { + Interlocked.Increment(ref beforeCount); + events.Enqueue("before"); + } + + public void RecordAfter() + { + Interlocked.Increment(ref afterCount); + events.Enqueue("after"); + } +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(DurableRequest))] +[JsonSerializable(typeof(DurableResult))] +internal partial class DurableLambdaJsonContext : JsonSerializerContext; diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/appsettings.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DurableLambda/appsettings.json @@ -0,0 +1 @@ +{} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DurableLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DurableLambdaTests.cs new file mode 100644 index 00000000..6342764a --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DurableLambdaTests.cs @@ -0,0 +1,104 @@ +#if NET10_0_OR_GREATER +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; + +namespace MinimalLambda.Testing.UnitTests; + +public class DurableLambdaTests +{ + private const string ExecutionArn = + "arn:aws:lambda:us-east-1:123456789012:durable-execution:ml-ses"; + + [Fact] + public async Task DurableLambda_Success_RoundTripsGeneratedAdapterPipeline() + { + // Arrange + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken); + var input = CreateInvocationInput(shouldFail: false); + + // Act + var response = await factory.TestServer.InvokeAsync( + input, + TestContext.Current.CancellationToken); + + // Assert + response.WasSuccess.Should().BeTrue(); + response.Error.Should().BeNull(); + var output = response.Response; + output.GetProperty("Status").GetString().Should().Be("SUCCEEDED"); + output.GetProperty("Result").ValueKind.Should().Be(JsonValueKind.String); + + using var resultDocument = JsonDocument.Parse(output.GetProperty("Result").GetString()!); + var result = resultDocument.RootElement; + result.GetProperty("Message").GetString().Should().Be("Hello World!"); + result.GetProperty("ExecutionArn").GetString().Should().Be(ExecutionArn); + result.GetProperty("MiddlewareEntered").GetBoolean().Should().BeTrue(); + result.GetProperty("SerializerIdentityPreserved").GetBoolean().Should().BeTrue(); + + AssertMiddlewareProbe(factory); + } + + [Fact] + public async Task DurableLambda_Failure_ReturnsFailedOuterEnvelope() + { + // Arrange + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken); + var input = CreateInvocationInput(shouldFail: true); + + // Act + var response = await factory.TestServer.InvokeAsync( + input, + TestContext.Current.CancellationToken); + + // Assert + response.WasSuccess.Should().BeTrue(); + response.Error.Should().BeNull(); + var output = response.Response; + output.GetProperty("Status").GetString().Should().Be("FAILED"); + var error = output.GetProperty("Error"); + error.GetProperty("ErrorType").GetString().Should().Be("System.InvalidOperationException"); + error.GetProperty("ErrorMessage").GetString().Should().Be("durable fixture failure"); + + AssertMiddlewareProbe(factory); + } + + private static JsonElement CreateInvocationInput(bool shouldFail) + { + var inputPayload = JsonSerializer.Serialize(new { name = "World", shouldFail }); + var envelope = JsonSerializer.Serialize( + new + { + DurableExecutionArn = ExecutionArn, + CheckpointToken = "checkpoint-token", + InitialExecutionState = new + { + Operations = new[] + { + new + { + Id = "execution-0", + Type = "EXECUTION", + Status = "STARTED", + ExecutionDetails = new { InputPayload = inputPayload } + } + } + } + }); + + using var document = JsonDocument.Parse(envelope); + return document.RootElement.Clone(); + } + + private static void AssertMiddlewareProbe(LambdaApplicationFactory factory) + { + var probe = factory.TestServer.Services.GetRequiredService(); + probe.BeforeCount.Should().Be(1); + probe.AfterCount.Should().Be(1); + probe.Events.Should().Equal("before", "after"); + } +} +#endif diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj index b7c80426..f4938350 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj @@ -8,22 +8,22 @@ $(NoWarn);IL2026;IL2087;IL2091;IL3050;IL3051;IL2075: - - - - + + + + - - - - - - - - - + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -32,18 +32,25 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - - + + + + + + - + diff --git a/tests/MinimalLambda.UnitTests/Builder/Extensions/ServiceCollectionExtensionsTests.cs b/tests/MinimalLambda.UnitTests/Builder/Extensions/ServiceCollectionExtensionsTests.cs index c5e073d7..a50878a9 100644 --- a/tests/MinimalLambda.UnitTests/Builder/Extensions/ServiceCollectionExtensionsTests.cs +++ b/tests/MinimalLambda.UnitTests/Builder/Extensions/ServiceCollectionExtensionsTests.cs @@ -30,6 +30,21 @@ public void AddLambdaHostCoreServices_WithValidServiceCollection_ReturnsServiceC result.Should().BeSameAs(serviceCollection); } + [Fact] + public void AddLambdaHostCoreServices_DoesNotRequireLambdaSerializerUntilInvocation() + { + // Arrange + using var serviceProvider = new ServiceCollection() + .AddLambdaHostCoreServices() + .BuildServiceProvider(); + + // Act + var act = () => serviceProvider.GetRequiredService(); + + // Assert + act.Should().NotThrow(); + } + [Theory] [InlineData(14)] public void AddLambdaHostCoreServices_RegistersExactlyNServices(int servicesCount) diff --git a/tests/MinimalLambda.UnitTests/Core/Context/LambdaInvocationContextFactory.cs b/tests/MinimalLambda.UnitTests/Core/Context/LambdaInvocationContextFactory.cs index bf2959b5..07f81881 100644 --- a/tests/MinimalLambda.UnitTests/Core/Context/LambdaInvocationContextFactory.cs +++ b/tests/MinimalLambda.UnitTests/Core/Context/LambdaInvocationContextFactory.cs @@ -56,6 +56,28 @@ public void Constructor_WithNullContextAccessor_SuccessfullyConstructs() factory.Should().NotBeNull(); } + [Theory] + [AutoNSubstituteData] + internal void Create_ForwardsRuntimeSerializer( + IServiceScopeFactory serviceScopeFactory, + IFeatureCollectionFactory featureCollectionFactory, + ILambdaContext lambdaContext, + IDictionary properties, + ILambdaSerializer serializer) + { + // Arrange + lambdaContext.Serializer.Returns(serializer); + var factory = new LambdaInvocationContextFactory( + serviceScopeFactory, + featureCollectionFactory); + + // Act + var context = factory.Create(lambdaContext, properties, CancellationToken.None); + + // Assert + context.Serializer.Should().BeSameAs(serializer); + } + [Theory] [AutoNSubstituteData] internal void Create_CallsFeatureCollectionFactoryCreate( @@ -76,6 +98,55 @@ internal void Create_CallsFeatureCollectionFactoryCreate( featureCollectionFactory.Received(1).Create(Arg.Any>()); } + [Fact] + public void Create_WithRealEventAndResponseProviders_UsesOneSerializerInstanceEndToEnd() + { + // Arrange + var serializer = Substitute.For(); + var expectedEvent = new SerializerIdentityEvent("event"); + var expectedResponse = new SerializerIdentityResponse("response"); + using var eventStream = new MemoryStream([1, 2, 3]); + var responseStream = new MemoryStream(); + serializer.Deserialize(eventStream).Returns(expectedEvent); + var properties = new Dictionary + { + [LambdaInvocationBuilder.EventFeatureProviderKey] = + new DefaultEventFeatureProvider(serializer), + [LambdaInvocationBuilder.ResponseFeatureProviderKey] = + new DefaultResponseFeatureProvider(serializer), + }; + var factory = new LambdaInvocationContextFactory( + Substitute.For(), + new DefaultFeatureCollectionFactory([])); + + // Act + var context = factory.Create( + Substitute.For(), + properties, + CancellationToken.None); + context.Features.Set( + new InvocationDataFeature + { + EventStream = eventStream, ResponseStream = responseStream, + }); + var eventFeature = context.Features.GetRequired(); + var responseFeature = context.Features.GetRequired(); + var actualEvent = ((IEventFeature)eventFeature).GetEvent(context); + ((IResponseFeature)responseFeature).SetResponse( + expectedResponse); + responseFeature.SerializeToStream(context); + + // Assert + actualEvent.Should().BeSameAs(expectedEvent); + serializer.Received(1).Deserialize(eventStream); + serializer + .Received(1) + .Serialize( + Arg.Is(response => + ReferenceEquals(response, expectedResponse)), + responseStream); + } + [Theory] [AutoNSubstituteData] internal void Create_WithContextAccessor_SetsContextOnAccessor( @@ -124,9 +195,14 @@ internal void Create_GetsFeaturesFromProperties( .Received(1) .Create( Arg.Is>(providers => - providers.Count() == 2 + providers != null + && providers.Count() == 2 && providers.Contains(eventFeatureProvider) && providers.Contains(responseFeatureProvider))); // ReSharper restore PossibleMultipleEnumeration } + + private sealed record SerializerIdentityEvent(string Value); + + private sealed record SerializerIdentityResponse(string Value); } diff --git a/tests/MinimalLambda.UnitTests/Core/Runtime/LambdaHandlerComposerTests.cs b/tests/MinimalLambda.UnitTests/Core/Runtime/LambdaHandlerComposerTests.cs index d4b47c26..b339061a 100644 --- a/tests/MinimalLambda.UnitTests/Core/Runtime/LambdaHandlerComposerTests.cs +++ b/tests/MinimalLambda.UnitTests/Core/Runtime/LambdaHandlerComposerTests.cs @@ -1,3 +1,5 @@ +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace MinimalLambda.UnitTests.Core.Runtime; @@ -46,6 +48,8 @@ public Fixture() CancellationTokenSource = new CancellationTokenSource(); LambdaContext = Substitute.For(); ResponseFeature = Substitute.For(); + Features = Substitute.For(); + InvocationDataFeature = Substitute.For(); LambdaInvocationContext = Substitute.For(); SetupDefaults(); @@ -55,8 +59,10 @@ public Fixture() public CancellationTokenSource CancellationTokenSource { get; } public ILambdaInvocationBuilder InvocationBuilder { get; } public IInvocationDataFeatureFactory InvocationDataFeatureFactory { get; } + public IFeatureCollection Features { get; } + public IInvocationDataFeature InvocationDataFeature { get; } public ILambdaContext LambdaContext { get; } - public ILambdaInvocationContext LambdaInvocationContext { get; } + public ILambdaInvocationContext LambdaInvocationContext { get; private set; } public ILambdaInvocationContextFactory LambdaInvocationContextFactory { get; } public ILambdaInvocationBuilderFactory LambdaInvocationBuilderFactory { get; } public IOptions Options { get; } @@ -74,31 +80,23 @@ private void SetupDefaults() .Returns(CancellationTokenSource); // Create a mock features collection - var mockFeatures = Substitute.For(); - mockFeatures.Get().Returns(ResponseFeature); + Features.Get().Returns(ResponseFeature); // Create a mock invocation data feature with response stream - var mockInvocationDataFeature = Substitute.For(); - mockInvocationDataFeature.ResponseStream.Returns(new MemoryStream()); - InvocationDataFeatureFactory - .Create(Arg.Any()) - .Returns(mockInvocationDataFeature); - - // Set up the context factory to return a mock context for any Create call + InvocationDataFeature.ResponseStream.Returns(new MemoryStream()); + InvocationDataFeatureFactory.Create(Arg.Any()).Returns(InvocationDataFeature); + + // Set up the context factory to return the current context for any Create call + LambdaInvocationContext.Features.Returns(Features); + ((IAsyncDisposable)LambdaInvocationContext) + .DisposeAsync() + .Returns(ValueTask.CompletedTask); LambdaInvocationContextFactory .Create( Arg.Any(), Arg.Any>(), Arg.Any()) - .Returns(_ => - { - // Create a new mock context for each call - LambdaInvocationContext.Features.Returns(mockFeatures); - ((IAsyncDisposable)LambdaInvocationContext) - .DisposeAsync() - .Returns(ValueTask.CompletedTask); - return LambdaInvocationContext; - }); + .Returns(_ => LambdaInvocationContext); } /// Creates a LambdaHandlerComposer with the configured mocks. @@ -321,5 +319,40 @@ public async Task RequestHandler_DisposesResources_AfterInvocation() act.Should().ThrowExactly(); } + [Theory] + [InlineData(InvocationStatus.Succeeded)] + [InlineData(InvocationStatus.Failed)] + [InlineData(InvocationStatus.Pending)] + public async Task RequestHandler_DurableEnvelope_PreservesTypedResponseAndSerializes( + InvocationStatus status) + { + // Arrange + var serializer = Substitute.For(); + var responseFeature = + new DefaultResponseFeature(serializer); + _fixture.Features.Get().Returns(responseFeature); + _fixture.Features.Get().Returns(_fixture.InvocationDataFeature); + var expected = new DurableExecutionInvocationOutput { Status = status }; + _fixture.SetInvocationHandler(_ => + { + responseFeature.SetResponse(expected); + return Task.CompletedTask; + }); + var composer = _fixture.CreateComposer(); + var handler = composer.CreateHandler(CancellationToken.None); + + // Act + await handler(new MemoryStream(), _fixture.LambdaContext); + + // Assert + responseFeature.GetResponse().Should().BeSameAs(expected); + serializer + .Received(1) + .Serialize( + Arg.Is(output => + ReferenceEquals(output, expected)), + _fixture.InvocationDataFeature.ResponseStream); + } + #endregion } diff --git a/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj b/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj index 417ce6bc..13a2fa61 100644 --- a/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj +++ b/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/package-compatibility/AotConsumer/AotConsumer.csproj b/tests/package-compatibility/AotConsumer/AotConsumer.csproj new file mode 100644 index 00000000..1b53a496 --- /dev/null +++ b/tests/package-compatibility/AotConsumer/AotConsumer.csproj @@ -0,0 +1,22 @@ + + + Exe + net10.0 + preview + enable + enable + true + true + false + true + $(WarningsAsErrors);IL2026;IL2087;IL2091;IL3050;IL3051 + false + + + + + + + \ No newline at end of file diff --git a/tests/package-compatibility/AotConsumer/Program.cs b/tests/package-compatibility/AotConsumer/Program.cs new file mode 100644 index 00000000..3ae5731a --- /dev/null +++ b/tests/package-compatibility/AotConsumer/Program.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; +using MinimalLambda.DurableExecution; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); +lambda.MapDurableHandler(HandleAsync); +await lambda.RunAsync(); + +static Task HandleAsync( + IDurableContext durable, + [FromServices] GreetingService greetingService) +{ + _ = durable.GetInvocationContext(); + _ = greetingService.Create("durable"); + return Task.CompletedTask; +} + +internal sealed class GreetingService +{ + public string Create(string name) => $"Hello, {name}!"; +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(object))] +internal partial class AotJsonContext : JsonSerializerContext; diff --git a/tests/package-compatibility/Directory.Packages.props b/tests/package-compatibility/Directory.Packages.props new file mode 100644 index 00000000..c416fb78 --- /dev/null +++ b/tests/package-compatibility/Directory.Packages.props @@ -0,0 +1,5 @@ + + + false + + \ No newline at end of file diff --git a/tests/package-compatibility/InvalidSignatureConsumer/InvalidSignatureConsumer.csproj b/tests/package-compatibility/InvalidSignatureConsumer/InvalidSignatureConsumer.csproj new file mode 100644 index 00000000..0d237b64 --- /dev/null +++ b/tests/package-compatibility/InvalidSignatureConsumer/InvalidSignatureConsumer.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + preview + enable + enable + true + false + + + + + + + \ No newline at end of file diff --git a/tests/package-compatibility/InvalidSignatureConsumer/Program.cs b/tests/package-compatibility/InvalidSignatureConsumer/Program.cs new file mode 100644 index 00000000..b9d8470e --- /dev/null +++ b/tests/package-compatibility/InvalidSignatureConsumer/Program.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); + +await using var lambda = builder.Build(); +lambda.MapDurableHandler(HandleAsync); +await lambda.RunAsync(); + +static Task HandleAsync(ref int value) +{ + _ = value; + return Task.CompletedTask; +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +internal partial class InvalidJsonContext : JsonSerializerContext; diff --git a/tests/package-compatibility/OldCoreConsumer/OldCoreConsumer.csproj b/tests/package-compatibility/OldCoreConsumer/OldCoreConsumer.csproj new file mode 100644 index 00000000..f6f6d092 --- /dev/null +++ b/tests/package-compatibility/OldCoreConsumer/OldCoreConsumer.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + enable + NU1605 + false + + + + + + + \ No newline at end of file diff --git a/tests/package-compatibility/OldCoreConsumer/Program.cs b/tests/package-compatibility/OldCoreConsumer/Program.cs new file mode 100644 index 00000000..15345c46 --- /dev/null +++ b/tests/package-compatibility/OldCoreConsumer/Program.cs @@ -0,0 +1 @@ +Console.WriteLine("restore must fail before this consumer builds"); diff --git a/tests/package-compatibility/TaskConsumer/Program.cs b/tests/package-compatibility/TaskConsumer/Program.cs new file mode 100644 index 00000000..5c41d544 --- /dev/null +++ b/tests/package-compatibility/TaskConsumer/Program.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; +using MinimalLambda.DurableExecution; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); +lambda.MapDurableHandler(HandleAsync); +await lambda.RunAsync(); + +static Task HandleAsync( + [FromEvent] WorkflowInput input, + IDurableContext durable, + ILambdaContext lambdaContext, + ILambdaInvocationContext invocationContext, + [FromServices] WorkflowProbe probe) +{ + _ = durable.GetInvocationContext(); + _ = lambdaContext.AwsRequestId; + _ = invocationContext.Serializer; + probe.Observe(input.Name); + return Task.CompletedTask; +} + +internal sealed record WorkflowInput(string Name); + +internal sealed class WorkflowProbe +{ + public void Observe(string value) => _ = value.Length; +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(WorkflowInput))] +internal partial class TaskJsonContext : JsonSerializerContext; diff --git a/tests/package-compatibility/TaskConsumer/TaskConsumer.csproj b/tests/package-compatibility/TaskConsumer/TaskConsumer.csproj new file mode 100644 index 00000000..edb11aac --- /dev/null +++ b/tests/package-compatibility/TaskConsumer/TaskConsumer.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + preview + enable + enable + true + false + + + + + + \ No newline at end of file diff --git a/tests/package-compatibility/TypedConsumer/Program.cs b/tests/package-compatibility/TypedConsumer/Program.cs new file mode 100644 index 00000000..ac081e07 --- /dev/null +++ b/tests/package-compatibility/TypedConsumer/Program.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; +using Amazon.Lambda.Core; +using Amazon.Lambda.DurableExecution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; +using MinimalLambda.DurableExecution; + +var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddLambdaSerializerWithContext(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); +lambda.MapDurableHandler(HandleAsync); +await lambda.RunAsync(); + +static Task HandleAsync( + [FromEvent] WorkflowInput input, + IDurableContext durable, + ILambdaContext lambdaContext, + ILambdaInvocationContext invocationContext, + [FromServices] IGreetingService greetingService) +{ + _ = durable.GetInvocationContext(); + _ = lambdaContext.AwsRequestId; + _ = invocationContext.Serializer; + return Task.FromResult(new WorkflowOutput(greetingService.Create(input.Name))); +} + +internal sealed record WorkflowInput(string Name); + +internal sealed record WorkflowOutput(string Message); + +internal interface IGreetingService +{ + string Create(string name); +} + +internal sealed class GreetingService : IGreetingService +{ + public string Create(string name) => $"Hello, {name}!"; +} + +[JsonSerializable(typeof(DurableExecutionInvocationInput))] +[JsonSerializable(typeof(DurableExecutionInvocationOutput))] +[JsonSerializable(typeof(WorkflowInput))] +[JsonSerializable(typeof(WorkflowOutput))] +internal partial class TypedJsonContext : JsonSerializerContext; diff --git a/tests/package-compatibility/TypedConsumer/TypedConsumer.csproj b/tests/package-compatibility/TypedConsumer/TypedConsumer.csproj new file mode 100644 index 00000000..0d237b64 --- /dev/null +++ b/tests/package-compatibility/TypedConsumer/TypedConsumer.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + preview + enable + enable + true + false + + + + + + + \ No newline at end of file