feat: guarantee manual fallback and in-session draft recovery (#12)
Quality gates / quality (pull_request) Successful in 1m54s
Quality gates / quality (pull_request) Successful in 1m54s
Logging must never depend on AI availability, and an unsaved entry must not disappear when the user navigates away mid-log. - add src/vision-readiness.js so the photo flow consumes one readiness contract - when the worker is offline/disabled/unknown, offer no AI action and no analysis-consent prompt, and keep manual Bristol entry one action away - add src/draft.js and make the draft own the photo so a chosen photo and entered context survive a recoverable close or navigation, with a visible kept-draft summary and an explicit start over - drafts stay in session memory only: a photo awaiting consent is never persisted, and reload starts clean - service worker now refuses to cache image bodies in addition to analysis requests and no-store responses - tests: 2 new unit files, 3 service-worker privacy assertions (1 hold-out), 1 browser acceptance file, release-demo and CI/package assertions - release demo now visibly replays the vision-offline pass and the retained draft with synthetic data and an in-script assertion
This commit is contained in:
1 parent
5dac04b1e6
commit
dce668e72b
20 files changed
+374
-22
No files matched your search
@@ -111,6 +111,7 @@ def main() -> int:
|
||||
run(["npm", "run", "test:photo"], tree)
|
||||
run(["npm", "run", "test:mobile-capture"], tree)
|
||||
run(["npm", "run", "test:sleek"], tree)
|
||||
run(["npm", "run", "test:manual-fallback"], tree)
|
||||
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||
demo_env = dict(server_env)
|
||||
demo_env["TIMMY_RELEASE_VERSION"] = version
|
||||
@@ -134,7 +135,7 @@ def main() -> int:
|
||||
contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg"
|
||||
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree)
|
||||
run(["npm", "audit", "--audit-level=high"], tree)
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"):
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"):
|
||||
run(["node", "--check", file], tree)
|
||||
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
||||
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
|
||||
@@ -158,6 +159,7 @@ def main() -> int:
|
||||
f"- Source commit: [{commit}]({PUBLIC_REPO}/commit/{commit})\n"
|
||||
f"- Build date (UTC): {release_date}\n"
|
||||
"- Sleek three-destination shell with one dominant photo action and quiet manual fallback\n"
|
||||
"- Manual fallback: vision-offline logging stays one action away with an in-session draft and explicit start over\n"
|
||||
"- Smart free-text Timmy chat backed by authenticated server-side Hermes session continuity\n"
|
||||
"- Agent safety: exact-origin HttpOnly session, bounded text-only ledger context, no browser credentials or session IDs\n"
|
||||
"- Vision profiles: hosted and self-hosted SmolVLM2 bootstrap\n"
|
||||
@@ -205,13 +207,14 @@ def main() -> int:
|
||||
"codec": "h264",
|
||||
"pixel_format": "yuv420p",
|
||||
"recorded_from_working_app": True,
|
||||
"fixture_data": "synthetic-Type-4, deterministic vision suggestion, and deterministic Hermes chat response",
|
||||
"fixture_data": "synthetic-Type-4, deterministic vision suggestion, offline vision status, and deterministic Hermes chat response",
|
||||
},
|
||||
"gates": {
|
||||
"unit_security": "passed",
|
||||
"mobile_green_path": "passed",
|
||||
"photo_first_acceptance": "passed",
|
||||
"sleek_hermes_chat_acceptance": "passed",
|
||||
"manual_fallback_draft_recovery": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
|
||||
@@ -19,6 +19,7 @@ const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
let visionOffline = false;
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -27,8 +28,9 @@ await page.route('**/api/vision-status', route => route.fulfill({
|
||||
profile: 'selfhost',
|
||||
processor: 'self-hosted',
|
||||
model: 'SmolVLM2-2.2B-Instruct',
|
||||
providerReady: true,
|
||||
modelSeen: true,
|
||||
// The second pass flips the app's own readiness contract so no AI control is offered at all.
|
||||
providerReady: !visionOffline,
|
||||
modelSeen: !visionOffline,
|
||||
}),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => route.fulfill({
|
||||
@@ -137,6 +139,23 @@ await caption('AI may suggest visible form, broad color, and image quality — n
|
||||
await tap('#use-suggestion', 450);
|
||||
await caption('The user reviews every suggestion before saving', 1100);
|
||||
await tap('#close-sheet', 450);
|
||||
await caption('Closing the log keeps the draft in this session instead of throwing it away', 1600);
|
||||
await caption('Now the same photo flow while the vision worker is offline', 1500);
|
||||
visionOffline = true;
|
||||
await tap('[data-scan]', 550);
|
||||
await page.getByText(/Vision worker offline/i).waitFor();
|
||||
await caption('Vision worker offline — the AI and consent steps disappear instead of failing after upload', 1900);
|
||||
await indicate('label[for="gallery-photo"]');
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.getByText(/Ready for your review/i).waitFor();
|
||||
await caption('No consent prompt and no upload when analysis cannot run', 1600);
|
||||
await tap('#manual-from-scan', 500);
|
||||
const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 });
|
||||
if (!/Type 4/.test(keptDraft)) throw new Error('The retained draft must show exactly what the user is keeping');
|
||||
await caption('One tap still reaches manual Bristol entry', 1500);
|
||||
await tap('#discard-draft', 500);
|
||||
await caption('Start over clears the kept draft — nothing lingers silently', 1500);
|
||||
await tap('#close-sheet', 450);
|
||||
await tap('[data-view="timmy"]', 550);
|
||||
await page.getByText(/Hermes Agent connected/i).waitFor();
|
||||
await caption('Hermes Agent connected — free-text, contextual, and server-side', 1400);
|
||||
|
||||
Reference in new issue
Block a user