Compare commits
4 Commits
37b17f7284
...
3e269a4d4c
| Author | SHA1 | Date |
|---|---|---|
|
|
3e269a4d4c | |
|
|
d4aed475a2 | |
|
|
276e03cc87 | |
|
|
87e9181e11 |
|
|
@ -0,0 +1,186 @@
|
||||||
|
# Recipe Manager Agentic Runbook
|
||||||
|
|
||||||
|
Last updated: 2026-03-24
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Operational guide for running the Recipe Manager agent harness reliably.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Execution Model
|
||||||
|
|
||||||
|
- One task per iteration
|
||||||
|
- One commit per iteration
|
||||||
|
- TODO.md is the authoritative queue
|
||||||
|
- Work only in:
|
||||||
|
`/home/paulh/.openclaw/workspace/projects/recipe-manager`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Guards (Must Pass Before Coding)
|
||||||
|
|
||||||
|
### Pre-flight checks
|
||||||
|
Before any iteration starts, verify these files exist:
|
||||||
|
- `AGENT_INSTRUCTIONS.md`
|
||||||
|
- `TODO.md`
|
||||||
|
|
||||||
|
If missing, fail with:
|
||||||
|
`STUCK: bad working dir or missing harness files at /home/paulh/.openclaw/workspace/projects/recipe-manager`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring Signals (How we know it's working)
|
||||||
|
|
||||||
|
A run is healthy only when all 3 are true:
|
||||||
|
1. Active session updated recently (`recipe-v1-iter*`)
|
||||||
|
2. New git commits are landing
|
||||||
|
3. TODO checkboxes advance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Failure Modes and Fixes
|
||||||
|
|
||||||
|
## 1) Wrong working directory
|
||||||
|
### Symptom
|
||||||
|
Agent says AGENT_INSTRUCTIONS.md / TODO.md missing in `/workspace`.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
Spawner started outside project root.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Force absolute project path in every task prompt
|
||||||
|
- Add mandatory pre-flight guard
|
||||||
|
- Relaunch fresh iteration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) False “iteration already running”
|
||||||
|
### Symptom
|
||||||
|
Auto-iterator repeatedly prints SKIP even when no coding progress occurs.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
It treated stale historical sessions as active.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Treat a session as active only if updated recently (freshness window)
|
||||||
|
- Use current phase labels only (`recipe-v1-iter*`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Label mismatch across phases
|
||||||
|
### Symptom
|
||||||
|
Monitor reports wrong status or misses active runs.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
MVP labels (`recipe-mvp-*`) used during v1 phase.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Update monitor + iterator to phase-specific labels
|
||||||
|
- Standardize naming per phase:
|
||||||
|
- MVP: `recipe-mvp-iter*`
|
||||||
|
- v1: `recipe-v1-iter*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Model/provider auth mismatch
|
||||||
|
### Symptom
|
||||||
|
Cron jobs fail with:
|
||||||
|
- `No API key found for provider openai`
|
||||||
|
- or Copilot cooldown rate-limit errors
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
Using `openai/...` models without OpenAI API key.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Use OAuth provider model prefix: `openai-codex/...`
|
||||||
|
- For this project, prefer:
|
||||||
|
`openai-codex/gpt-5.3-codex`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Environment capability mismatch (Docker)
|
||||||
|
### Symptom
|
||||||
|
Task fails with `docker: command not found`.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
Agent runtime host lacks Docker.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Mark as manual host validation task
|
||||||
|
- Continue with unblocked tasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Runtime module mismatch (ESM/CommonJS)
|
||||||
|
### Symptom
|
||||||
|
Backend runtime error: `require is not defined`.
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
Using `require()` in ESM code path.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Replace `require('fs')` calls with ESM imports (`writeFileSync`)
|
||||||
|
- Build + rerun server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Controls
|
||||||
|
|
||||||
|
## Pause automation
|
||||||
|
Disable both jobs:
|
||||||
|
- Recipe Manager Auto-Iterator
|
||||||
|
- Recipe Manager Progress Monitor
|
||||||
|
|
||||||
|
## Resume automation
|
||||||
|
Enable both jobs, then manually kick one fresh iteration.
|
||||||
|
|
||||||
|
## Manual override iteration (safe restart)
|
||||||
|
Spawn one explicit iteration with:
|
||||||
|
- absolute project path
|
||||||
|
- pre-flight guard
|
||||||
|
- one-task/one-commit rule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion Definition
|
||||||
|
|
||||||
|
A phase is complete when:
|
||||||
|
1. No unchecked tasks remain in that phase section of TODO.md
|
||||||
|
2. Latest iteration exits without STUCK/ERROR
|
||||||
|
3. Commit + TODO update are present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Cadence
|
||||||
|
|
||||||
|
- Auto-iterator: every 15 minutes
|
||||||
|
- Progress monitor: every 5 minutes (high visibility mode)
|
||||||
|
|
||||||
|
If noisy, set monitor to every 10–15 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handoff Checklist (Before ending a session)
|
||||||
|
|
||||||
|
- [ ] Confirm latest commit hash
|
||||||
|
- [ ] Confirm active phase + next unchecked task
|
||||||
|
- [ ] Confirm auto-iterator enabled/disabled status
|
||||||
|
- [ ] Confirm monitor enabled/disabled status
|
||||||
|
- [ ] Confirm no stale active-session false positives
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Status Commands
|
||||||
|
|
||||||
|
### Latest commit
|
||||||
|
`git log -1 --oneline`
|
||||||
|
|
||||||
|
### Next tasks
|
||||||
|
`grep -n "^- \[ \]" TODO.md | head`
|
||||||
|
|
||||||
|
### Recent progress
|
||||||
|
`git log --oneline -5`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This runbook should be updated whenever a new failure mode appears.
|
||||||
6
TODO.md
6
TODO.md
|
|
@ -28,11 +28,11 @@ MVP is functionally complete (core app + docs + tests).
|
||||||
- [x] Add backend import endpoint: `POST /api/import/url`
|
- [x] Add backend import endpoint: `POST /api/import/url`
|
||||||
- [x] Implement Schema.org Recipe JSON-LD parser service
|
- [x] Implement Schema.org Recipe JSON-LD parser service
|
||||||
- [x] Normalize parsed recipe into internal Recipe draft format
|
- [x] Normalize parsed recipe into internal Recipe draft format
|
||||||
- [ ] Add import endpoint tests (valid recipe page, non-recipe page, malformed JSON-LD)
|
- [x] Add import endpoint tests (valid recipe page, non-recipe page, malformed JSON-LD)
|
||||||
|
|
||||||
### Phase 2: Import UI
|
### Phase 2: Import UI
|
||||||
- [ ] Add “Import from URL” UI page/form in frontend
|
- [x] Add “Import from URL” UI page/form in frontend
|
||||||
- [ ] Show parsed preview (title, ingredients, steps, source URL)
|
- [x] Show parsed preview (title, ingredients, steps, source URL)
|
||||||
- [ ] Allow edit-before-save flow, then save to existing create recipe API
|
- [ ] Allow edit-before-save flow, then save to existing create recipe API
|
||||||
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
|
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { RecipeListPage } from './pages/RecipeListPage';
|
||||||
import { RecipeDetailPage } from './pages/RecipeDetailPage';
|
import { RecipeDetailPage } from './pages/RecipeDetailPage';
|
||||||
import { CookModePage } from './pages/CookModePage';
|
import { CookModePage } from './pages/CookModePage';
|
||||||
import { NotFoundPage } from './pages/NotFoundPage';
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
|
import { ImportUrlPage } from './pages/ImportUrlPage';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { ToastContainer } from './components/Toast';
|
import { ToastContainer } from './components/Toast';
|
||||||
import { useToast } from './hooks/useToast';
|
import { useToast } from './hooks/useToast';
|
||||||
|
|
@ -65,6 +66,9 @@ function App() {
|
||||||
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
|
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
|
||||||
Add Recipe
|
Add Recipe
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/import/url" className={linkClass('/import/url')}>
|
||||||
|
Import URL
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,6 +80,7 @@ function App() {
|
||||||
<Route path="/recipe/new" element={<RecipeDetailPage />} />
|
<Route path="/recipe/new" element={<RecipeDetailPage />} />
|
||||||
<Route path="/recipe/:id" element={<RecipeDetailPage />} />
|
<Route path="/recipe/:id" element={<RecipeDetailPage />} />
|
||||||
<Route path="/recipe/:id/cook" element={<CookModePage />} />
|
<Route path="/recipe/:id/cook" element={<CookModePage />} />
|
||||||
|
<Route path="/import/url" element={<ImportUrlPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { importRecipeFromUrl } from '../services/api';
|
||||||
|
import type { UrlImportResult } from '../types/recipe';
|
||||||
|
|
||||||
|
export function ImportUrlPage() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<UrlImportResult | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await importRecipeFromUrl(url);
|
||||||
|
setResult(imported);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Paste a recipe URL and we'll try to fetch the page and extract recipe data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Recipe URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="import-url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
placeholder="https://example.com/my-recipe"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Importing…' : 'Import URL'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-800">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="mt-4 bg-white border border-gray-200 rounded-lg p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Parsed Preview</h3>
|
||||||
|
<p className="text-sm text-gray-600">Source: {result.source_url}</p>
|
||||||
|
<p className="text-sm text-gray-600">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.draft_recipe ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold text-gray-900">{result.draft_recipe.title}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Ingredients</h5>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-gray-800">
|
||||||
|
{result.draft_recipe.ingredients.map((ingredient, index) => (
|
||||||
|
<li key={`${ingredient}-${index}`}>{ingredient}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Steps</h5>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-gray-800">
|
||||||
|
{result.draft_recipe.instructions.map((instruction, index) => (
|
||||||
|
<li key={`${instruction}-${index}`}>{instruction}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 text-sm">
|
||||||
|
Could not parse a recipe preview from this URL.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* API client for Recipe Manager backend
|
* API client for Recipe Manager backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Recipe, Tag, ApiResponse } from '../types/recipe';
|
import type { Recipe, Tag, ApiResponse, UrlImportResult } from '../types/recipe';
|
||||||
|
|
||||||
// Use relative URL - nginx will proxy to backend in production
|
// Use relative URL - nginx will proxy to backend in production
|
||||||
// For local development (npm run dev), configure vite.config.ts proxy
|
// For local development (npm run dev), configure vite.config.ts proxy
|
||||||
|
|
@ -239,3 +239,28 @@ export async function deleteTag(id: number): Promise<void> {
|
||||||
throw new Error(result.error || 'Failed to delete tag');
|
throw new Error(result.error || 'Failed to delete tag');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import recipe data from URL
|
||||||
|
*/
|
||||||
|
export async function importRecipeFromUrl(url: string): Promise<UrlImportResult> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/import/url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to import URL: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<UrlImportResult> = await response.json();
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.error || 'Failed to import URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,14 @@ export interface ApiResponse<T> {
|
||||||
data?: T;
|
data?: T;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL import result returned by backend import endpoint
|
||||||
|
*/
|
||||||
|
export interface UrlImportResult {
|
||||||
|
source_url: string;
|
||||||
|
html: string;
|
||||||
|
json_ld_blocks: string[];
|
||||||
|
draft_recipe: Recipe | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,37 @@ describe('Import API', () => {
|
||||||
expect(response.body.data.draft_recipe).toBeNull();
|
expect(response.body.data.draft_recipe).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should ignore malformed JSON-LD and return null draft when no valid recipe blocks exist', async () => {
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">{"@type":"Recipe","name":"Broken JSON"</script>
|
||||||
|
<script type="application/ld+json">{"@type":"Thing","name":"Still not recipe"}</script>
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
|
||||||
|
text: async () => html,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/import/url')
|
||||||
|
.send({ url: 'https://example.com/malformed-jsonld' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.data.json_ld_blocks).toEqual([
|
||||||
|
'{"@type":"Recipe","name":"Broken JSON"',
|
||||||
|
'{"@type":"Thing","name":"Still not recipe"}'
|
||||||
|
]);
|
||||||
|
expect(response.body.data.draft_recipe).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return an error for non-HTML responses', async () => {
|
it('should return an error for non-HTML responses', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue