Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b7feddc8 | ||
|
|
d027a1e791 | ||
|
|
503efaf959 | ||
|
|
29ea045671 | ||
|
|
5505f3c3ef | ||
|
|
4cc5f4f7b1 | ||
|
|
7c5c466b83 | ||
|
|
5aee7436f2 | ||
|
|
3e87f881c5 | ||
|
|
dce668e72b | ||
|
|
5dac04b1e6 | ||
|
|
87c7680aad | ||
|
|
463dff7f22 |
No files matched your search
@@ -50,7 +50,11 @@ jobs:
|
||||
done
|
||||
npm run test:ui
|
||||
npm run test:photo
|
||||
npm run test:mobile-capture
|
||||
npm run test:sleek
|
||||
npm run test:manual-fallback
|
||||
npm run test:provisional
|
||||
npm run test:followup
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high
|
||||
- name: Syntax checks
|
||||
|
||||
@@ -29,6 +29,7 @@ Most stool trackers are sterile diaries. Most AI-health demos overclaim. Timmy c
|
||||
- Smart free-text chat with local fallback and an optional fully tool-capable Hermes backend behind exact-origin authentication.
|
||||
- Portable JSON export and delete-all control.
|
||||
- A Timmy voice that is playful about logging and serious about red flags.
|
||||
- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log: difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports receive proportionate non-diagnostic self-care; a confirmed red flag routes into the existing deterministic escalation, and dark brown is never silently escalated as black stool.
|
||||
|
||||
## Honest AI boundary
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ npm test
|
||||
npm run test:ui # server required on port 4173
|
||||
npm run test:photo # mocked positive suggestion through the real browser UX
|
||||
npm run test:sleek # simplified 390x844 shell + free-text Hermes chat
|
||||
npm run test:manual-fallback # vision-offline manual path + in-session draft recovery
|
||||
npm audit --audit-level=high
|
||||
```
|
||||
|
||||
@@ -112,6 +113,9 @@ npm audit --audit-level=high
|
||||
- Free-text smart chat with real server-side Hermes session continuity and local fallback
|
||||
- Authenticated, same-origin, bounded browser-to-agent gateway with no browser-side credentials
|
||||
- Red-flag symptom escalation
|
||||
- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log — difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports get concise non-diagnostic self-care that never claims a photo ruled out bleeding; a confirmed red flag sets the same deterministic escalation the manual checkboxes use
|
||||
- Manual fallback that never depends on AI: when the vision worker is offline or disabled, no AI or consent control is offered and manual Bristol entry stays one action away
|
||||
- Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies
|
||||
- Local browser ledger, calendar, pattern summary, JSON portability, and delete-all
|
||||
- Installable/offline PWA shell for the manual and saved-ledger paths
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||
import { mergeVisualSuggestion } from './src/analysis.js';
|
||||
import { createDraft, draftNotice, isDraftDirty } from './src/draft.js';
|
||||
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
|
||||
import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp, needsHardStoolFollowUp } from './src/followup.js';
|
||||
import { visionReadiness } from './src/vision-readiness.js';
|
||||
|
||||
const runtimeConfig = {
|
||||
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
|
||||
@@ -22,17 +26,59 @@ if (BASE_PATH === '/') {
|
||||
const app = document.querySelector('#app');
|
||||
let entries = loadEntries();
|
||||
let view = 'home';
|
||||
let photoDataUrl = '';
|
||||
let photoHint = '';
|
||||
let aiSuggestion = null;
|
||||
let suggestionState = createSuggestionState(null);
|
||||
let visionStatus = null;
|
||||
let photoFirstMode = 'pick';
|
||||
let agentStatus = null;
|
||||
let chatBusy = false;
|
||||
let chatError = '';
|
||||
let chatMessages = [{ role: 'timmy', text: 'Ask me about your confirmed logs, visible patterns, privacy, or how Timmy works.' }];
|
||||
const draft = () => ({ bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: '', symptoms: {} });
|
||||
let form = draft();
|
||||
let draftResumed = false;
|
||||
let form = createDraft();
|
||||
let followUpAnswers = createFollowUpAnswers();
|
||||
|
||||
function draftBanner(){
|
||||
if(!draftResumed||!isDraftDirty(form))return '';
|
||||
return `<div class="draft-resume"><span role="status">Draft kept in this session: <strong>${esc(draftNotice(form))}</strong></span><button class="text-action compact" id="discard-draft">Start over</button></div>`;
|
||||
}
|
||||
function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()}
|
||||
function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null);followUpAnswers=createFollowUpAnswers()}
|
||||
function resetSuggestion(){aiSuggestion=null;suggestionState=createSuggestionState(null)}
|
||||
// A single place where provisional, confirmed, and corrected AI states become visible copy.
|
||||
function provenanceBlock(){
|
||||
const copy=provenanceCopy(suggestionState,form);
|
||||
if(!copy.label)return '';
|
||||
return `<div class="ai-prefill ai-provenance" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)}</span><strong>Type ${form.bristolType} · ${esc(form.color)}</strong><small>You’re in charge—tap any type to correct it.</small><p class="provenance-note fine" role="status">${esc(copy.detail)}</p></div>`;
|
||||
}
|
||||
function refreshProvenance(){const copy=provenanceCopy(suggestionState,form);document.querySelectorAll('.provenance-note').forEach(node=>{node.textContent=copy.detail||''});const container=document.querySelector('.ai-provenance');if(container&©.aria)container.setAttribute('aria-label',copy.aria)}
|
||||
|
||||
// Hard-stool follow-up (field finding #67). After a Type 1-2 entry Timmy asks a short
|
||||
// user-reported follow-up: soreness, difficulty passing, hydration, and black/tarry
|
||||
// character are never inferred from pixels. A confirmed red-flag answer writes the same
|
||||
// symptom flag the rest of the app already escalates on, so the safety path stays one path.
|
||||
function followUpState(){return {...followUpAnswers}}
|
||||
function syncFollowUpFlags(){for(const question of HARD_STOOL_QUESTIONS){if(!question.urgentFlag)continue;const box=document.querySelector(`[data-symptom="${question.urgentFlag}"]`);if(box)box.checked=form.symptoms[question.urgentFlag]===true}}
|
||||
function followUpOutput(verdict){
|
||||
if(verdict.urgent)return `<div id="followup-guidance" class="followup-guidance escalated" role="status"><strong>That answer sets the red-flag check below.</strong><p class="fine">Timmy is not diagnosing you here. The answer you gave is what triggers this, and the check below is the same one manual logging uses.</p></div>`;
|
||||
if(!verdict.answered)return '<p class="fine" id="followup-guidance" role="status">Answer what applies. Anything you skip stays unanswered, and none of it is guessed from your photo.</p>';
|
||||
return `<div id="followup-guidance" class="followup-guidance" role="status"><strong>Mild report — general self-care, not a diagnosis.</strong><ul>${verdict.guidance.map(line=>`<li>${esc(line)}</li>`).join('')}</ul><p class="fine">${esc(verdict.note)}</p><p class="fine">${esc(verdict.seekCare)}</p></div>`;
|
||||
}
|
||||
function followUpBlock(){
|
||||
if(!needsHardStoolFollowUp(form))return '';
|
||||
const state=followUpState();
|
||||
const rows=HARD_STOOL_QUESTIONS.map(question=>`<div class="followup-row" data-question="${question.id}"><span>${esc(question.label)}${question.urgentFlag?'<small class="followup-map">Also sets the red-flag check below.</small>':''}</span><div class="followup-toggle"><button type="button" data-followup="${question.id}" data-answer="yes" class="${state[question.id]===true?'selected':''}" aria-pressed="${state[question.id]===true}">Yes</button><button type="button" data-followup="${question.id}" data-answer="no" class="${state[question.id]===false?'selected':''}" aria-pressed="${state[question.id]===false}">No</button></div></div>`).join('');
|
||||
return `<section class="followup" data-followup-block aria-label="Hard stool follow-up questions"><h3>Type ${form.bristolType} — a few quick questions</h3><p class="fine">Timmy never guesses these from your photo. You report them, and you can skip any of them.</p>${rows}<div id="followup-output">${followUpOutput(evaluateHardStoolFollowUp(state))}</div></section>`;
|
||||
}
|
||||
function refreshFollowUp(){
|
||||
const state=followUpState();
|
||||
if(document.querySelector('[data-followup-block]')){
|
||||
document.querySelectorAll('[data-followup]').forEach(button=>{const selected=state[button.dataset.followup]===(button.dataset.answer==='yes');button.classList.toggle('selected',selected);button.setAttribute('aria-pressed',String(selected))});
|
||||
const output=document.querySelector('#followup-output');if(output)output.innerHTML=followUpOutput(evaluateHardStoolFollowUp(state));
|
||||
}
|
||||
const urgent=detectUrgentFlags(form.symptoms);const box=document.querySelector('#urgent-box');if(box)box.innerHTML=urgent.urgent?alertHtml(urgent.message):'';
|
||||
}
|
||||
|
||||
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||||
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
|
||||
@@ -122,7 +168,7 @@ function exportData(){const blob=new Blob([exportLedger(entries)],{type:'applica
|
||||
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
|
||||
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
|
||||
|
||||
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
||||
function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
||||
async function loadVisionStatus(){
|
||||
try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
|
||||
catch{visionStatus={enabled:false,providerReady:false}}
|
||||
@@ -135,45 +181,46 @@ function visionStatusHtml(){
|
||||
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
|
||||
}
|
||||
function photoFirstBody(mode,error=''){
|
||||
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
|
||||
if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
|
||||
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div>${error?`<p class="capture-status" role="status">${esc(error)}</p>`:''}<div class="capture-choice-grid"><label class="photo-capture" for="camera-photo"><b>📷</b><strong>Take photo</strong><span>Use the rear camera</span><input id="camera-photo" type="file" accept="image/*" capture="environment"></label><label class="photo-capture" for="gallery-photo"><b>▧</b><strong>Choose from gallery</strong><span>JPEG, PNG, or WebP</span><input id="gallery-photo" type="file" accept="image/*"></label></div><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
|
||||
if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo kept on this device"><p class="quality-note">${esc(photoHint)}</p><p class="fine">${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.</p><button class="btn btn-primary btn-wide" id="manual-from-scan">${esc(readiness.manualLabel)}</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
|
||||
if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
|
||||
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||||
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
|
||||
if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `<div class="scan-result provisional" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${suggestionState.suggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(suggestionState.suggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="provenance-note" role="status">${esc(copy.detail)}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;}
|
||||
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||||
}
|
||||
function showPhotoFirst(mode='pick',error=''){
|
||||
photoFirstMode=mode;
|
||||
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
|
||||
const file=document.querySelector('#ai-photo');if(file)file.onchange=handleAiPhoto;
|
||||
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
|
||||
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis;
|
||||
document.querySelector('#retake-photo')?.addEventListener('click',()=>{photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')});
|
||||
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)});
|
||||
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)});
|
||||
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
|
||||
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
|
||||
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)});
|
||||
}
|
||||
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
|
||||
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
|
||||
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
|
||||
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;suggestionState=createSuggestionState(data);showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
|
||||
|
||||
function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)}
|
||||
function openLogger(){beginDraft();photoHint='';resetSuggestion();showLogStep(1)}
|
||||
function showLogStep(step){
|
||||
document.querySelector('.sheet-backdrop')?.remove();
|
||||
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
|
||||
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${draftBanner()}${stepBody(step)}</section>`;document.body.append(wrap);
|
||||
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
|
||||
}
|
||||
function stepBody(step){
|
||||
if(step===1)return `${aiSuggestion?.status==='suggestion'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>You’re in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
|
||||
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${photoDataUrl?`<img class="photo-preview" src="${photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
|
||||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||||
if(step===1)return `${provenanceBlock()||'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
|
||||
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${form.photoDataUrl?`<img class="photo-preview" src="${form.photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${suggestionState.suggestion?`<p class="provenance-note fine" role="status">${esc(provenanceCopy(suggestionState,form).detail)}</p>`:'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
|
||||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div>${followUpBlock()}<div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||||
}
|
||||
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
|
||||
function bindStep(step){
|
||||
if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{form.bristolType=Number(b.dataset.type);aiSuggestion=null;showLogStep(1)});document.querySelector('#next').onclick=()=>showLogStep(2)}
|
||||
if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>form.color=color.value;['urgency','discomfort'].forEach(k=>{const n=document.querySelector('#'+k);n.oninput=()=>{form[k]=Number(n.value);n.nextElementSibling.value=n.value}});document.querySelector('#note').oninput=e=>form.note=e.target.value;document.querySelector('#photo').onchange=handlePhoto;document.querySelector('#back').onclick=()=>showLogStep(1);document.querySelector('#next').onclick=()=>showLogStep(3)}
|
||||
if(step===3){document.querySelectorAll('[data-symptom]').forEach(c=>c.onchange=()=>{form.symptoms[c.dataset.symptom]=c.checked;document.querySelector('#urgent-box').innerHTML=detectUrgentFlags(form.symptoms).urgent?alertHtml(detectUrgentFlags(form.symptoms).message):''});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog}
|
||||
document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)});
|
||||
if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{const chosen=Number(b.dataset.type);form.bristolType=chosen;form.typeChosen=true;suggestionState=changeVisualField(suggestionState,'bristolType',chosen);showLogStep(1)});document.querySelector('#next').onclick=()=>{form.typeChosen=true;showLogStep(2)}}
|
||||
if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>{form.color=color.value;suggestionState=changeVisualField(suggestionState,'color',form.color);refreshProvenance()};['urgency','discomfort'].forEach(k=>{const n=document.querySelector('#'+k);n.oninput=()=>{form[k]=Number(n.value);n.nextElementSibling.value=n.value}});document.querySelector('#note').oninput=e=>form.note=e.target.value;document.querySelector('#photo').onchange=handlePhoto;document.querySelector('#back').onclick=()=>showLogStep(1);document.querySelector('#next').onclick=()=>showLogStep(3)}
|
||||
if(step===3){document.querySelectorAll('[data-symptom]').forEach(c=>c.onchange=()=>{form.symptoms[c.dataset.symptom]=c.checked;const mirrored=HARD_STOOL_QUESTIONS.find(q=>q.urgentFlag===c.dataset.symptom);if(mirrored)followUpAnswers[mirrored.id]=c.checked;refreshFollowUp()});document.querySelectorAll('[data-followup]').forEach(b=>b.onclick=()=>{const question=HARD_STOOL_QUESTIONS.find(q=>q.id===b.dataset.followup);const value=b.dataset.answer==='yes';followUpAnswers[b.dataset.followup]=value;if(question?.urgentFlag)form.symptoms[question.urgentFlag]=value;syncFollowUpFlags();refreshFollowUp()});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog}
|
||||
}
|
||||
async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
|
||||
async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
|
||||
function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{URL.revokeObjectURL(url);reject()};img.src=url})}
|
||||
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,photoDataUrl});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
|
||||
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}clearDraft();document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
|
||||
function render(){({home,calendar,timmy,privacy}[view]||home)()}
|
||||
|
||||
render();
|
||||
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 224 KiB |
@@ -0,0 +1,157 @@
|
||||
# Photo Data-Flow Threat Model — End-to-End Photo Path
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Issue:** [#20 — Write the end-to-end photo data-flow threat model](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/20)
|
||||
- **Parent epic:** [#5 — Clinical safety, privacy & security](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/5)
|
||||
- **Milestone:** M1 — Sovereign Photo Intelligence
|
||||
- **Date:** 2026-10-10
|
||||
- **Scope:** Every byte of a user's photo from capture to deletion on the `/api/analyze` path, plus the logs, exports, and consent records that touch it.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Photo-first logging asks a user to hand Timmy an image of the most private output their body produces. That image is sensitive medical-adjacent data by default, so the data-flow boundaries must be explicit before any private beta, and every abuse case that is not yet fully mitigated must name an owner and a tracked remediation ticket. This model covers the browser, the API, the model worker, transient buffers, the training opt-in store, logs, exports, and deletion.
|
||||
|
||||
## Product boundary (unchanged by this model)
|
||||
|
||||
Timmy is non-diagnostic. AI may suggest only visible Bristol form, broad color, and image quality; every suggestion stays provisional until the user confirms or corrects it. Timmy must never diagnose disease, identify bleeding conclusively, infer pain, urgency, fever, or vomiting, or clear a food. Symptoms stay user-entered, and journal save is a separate decision from training contribution. Deterministic red-flag escalation runs on user-reported answers and is never model-gated or model-suppressible.
|
||||
|
||||
No real medical images are ever added to this repository, its fixtures, its tests, or its Git history. Every fixture used in the photo path is synthetic.
|
||||
|
||||
## Data-flow diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Browser["Browser — camera or gallery pick, in-memory draft only"]
|
||||
APIIngress["API ingress — server.mjs /api/analyze"]
|
||||
TransientBuffers["Transient buffers — bounded body bytes, decode canvas"]
|
||||
ModelWorker["Model worker — self-hosted vision, private loopback only"]
|
||||
OptInStore["Training opt-in store — consented contributions only"]
|
||||
Logs["Sanitized logs — counts and reasons, never bytes or base64"]
|
||||
Exports["Exports — user-initiated JSON portability"]
|
||||
Deletion["Deletion — withdrawal, purge, and tombstones"]
|
||||
|
||||
Browser -->|"image bytes plus explicit consent"| APIIngress
|
||||
APIIngress -->|"length, dimension, and rate limits first"| TransientBuffers
|
||||
TransientBuffers -->|"re-encoded baseline JPEG, metadata stripped"| ModelWorker
|
||||
ModelWorker -->|"observable fields only — image dropped, never persisted"| APIIngress
|
||||
APIIngress -->|"no image bytes"| Logs
|
||||
Browser --> Exports
|
||||
OptInStore --> Deletion
|
||||
TransientBuffers --> Deletion
|
||||
Logs --> Deletion
|
||||
```
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
| # | Boundary | Trust posture |
|
||||
|---|---|---|
|
||||
| B1 | Browser to API ingress | Hostile network. Treat every upload as hostile and sensitive: sniff content, cap size and dimensions, rate-limit the client, and never trust the filename or declared MIME type. |
|
||||
| B2 | API ingress to model worker | Private loopback only. The worker is never exposed publicly and never receives a user identity. |
|
||||
| B3 | Model worker to provider or hosted profile | Disabled by default. When enabled, the operator carries the decision, the image is the only payload, and no ledger, symptom, or session data travels with it. |
|
||||
| B4 | App process to logs | One-way and lossy: counts, reasons, and durations leave; bytes, base64, filenames, and symptom text do not. |
|
||||
| B5 | App process to consent records | Contribution requires its own opt-in with a versioned receipt; journal save never implies contribution. |
|
||||
| B6 | Host to operator | The operator can reach data the app holds. Least privilege, documented purge, and no image bytes in operator-facing logs. |
|
||||
|
||||
## Components
|
||||
|
||||
### C1 — Browser
|
||||
|
||||
Holds the captured image as an in-memory draft and renders the consent prompt. A photo awaiting consent is deliberately not written to storage, so it cannot survive the tab by accident, and the service worker never caches analysis requests or image bodies. Lifetime: until the tab closes, consent is refused, or the draft is cleared.
|
||||
|
||||
### C2 — API ingress
|
||||
|
||||
`server.mjs` `/api/analyze` is the only door for image bytes. It owns content sniffing, dimension and body caps, re-encode, metadata stripping, rate control, and the sanitized error vocabulary. Lifetimes are per request: nothing about an image is written to disk.
|
||||
|
||||
### C3 — Model worker
|
||||
|
||||
A self-hosted vision process bound to private loopback. It receives the re-encoded image and returns schema-constrained observable fields or an abstention. It returns no free text, and its output is provisional until the user confirms it.
|
||||
|
||||
### C4 — Transient buffers
|
||||
|
||||
Request body buffer, decode canvas, and the re-encode pipe. These are the moments the image exists in readable form; they are bounded, per request, and dropped when the request ends, on success or failure alike.
|
||||
|
||||
### C5 — Training opt-in store
|
||||
|
||||
Only images and labels a user explicitly contributed, each with consent version, receipt, and provenance. This store is not on the logging path: an ordinary journal save never reaches it.
|
||||
|
||||
### C6 — Sanitized logs
|
||||
|
||||
Operational logs for health, latency, abstention, and failure. They carry counts, reason codes, and durations; they never carry image bytes, base64, the original filename, or user symptom text.
|
||||
|
||||
### C7 — Exports
|
||||
|
||||
User-initiated JSON portability of the user's own ledger. Exports are generated on request, are not written server-side, and deliberately exclude photo bytes.
|
||||
|
||||
### C8 — Deletion
|
||||
|
||||
Withdrawal, purge, and tombstones. Deletion is addressable by contribution ID so it can reach derived artifacts, and a tombstone records that deletion happened without keeping what was deleted.
|
||||
|
||||
## Threat register
|
||||
|
||||
Each entry names the abuse case, the mitigation that is already committed or queued, the owning role, the tracked ticket that will prove the mitigation, and the artifact that will count as evidence.
|
||||
|
||||
### T1 — Replay and re-submission
|
||||
|
||||
- **Scenario:** An attacker replays a captured upload to exhaust inference capacity, correlate timing with a user session, or force repeated provider work from one image.
|
||||
- **Impact:** Denial of service for real users, cost amplification on hosted profiles, and weak linkage between a session and its image bytes.
|
||||
- **Mitigation:** Per-client fixed-window rate control on `/api/analyze` that fails with `429` and `Retry-After` before any provider work, opaque client identity held server-side rather than in the image payload, and no retention of over-limit payloads.
|
||||
- **Owner:** Timmy (ingress, area/backend)
|
||||
- **Testable follow-up:** #16
|
||||
- **Evidence:** An ingress test that drives the limiter past its window and asserts a rejected request never reaches the provider, with a separate assertion that no over-limit payload is retained.
|
||||
|
||||
### T2 — Oversized input
|
||||
|
||||
- **Scenario:** A decompression bomb, an enormous dimension count, or a multi-hundred-megabyte body arrives to consume worker memory, block the queue, or stall the request path indefinitely.
|
||||
- **Impact:** Worker exhaustion and queue starvation that degrade logging availability, up to a full outage of photo analysis.
|
||||
- **Mitigation:** Decoded-body, per-dimension, and timeout caps enforced before any provider or worker work; a bounded inference queue with cancellation and an overload fallback that returns the manual path instead of hanging.
|
||||
- **Owner:** Timmy (ingress, area/backend)
|
||||
- **Testable follow-up:** #17
|
||||
- **Evidence:** A queue-full and body-timeout test proving the request is rejected inside its budget and that the manual Bristol path remains one action away, per the acceptance criteria of #17.
|
||||
|
||||
### T3 — Metadata leakage
|
||||
|
||||
- **Scenario:** An uploaded photo carries EXIF, GPS, device identifiers, XMP, or PNG text chunks; or a failure path echoes base64 image bytes into an error message or a log line.
|
||||
- **Impact:** Location and device deanonymization, plus durable copies of the image outside the intended boundary, including in logs.
|
||||
- **Mitigation:** Content sniffing independent of filename and MIME, safe re-encode to baseline JPEG with a fixed argv, metadata dropped by construction, polyglot and malformed inputs rejected, and a fixed sanitized error vocabulary with no bytes, no base64, and no stack traces.
|
||||
- **Owner:** Timmy (ingress, area/backend)
|
||||
- **Testable follow-up:** #16
|
||||
- **Evidence:** A re-encode test asserting EXIF, GPS, XMP, and PNG text chunks are absent from the accepted bytes, paired with a log-scan test asserting no image bytes or base64 appear in request or error logs.
|
||||
|
||||
### T4 — Dataset poisoning
|
||||
|
||||
- **Scenario:** Mislabeled, duplicated, or adversarially crafted contributions enter the training corpus and shift the classifier toward a wrong or unsafe behavior, including teaching it to call a not-stool image a stool form.
|
||||
- **Impact:** Silent accuracy loss that is invisible in aggregate metrics and could undermine abstention exactly where the product must fail closed.
|
||||
- **Mitigation:** Immutable provenance per contribution, contributor-level splits so a single contributor cannot straddle train and evaluation, a reviewer and adjudication tool for form, color, quality, and exclusions, and a published dataset card with the labeling guide and split policy.
|
||||
- **Owner:** Privacy reviewer (human gate), with Timmy as release authority
|
||||
- **Testable follow-up:** #29
|
||||
- **Evidence:** The contributor-held-out evaluation harness and calibration report, with the reviewer and adjudication tool as the labeling control.
|
||||
|
||||
### T5 — Unauthorized training
|
||||
|
||||
- **Scenario:** A journal save is silently treated as consent, an image is trained on without a receipt, or a withdrawal fails to reach a derived artifact so contribution survives deletion.
|
||||
- **Impact:** Use of private data without consent, which breaks the product promise and the beta consent gate, and is irreversible once a model is trained.
|
||||
- **Mitigation:** Contribution is a separate opt-in decision with a versioned consent receipt shown before the data moves; journal save defaults to no contribution; withdrawal is addressable by contribution ID and reaches every derived artifact; journal saving never requires contribution.
|
||||
- **Owner:** Privacy reviewer (human gate)
|
||||
- **Testable follow-up:** #24
|
||||
- **Evidence:** An opt-in and withdrawal test proving journal save contributes nothing by default, that the consent version and receipt are shown before contribution, and that a withdrawal removes every derived artifact by contribution ID, with tombstone handling from #25.
|
||||
|
||||
### T6 — Operator access
|
||||
|
||||
- **Scenario:** The host operator — or anyone who reaches the host with the operator's privilege — reads image bytes, symptom text, or consent records from process memory, on-disk state, backups, or logs.
|
||||
- **Impact:** A single privileged reader can deanonymize a user's most private log, and an undocumented backup can outlive a deletion request.
|
||||
- **Mitigation:** Least-privilege host access, worker bound to private loopback with no public exposure, no image bytes or base64 in operator-facing logs, documented backup and rollback boundaries, and a purge procedure that names every place an image can exist.
|
||||
- **Owner:** Operator (host owner), with Timmy as release authority
|
||||
- **Testable follow-up:** #39
|
||||
- **Evidence:** The production topology, secrets, backup, and rollback runbook, exercised against the deletion path so that purge reaches memory, temporary files, logs, and backups, with observability from #41 confirming no bytes reach the metrics surface.
|
||||
|
||||
## Accepted residual risks
|
||||
|
||||
- **Host compromise.** Data in a running process can be read by a sufficiently privileged attacker. This is accepted for the private beta and bounded by least privilege plus the purge procedure in #39; it is not claimed as eliminated.
|
||||
- **Model accuracy.** The photo prefill remains blocked behind abstention and specialist-model evidence. A wrong or confident suggestion is treated as a user-correction problem, and the user remains the author of every saved entry, per #32 and the calibration work in #4.
|
||||
- **Provider profiles.** A hosted OpenAI-compatible profile moves the image off-host by definition. It stays opt-in, operator-controlled, and off by default, with readiness reporting before it is offered, per #19.
|
||||
|
||||
## Verification
|
||||
|
||||
- This document is enforced by `tests/threat-model.test.js`, which asserts that the diagram covers every byte-bearing component, that each required abuse case is a numbered threat with an owner and a tracked follow-up ticket, and that the model keeps the non-diagnostic boundary and ships no real medical images.
|
||||
- The test runs in `npm test` and therefore inside the CI quality gate and the release build, so the threat model cannot silently drift from the code it describes.
|
||||
- Boundary and boundary-adjacent claims are reviewed by the clinical and privacy gate (#22, #23) before beta; this model does not move that gate, it feeds it.
|
||||
@@ -30,6 +30,10 @@ Timmy may expose a smart free-text conversation backed by a fully tool-capable H
|
||||
|
||||
The application’s deterministic urgent-symptom guidance runs before chat and remains authoritative when Hermes is offline, slow, malformed, or conversationally mistaken. Hermes must not diagnose, invent symptoms, override escalation, or take external/destructive actions without explicit user intent and confirmation.
|
||||
|
||||
### Availability and draft retention
|
||||
|
||||
Logging must never depend on AI availability. When the vision worker is offline, disabled, or still unknown, the photo-first flow offers no AI action and no analysis-consent prompt, and manual Bristol entry remains one action away from every photo step. A chosen photo and any entered context are retained as an in-session draft with a visible summary and an explicit start over; drafts are deliberately not persisted to storage, so a photo awaiting consent is never written to disk. The service worker never caches analysis requests or image bodies.
|
||||
|
||||
### Release authority
|
||||
|
||||
Hermes/Timmy is the release authority for routine engineering, test, build, deployment, and release-candidate preparation. Automated evidence must remain reproducible and must not overstate model accuracy or safety.
|
||||
@@ -55,3 +59,4 @@ Routine provisioning, deployment, label sweeps, and release-candidate assembly a
|
||||
- [Product brief and safety boundary](../PRODUCT.md)
|
||||
- [AI execution evidence](../AI-EVIDENCE.md)
|
||||
- [Sovereign vision decision and measured limitations](../research/SELF-HOSTED-STOOL-VISION.md)
|
||||
- [Photo data-flow threat model](./PHOTO-DATAFLOW-THREAT-MODEL.md)
|
||||
@@ -4,14 +4,16 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-queue.test.js tests/agent-turn-timeout.test.js tests/inference-queue.test.js tests/inference-queue-cancellation.test.js tests/inference-zero-call.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
|
||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||
"test:queue-acceptance": "node tests/inference-queue.acceptance.mjs",
|
||||
"test:shutdown-acceptance": "node tests/server-shutdown.acceptance.mjs",
|
||||
"test:manual-fallback": "node tests/manual-fallback.acceptance.mjs",
|
||||
"test:provisional": "node tests/provisional-suggestion.acceptance.mjs",
|
||||
"test:followup": "node tests/followup.acceptance.mjs",
|
||||
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/inference-queue.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/inference-queue.acceptance.mjs && node --check tests/server-shutdown.acceptance.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
|
||||
"check:diff": "bash scripts/check_diff.sh",
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,19 @@ SECRET_PATTERNS = (
|
||||
re.compile(r"(?i)(?:api[_-]?key|access[_-]?token|authorization)\s*[:=]\s*['\"](?!\.\.\.|top-secret|test-|example|placeholder)[A-Za-z0-9_./+:-]{16,}"),
|
||||
)
|
||||
|
||||
# Captions that must be proven on camera (via the demo's own receipt) for the release to pass.
|
||||
DEMO_REQUIRED_CAPTIONS = (
|
||||
"One clear photo action. Manual logging stays one tap away.",
|
||||
"Nothing uploads until explicit consent",
|
||||
"The suggestion stays provisional — nothing is applied or saved until you confirm it",
|
||||
"Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo",
|
||||
"Vision worker offline — the AI and consent steps disappear instead of failing after upload",
|
||||
"Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion",
|
||||
"Delete all local data clears the ledger in this browser; there is no server copy of it to keep",
|
||||
"Hermes Agent connected — free-text, contextual, and server-side",
|
||||
"Urgent language is intercepted deterministically before Hermes",
|
||||
)
|
||||
|
||||
|
||||
def run(args: list[str], cwd: Path, *, capture: bool = False, env: dict[str, str] | None = None) -> str:
|
||||
print("+", " ".join(args), flush=True)
|
||||
@@ -74,6 +87,10 @@ def main() -> int:
|
||||
status = run(["git", "status", "--porcelain"], ROOT, capture=True)
|
||||
if status:
|
||||
raise SystemExit("Release requires a clean committed tree")
|
||||
release_delta = os.environ.get("TIMMY_RELEASE_DELTA",
|
||||
"Verified rebuild of the current user-visible feature path — no new feature is claimed.").strip()
|
||||
if not release_delta:
|
||||
raise SystemExit("TIMMY_RELEASE_DELTA must not be empty")
|
||||
commit = run(["git", "rev-parse", "HEAD"], ROOT, capture=True)
|
||||
short = commit[:12]
|
||||
commit_epoch = int(run(["git", "show", "-s", "--format=%ct", commit], ROOT, capture=True))
|
||||
@@ -109,11 +126,27 @@ def main() -> int:
|
||||
raise SystemExit("Acceptance server did not become ready")
|
||||
run(["npm", "run", "test:ui"], tree)
|
||||
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)
|
||||
run(["npm", "run", "test:provisional"], tree)
|
||||
run(["npm", "run", "test:followup"], tree)
|
||||
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
||||
demo_env = dict(server_env)
|
||||
demo_env["TIMMY_RELEASE_VERSION"] = version
|
||||
demo_env["TIMMY_RELEASE_DEMO_DELTA"] = release_delta
|
||||
demo_env["TIMMY_DEMO_CAPTIONS_OUT"] = str(caption_receipt)
|
||||
run(["node", "scripts/record_release_demo.mjs", str(demo_raw)], tree, env=demo_env)
|
||||
if not caption_receipt.exists():
|
||||
raise SystemExit("Release demo produced no on-camera caption receipt")
|
||||
recorded = json.loads(caption_receipt.read_text(encoding="utf-8"))
|
||||
onscreen = [str(line) for line in (recorded.get("captions") or [])]
|
||||
if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in onscreen):
|
||||
raise SystemExit("Release demo did not state the declared release delta on camera")
|
||||
for required in DEMO_REQUIRED_CAPTIONS:
|
||||
if not any(required in line for line in onscreen):
|
||||
raise SystemExit(f"Release demo is missing a required on-camera caption: {required}")
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
@@ -131,9 +164,15 @@ def main() -> int:
|
||||
if len(video_streams) != 1 or video_streams[0].get("codec_name") != "h264" or video_streams[0].get("pix_fmt") != "yuv420p" or video_streams[0].get("width") != 720 or video_streams[0].get("height") != 1280:
|
||||
raise SystemExit("Release demo media probe failed")
|
||||
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)
|
||||
# Span the whole recording: a fixed 1/2 fps sheet only covered the first 24s of a longer demo.
|
||||
demo_duration = float(run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(demo)], tree, capture=True))
|
||||
if demo_duration <= 0:
|
||||
raise SystemExit("Release demo has no measurable duration")
|
||||
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", f"fps={12 / demo_duration:.6f},scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree)
|
||||
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
|
||||
raise SystemExit("Release demo contact sheet was not produced")
|
||||
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/suggestion.js", "src/followup.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)
|
||||
@@ -156,10 +195,14 @@ def main() -> int:
|
||||
f"# Timmy review release {version}\n\n"
|
||||
f"- Source commit: [{commit}]({PUBLIC_REPO}/commit/{commit})\n"
|
||||
f"- Build date (UTC): {release_date}\n"
|
||||
f"- Release delta: {release_delta}\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"
|
||||
"- AI suggestions stay provisional: suggested, confirmed, and corrected states are distinct and screen-reader described; correcting one clears the AI-confirmed state without losing user context\n"
|
||||
"- Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions (difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, hydration); mild reports get non-diagnostic self-care, and a confirmed red-flag answer lands on the same deterministic escalation path as manual logging\n"
|
||||
"- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n"
|
||||
"- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n"
|
||||
"- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
|
||||
@@ -190,6 +233,7 @@ def main() -> int:
|
||||
"project": "Timmy the Talking Turd",
|
||||
"version": version,
|
||||
"commit": commit,
|
||||
"release_delta": release_delta,
|
||||
"source_date_utc": source_date.isoformat(),
|
||||
"built_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"artifact": archive.name,
|
||||
@@ -204,19 +248,23 @@ 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 reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, 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",
|
||||
"provisional_suggestion_state": "passed",
|
||||
"hard_stool_followup": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
"forbidden_artifacts": "passed",
|
||||
"feature_demo_full_decode": "passed",
|
||||
"feature_demo_media_probe": "passed",
|
||||
"release_delta_visible_on_camera": "passed",
|
||||
},
|
||||
}
|
||||
manifest_path = release_dir / "manifest.json"
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const output = path.resolve(process.argv[2] || 'release-demo.webm');
|
||||
const version = process.env.TIMMY_RELEASE_VERSION || 'review build';
|
||||
// The release delta is supplied by the builder so the recording states, on camera, what actually
|
||||
// changed in this release instead of implying a feature that did not ship.
|
||||
const releaseDelta = process.env.TIMMY_RELEASE_DEMO_DELTA
|
||||
|| 'Verified rebuild of the current user-visible feature path — no new feature is claimed.';
|
||||
const captionsOut = process.env.TIMMY_DEMO_CAPTIONS_OUT || '';
|
||||
const captionsShown = [];
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
|
||||
@@ -19,6 +25,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 +34,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({
|
||||
@@ -69,6 +77,7 @@ await page.addStyleTag({ content: `
|
||||
` });
|
||||
|
||||
async function caption(text, duration = 1400) {
|
||||
captionsShown.push(text);
|
||||
await page.evaluate(text => {
|
||||
document.querySelector('#demo-caption')?.remove();
|
||||
const node = document.createElement('div');
|
||||
@@ -78,6 +87,16 @@ async function caption(text, duration = 1400) {
|
||||
node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' });
|
||||
}, text);
|
||||
await sleep(duration);
|
||||
// A caption must not outlive its stated duration. It used to stay on screen until the next one
|
||||
// replaced it, so a view change left the previous screen's claim hanging over a different screen —
|
||||
// e.g. a delete-data caption sitting on top of the home view.
|
||||
await page.evaluate(() => {
|
||||
const node = document.querySelector('#demo-caption');
|
||||
if (!node) return;
|
||||
node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, fill: 'forwards' });
|
||||
setTimeout(() => node.remove(), 220);
|
||||
});
|
||||
await sleep(220);
|
||||
}
|
||||
|
||||
async function tap(selector, after = 650) {
|
||||
@@ -100,21 +119,102 @@ async function tap(selector, after = 650) {
|
||||
await sleep(after);
|
||||
}
|
||||
|
||||
await caption(`TIMMY ${version} • FEATURE DEMO`, 1200);
|
||||
async function indicate(selector) {
|
||||
const target = page.locator(selector).first();
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
const box = await target.boundingBox();
|
||||
if (!box) throw new Error(`Missing demo target: ${selector}`);
|
||||
await page.evaluate(({ x, y }) => {
|
||||
document.querySelector('#demo-touch')?.remove();
|
||||
const ring = document.createElement('div');
|
||||
ring.id = 'demo-touch';
|
||||
ring.style.left = `${x}px`;
|
||||
ring.style.top = `${y}px`;
|
||||
document.body.append(ring);
|
||||
ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 });
|
||||
setTimeout(() => ring.remove(), 550);
|
||||
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
await sleep(650);
|
||||
}
|
||||
|
||||
await caption(`TIMMY ${version} • RELEASE DEMO`, 1200);
|
||||
await caption(`This release: ${releaseDelta}`, 2600);
|
||||
await caption('Automated checks replay this synthetic path before review', 1200);
|
||||
await caption('One clear photo action. Manual logging stays one tap away.', 1500);
|
||||
await tap('[data-scan]', 450);
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
|
||||
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await indicate('label[for="camera-photo"]');
|
||||
await page.locator('#camera-photo').dispatchEvent('cancel');
|
||||
await page.getByText(/Camera or photo picker closed/i).waitFor();
|
||||
await caption('Camera closed cleanly — gallery and manual logging are still available', 1600);
|
||||
await indicate('label[for="gallery-photo"]');
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await caption('Nothing uploads until explicit consent', 1100);
|
||||
await page.locator('#ai-consent').check();
|
||||
await tap('#analyze-photo', 450);
|
||||
await page.getByText(/83% confidence/i).waitFor();
|
||||
await caption('AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis', 1700);
|
||||
await indicate('.scan-result[data-provenance="pending"]');
|
||||
await caption('The suggestion stays provisional — nothing is applied or saved until you confirm it', 1700);
|
||||
await tap('#use-suggestion', 450);
|
||||
await caption('The user reviews every suggestion before saving', 1100);
|
||||
await page.locator('.ai-provenance[data-provenance="confirmed"]').waitFor();
|
||||
await caption('Confirming is explicit, and every suggested value is still yours to overrule', 1600);
|
||||
await tap('[data-type="2"]', 500);
|
||||
await page.locator('[data-provenance="corrected"]').waitFor();
|
||||
await caption('Change a suggested value and Timmy clears the AI-confirmed state', 1700);
|
||||
await indicate('.ai-provenance[data-provenance="corrected"] .provenance-note');
|
||||
await tap('#next', 450);
|
||||
await page.getByText(/You changed it to Type 2/).waitFor();
|
||||
await caption('Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours', 1900);
|
||||
await tap('#next', 450);
|
||||
await page.locator('[data-followup-block]').waitFor();
|
||||
await caption('Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo', 2600);
|
||||
await page.locator('[data-question="bloodVisible"]').scrollIntoViewIfNeeded();
|
||||
await indicate('[data-question="bloodVisible"]');
|
||||
await tap('[data-followup="bloodVisible"][data-answer="no"]', 400);
|
||||
await caption('Every answer is yours to give, and a skipped question stays unanswered', 1900);
|
||||
await tap('[data-followup="soreness"][data-answer="yes"]', 350);
|
||||
await tap('[data-followup="dehydration"][data-answer="yes"]', 350);
|
||||
await page.locator('#followup-guidance').waitFor();
|
||||
await caption('Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis', 2600);
|
||||
await indicate('#followup-guidance');
|
||||
await caption('And it never claims the photo ruled out bleeding', 1900);
|
||||
await tap('[data-followup="tarryBlack"][data-answer="yes"]', 600);
|
||||
await page.locator('#urgent-box .alert').waitFor();
|
||||
await caption('Say it was truly black and tarry and the same deterministic red-flag path fires instead', 2600);
|
||||
await tap('[data-followup="tarryBlack"][data-answer="no"]', 500);
|
||||
await page.locator('#urgent-box .alert').waitFor({ state: 'detached' });
|
||||
await caption('Dark brown is not silently escalated, and the flag clears the moment the answer changes', 2200);
|
||||
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 2/.test(keptDraft)) throw new Error('The retained draft must show the value the user corrected to');
|
||||
await caption('One tap still reaches manual Bristol entry, and the draft keeps your corrected value', 1800);
|
||||
await tap('#discard-draft', 500);
|
||||
await caption('Start over clears the kept draft — nothing lingers silently', 1500);
|
||||
await tap('#close-sheet', 450);
|
||||
await caption('The threat model documents this surface, so the recording shows it live rather than describing it', 2200);
|
||||
await tap('[data-view="calendar"]', 550);
|
||||
await page.getByText(/Your private journal/i).waitFor();
|
||||
await tap('[data-view="privacy"]', 550);
|
||||
await page.getByText(/Your poop\. Your phone\./i).waitFor();
|
||||
await caption('Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion', 2800);
|
||||
await indicate('#export');
|
||||
await caption('Export writes a plain JSON file you hold — the model records it as a data-lifetime boundary, not a server sync', 2600);
|
||||
await indicate('#delete-all');
|
||||
await caption('Delete all local data clears the ledger in this browser; there is no server copy of it to keep', 2600);
|
||||
await tap('[data-view="home"]', 500);
|
||||
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);
|
||||
@@ -143,6 +243,11 @@ await page.evaluate(() => {
|
||||
await sleep(2200);
|
||||
|
||||
if (errors.length) throw new Error(`Browser errors: ${errors.join(' | ')}`);
|
||||
// Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove
|
||||
// the recorded delta statement reached the screen instead of trusting the script source.
|
||||
if (captionsOut) {
|
||||
await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown }, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
const recording = page.video();
|
||||
await page.close();
|
||||
await recording.saveAs(output);
|
||||
|
||||
@@ -39,34 +39,6 @@ function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'
|
||||
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
|
||||
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
|
||||
|
||||
// Graceful shutdown: SIGTERM must cancel in-flight Hermes turns (killing their
|
||||
// child processes) instead of abandoning them as orphan subprocesses.
|
||||
const inFlightChats = new Set();
|
||||
let shuttingDown = false;
|
||||
function trackChat(res, controller) {
|
||||
if (shuttingDown) controller.abort(new Error('Timmy is shutting down. Your journal still works — try again shortly.'));
|
||||
const entry = { res, controller };
|
||||
inFlightChats.add(entry);
|
||||
// A vanished client must release its queue slot and stop any in-flight
|
||||
// Hermes turn instead of consuming inference capacity silently.
|
||||
res.on('close', () => {
|
||||
inFlightChats.delete(entry);
|
||||
if (!res.writableEnded) {
|
||||
try { controller.abort(new Error('The browser closed the request. Your journal still works.')); } catch { /* already aborted */ }
|
||||
}
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
for (const signalName of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signalName, () => {
|
||||
shuttingDown = true;
|
||||
for (const { controller } of inFlightChats) {
|
||||
try { controller.abort(new Error('Server is shutting down.')); } catch { /* already aborted */ }
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(async(req,res)=>{
|
||||
try{
|
||||
const url=new URL(req.url,'http://localhost');
|
||||
@@ -90,7 +62,7 @@ http.createServer(async(req,res)=>{
|
||||
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
|
||||
}
|
||||
if(appPath==='/api/agent/chat'&&req.method==='POST'){
|
||||
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);const chatAbort=new AbortController();trackChat(res,chatAbort);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload,signal:chatAbort.signal}))}catch(error){return sendAgentError(res,error)}
|
||||
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
|
||||
}
|
||||
if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
|
||||
if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()}
|
||||
|
||||
@@ -37,7 +37,10 @@ self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response => {
|
||||
if (!/\bno-store\b/i.test(response.headers.get('cache-control') || '')) {
|
||||
const cacheControl = response.headers.get('cache-control') || '';
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
// Never persist photo or image bodies: a private photo must stay a transient upload.
|
||||
if (!/\bno-store\b/i.test(cacheControl) && !/^\s*image\//i.test(contentType)) {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE).then(cache => cache.put(event.request, copy));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// A draft is what the user has entered but not yet saved.
|
||||
// It is kept in this browser only, so a recoverable close or navigation never loses it.
|
||||
export function createDraft() {
|
||||
return {
|
||||
bristolType: 4,
|
||||
typeChosen: false,
|
||||
color: 'brown',
|
||||
urgency: 0,
|
||||
discomfort: 0,
|
||||
note: '',
|
||||
symptoms: {},
|
||||
photoDataUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function isDraftDirty(draft = {}) {
|
||||
return Boolean(
|
||||
draft.typeChosen
|
||||
|| draft.photoDataUrl
|
||||
|| String(draft.note || '').trim()
|
||||
|| Number(draft.urgency) > 0
|
||||
|| Number(draft.discomfort) > 0
|
||||
|| (draft.color && draft.color !== 'brown')
|
||||
|| Object.values(draft.symptoms || {}).some(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function draftNotice(draft = {}) {
|
||||
if (!isDraftDirty(draft)) return '';
|
||||
const parts = [];
|
||||
if (draft.typeChosen) parts.push(`Type ${draft.bristolType}`);
|
||||
if (draft.color && draft.color !== 'brown') parts.push(`${draft.color} color`);
|
||||
if (Number(draft.urgency) > 0) parts.push('urgency');
|
||||
if (Number(draft.discomfort) > 0) parts.push('discomfort');
|
||||
if (String(draft.note || '').trim()) parts.push('note');
|
||||
if (Object.values(draft.symptoms || {}).some(Boolean)) parts.push('symptoms');
|
||||
if (draft.photoDataUrl) parts.push('photo');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Hard-stool follow-up (field finding #67).
|
||||
//
|
||||
// After a hard/lumpy (Bristol 1-2) entry, Timmy asks a short follow-up. Every answer
|
||||
// is user-reported: soreness, difficulty passing, hydration, and black/tarry character
|
||||
// are never inferred from a photo. The two confirmed red flags map onto the same
|
||||
// deterministic symptom keys the rest of the app already escalates on.
|
||||
import { detectUrgentFlags } from './domain.js';
|
||||
|
||||
export const HARD_STOOL_TYPES = Object.freeze([1, 2]);
|
||||
|
||||
// `urgentFlag` is the existing deterministic symptom key a "yes" maps onto. Questions
|
||||
// without one stay non-diagnostic self-care only.
|
||||
export const HARD_STOOL_QUESTIONS = Object.freeze([
|
||||
Object.freeze({ id: 'difficultToPass', label: 'Was it hard or difficult to pass?', urgentFlag: null }),
|
||||
Object.freeze({ id: 'soreness', label: 'Any soreness or sharp pain afterward?', urgentFlag: null }),
|
||||
Object.freeze({ id: 'bloodVisible', label: 'Any visible red blood?', urgentFlag: 'blood' }),
|
||||
Object.freeze({ id: 'tarryBlack', label: 'Was it truly black, sticky, or tarry — not just dark brown?', urgentFlag: 'blackOrDarkRed' }),
|
||||
Object.freeze({ id: 'dehydration', label: 'Do you feel dehydrated, or is your urine unusually dark?', urgentFlag: null }),
|
||||
]);
|
||||
|
||||
export const HARD_STOOL_GUIDANCE = Object.freeze([
|
||||
'Drink water steadily through the day rather than a lot at once.',
|
||||
'Do not strain or force it; give it time and try again later if needed.',
|
||||
'Clean gently, pat dry, and avoid scrubbing sore skin.',
|
||||
'A few minutes in a warm sitz bath can ease soreness afterward.',
|
||||
'A barrier ointment on the outside skin can reduce irritation.',
|
||||
'Food-based fiber with fluids helps over days, not minutes.',
|
||||
]);
|
||||
|
||||
export const HARD_STOOL_GUIDANCE_NOTE = 'General self-care information, not a diagnosis. Your photo cannot rule out bleeding or another cause.';
|
||||
export const HARD_STOOL_SEEK_CARE = 'Contact a clinician or urgent service if pain is severe or worsening, you feel faint or weak, you pass very little urine, or any blood appears.';
|
||||
|
||||
export function isHardStoolForm(bristolType) {
|
||||
return HARD_STOOL_TYPES.includes(Number(bristolType));
|
||||
}
|
||||
|
||||
export function needsHardStoolFollowUp(draft = {}) {
|
||||
return isHardStoolForm(draft?.bristolType);
|
||||
}
|
||||
|
||||
export function createFollowUpAnswers() {
|
||||
return Object.fromEntries(HARD_STOOL_QUESTIONS.map((question) => [question.id, null]));
|
||||
}
|
||||
|
||||
function confirmedYes(answers, question) {
|
||||
// Only an explicit `true` counts. `null` (unanswered) and `false` (explicit no) do not escalate.
|
||||
return answers?.[question.id] === true;
|
||||
}
|
||||
|
||||
export function evaluateHardStoolFollowUp(answers = {}) {
|
||||
const answeredIds = HARD_STOOL_QUESTIONS.filter((question) => typeof answers[question.id] === 'boolean');
|
||||
const flags = HARD_STOOL_QUESTIONS.filter((question) => question.urgentFlag && confirmedYes(answers, question))
|
||||
.map((question) => question.urgentFlag);
|
||||
const escalated = detectUrgentFlags(Object.fromEntries(flags.map((flag) => [flag, true])));
|
||||
const urgent = escalated.urgent;
|
||||
return {
|
||||
answered: answeredIds.length > 0,
|
||||
unanswered: HARD_STOOL_QUESTIONS.filter((question) => !answeredIds.includes(question)).map((question) => question.id),
|
||||
urgent,
|
||||
flags,
|
||||
message: urgent ? escalated.message : '',
|
||||
guidance: !urgent && answeredIds.length ? [...HARD_STOOL_GUIDANCE] : [],
|
||||
note: HARD_STOOL_GUIDANCE_NOTE,
|
||||
seekCare: urgent ? '' : HARD_STOOL_SEEK_CARE,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { execFile } from 'node:child_process';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js';
|
||||
import { OverloadError, createInferenceQueue } from './inference-queue.js';
|
||||
|
||||
const MAX_MESSAGE_CHARS = 4000;
|
||||
const MAX_LEDGER_ENTRIES = 20;
|
||||
@@ -45,8 +44,6 @@ export function resolveHermesAgentConfig(env = process.env) {
|
||||
maxRequestsPerMinute: positiveInt(env.TIMMY_AGENT_RATE_PER_MINUTE, 12, 1, 60),
|
||||
maxUnlockAttemptsPerMinute: positiveInt(env.TIMMY_AGENT_UNLOCK_RATE_PER_MINUTE, 5, 1, 30),
|
||||
maxSessions: positiveInt(env.TIMMY_AGENT_MAX_SESSIONS, 64, 1, 512),
|
||||
maxConcurrentTurns: positiveInt(env.TIMMY_AGENT_MAX_CONCURRENT_TURNS, 1, 1, 8),
|
||||
maxQueueDepth: positiveInt(env.TIMMY_AGENT_MAX_QUEUE_DEPTH, 4, 0, 64),
|
||||
publicStatus(authenticated = false) {
|
||||
return {
|
||||
enabled,
|
||||
@@ -89,30 +86,12 @@ export function parseHermesCliOutput(output) {
|
||||
return { sessionId: marker[1], reply };
|
||||
}
|
||||
|
||||
function execFilePromise(command, args, options, signal) {
|
||||
function execFilePromise(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Abort must actually terminate the spawned CLI, not merely abandon the
|
||||
// promise — otherwise every cancelled turn leaks an orphan subprocess.
|
||||
let killedByAbort = false;
|
||||
const child = execFile(command, args, options, (error, stdout, stderr) => {
|
||||
if (killedByAbort && signal) {
|
||||
reject(normalizeAbortReason(signal.reason));
|
||||
return;
|
||||
}
|
||||
execFile(command, args, options, (error, stdout, stderr) => {
|
||||
if (error) reject(error);
|
||||
else resolve({ stdout, stderr });
|
||||
});
|
||||
if (!signal) return;
|
||||
const killTree = () => {
|
||||
killedByAbort = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* already exited */ }
|
||||
const forceKill = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch { /* already exited */ }
|
||||
}, 2_000);
|
||||
forceKill.unref?.();
|
||||
};
|
||||
if (signal.aborted) killTree();
|
||||
else signal.addEventListener('abort', killTree, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,8 +103,7 @@ export function buildHermesEnvironment(source = process.env) {
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function runHermesCliTurn({ prompt, hermesSessionId, config, signal, execImpl = execFilePromise }) {
|
||||
if (signal?.aborted) throw normalizeAbortReason(signal.reason);
|
||||
export async function runHermesCliTurn({ prompt, hermesSessionId, config, execImpl = execFilePromise }) {
|
||||
const args = ['chat', '-q', prompt, '-Q', '--source', 'tool', '--max-turns', String(config.maxTurns), '--in', config.workdir];
|
||||
if (hermesSessionId) args.push('--resume', hermesSessionId, '--no-restore-cwd');
|
||||
const output = await execImpl(config.command, args, {
|
||||
@@ -134,15 +112,10 @@ export async function runHermesCliTurn({ prompt, hermesSessionId, config, signal
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
env: buildHermesEnvironment(),
|
||||
}, signal);
|
||||
});
|
||||
return parseHermesCliOutput(`${output.stderr || ''}\n${output.stdout || ''}`);
|
||||
}
|
||||
|
||||
function normalizeAbortReason(reason) {
|
||||
if (reason instanceof Error) return reason;
|
||||
return new Error(typeof reason === 'string' && reason.trim() ? reason : 'Request cancelled.');
|
||||
}
|
||||
|
||||
function sanitizeLedger(entries) {
|
||||
if (!Array.isArray(entries)) throw new AgentGatewayError(400, 'Ledger context must be an array.');
|
||||
return entries.slice(-MAX_LEDGER_ENTRIES).map(entry => ({
|
||||
@@ -166,7 +139,6 @@ export function createHermesAgentService({
|
||||
runTurn = input => runHermesCliTurn(input),
|
||||
randomToken = () => randomBytes(32).toString('base64url'),
|
||||
now = () => Date.now(),
|
||||
queue = createInferenceQueue({ concurrency: config.maxConcurrentTurns, maxQueueDepth: config.maxQueueDepth, requestTimeoutMs: config.timeoutMs, now }),
|
||||
} = {}) {
|
||||
const sessions = new Map();
|
||||
let unlockFailures = [];
|
||||
@@ -210,7 +182,7 @@ export function createHermesAgentService({
|
||||
return { cookieToken, public: config.publicStatus(true) };
|
||||
},
|
||||
|
||||
async chat({ origin, cookieToken, payload, signal }) {
|
||||
async chat({ origin, cookieToken, payload }) {
|
||||
requireOrigin(origin);
|
||||
const session = lookup(cookieToken);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new AgentGatewayError(400, 'Invalid chat request.');
|
||||
@@ -223,48 +195,25 @@ export function createHermesAgentService({
|
||||
if (urgent) return { reply: urgentChatMessage, connected: true, safetyOverride: true };
|
||||
const cutoff = now() - RATE_WINDOW_MS;
|
||||
session.requests = session.requests.filter(time => time > cutoff);
|
||||
if (session.requests.length >= config.maxRequestsPerMinute) throw new AgentGatewayError(429, 'Too many messages in a row. Try again shortly.');
|
||||
if (session.requests.length >= config.maxRequestsPerMinute || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.');
|
||||
session.requests.push(now());
|
||||
if (signal?.aborted) throw new AgentGatewayError(503, 'The browser closed the request before Timmy could think. Your journal still works.');
|
||||
// Forward browser-side aborts into the inference queue by handle so a
|
||||
// disconnected client releases its slot immediately.
|
||||
let handle = null;
|
||||
const forwardAbort = () => {
|
||||
if (!handle) return;
|
||||
const reason = signal.reason instanceof Error ? signal.reason : normalizeReason(signal.reason);
|
||||
queue.cancel(handle, reason);
|
||||
};
|
||||
if (signal) signal.addEventListener('abort', forwardAbort, { once: true });
|
||||
session.busy = true;
|
||||
try {
|
||||
const result = await (handle = queue.run(async runSignal => {
|
||||
if (runSignal.aborted) throw normalizeReason(runSignal.reason);
|
||||
// Session continuity is read at execution time so a request that
|
||||
// waited in the queue resumes the conversation state left by the
|
||||
// turn that ran before it.
|
||||
return runTurn({
|
||||
prompt: buildPrompt(message, ledger, !session.hermesSessionId),
|
||||
hermesSessionId: session.hermesSessionId,
|
||||
config,
|
||||
signal: runSignal,
|
||||
});
|
||||
}));
|
||||
const result = await runTurn({
|
||||
prompt: buildPrompt(message, ledger, !session.hermesSessionId),
|
||||
hermesSessionId: session.hermesSessionId,
|
||||
config,
|
||||
});
|
||||
const reply = stripUnsafeControls(result?.reply || '');
|
||||
if (!reply || reply.length > 12_000 || !/^[A-Za-z0-9_-]{8,128}$/.test(String(result?.sessionId || ''))) throw new Error('invalid agent result');
|
||||
if (!session.hermesSessionId) session.hermesSessionId = result.sessionId;
|
||||
session.hermesSessionId = result.sessionId;
|
||||
return { reply, connected: true };
|
||||
} catch (error) {
|
||||
if (error instanceof AgentGatewayError) throw error;
|
||||
const messageText = String(error?.message || '');
|
||||
if (error instanceof OverloadError || /timed out|busy right now|disconnected|closed before/i.test(messageText)) {
|
||||
throw new AgentGatewayError(503, messageText);
|
||||
}
|
||||
throw new AgentGatewayError(503, 'Hermes is temporarily unavailable. Your local journal still works.');
|
||||
} finally {
|
||||
session.busy = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReason(reason) {
|
||||
if (reason instanceof Error) return reason;
|
||||
return new Error(typeof reason === 'string' && reason.trim() ? reason : 'Request cancelled.');
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
export class OverloadError extends Error {
|
||||
constructor(message = 'Timmy is busy right now. Your journal still works — try again shortly or continue manually.') {
|
||||
super(message);
|
||||
this.name = 'OverloadError';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReason(reason) {
|
||||
if (reason instanceof Error) return reason;
|
||||
const text = String(reason ?? '').trim().slice(0, 200);
|
||||
return new Error(text || 'Request cancelled.');
|
||||
}
|
||||
|
||||
function expiredMessage() {
|
||||
return 'Timmy is busy right now and your request timed out waiting. Your journal still works — try again shortly or continue manually.';
|
||||
}
|
||||
|
||||
export function createInferenceQueue({
|
||||
concurrency = 1,
|
||||
maxQueueDepth = 0,
|
||||
requestTimeoutMs = 60_000,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let active = 0;
|
||||
const waiting = [];
|
||||
const handles = new Map();
|
||||
|
||||
function expireStale(nowMs) {
|
||||
while (waiting.length) {
|
||||
if (nowMs - waiting[0].enqueuedAt <= requestTimeoutMs) break;
|
||||
const expired = waiting.shift();
|
||||
expired.settled = true;
|
||||
expired.abort(new OverloadError(expiredMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
function pump(nowMs = now()) {
|
||||
expireStale(nowMs);
|
||||
while (active < concurrency && waiting.length) {
|
||||
const entry = waiting.shift();
|
||||
if (entry.settled) continue;
|
||||
active += 1;
|
||||
entry.grant();
|
||||
}
|
||||
}
|
||||
|
||||
function release() {
|
||||
active -= 1;
|
||||
pump();
|
||||
}
|
||||
|
||||
async function admit(record) {
|
||||
const { controller } = record;
|
||||
if (controller.signal.aborted) throw normalizeReason(controller.signal.reason);
|
||||
expireStale(now());
|
||||
if (active < concurrency) {
|
||||
active += 1;
|
||||
return;
|
||||
}
|
||||
if (waiting.length >= maxQueueDepth) throw new OverloadError();
|
||||
await new Promise((resolve, reject) => {
|
||||
const entry = {
|
||||
enqueuedAt: now(),
|
||||
settled: false,
|
||||
grant: resolve,
|
||||
abort: reject,
|
||||
};
|
||||
record.entry = entry;
|
||||
waiting.push(entry);
|
||||
});
|
||||
record.entry = null;
|
||||
}
|
||||
|
||||
function run(task) {
|
||||
const controller = new AbortController();
|
||||
const record = { controller, entry: null };
|
||||
let resolveOutcome;
|
||||
let rejectOutcome;
|
||||
// Deliberately not an async function: the caller must receive the very
|
||||
// promise registered in `handles`, or cancellation lookups would target
|
||||
// a different object than the one they hold.
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
resolveOutcome = resolve;
|
||||
rejectOutcome = reject;
|
||||
});
|
||||
handles.set(promise, record);
|
||||
(async () => {
|
||||
let holdsSlot = false;
|
||||
try {
|
||||
await admit(record);
|
||||
holdsSlot = true;
|
||||
// The task body may not have started even though a slot is held; an
|
||||
// external cancel that raced ahead must still stop it here.
|
||||
if (controller.signal.aborted) throw normalizeReason(controller.signal.reason);
|
||||
resolveOutcome(await task(controller.signal));
|
||||
} catch (error) {
|
||||
rejectOutcome(error instanceof Error ? error : normalizeReason(error));
|
||||
} finally {
|
||||
if (holdsSlot) release();
|
||||
}
|
||||
// Drop the handle one microtask after the caller-visible promise
|
||||
// settles, so a same-tick external cancel still finds it.
|
||||
promise.then(
|
||||
() => handles.delete(promise),
|
||||
() => handles.delete(promise),
|
||||
);
|
||||
})();
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function cancel(handle, reason) {
|
||||
const record = handles.get(handle);
|
||||
if (!record) return false;
|
||||
handles.delete(handle);
|
||||
const normalized = normalizeReason(reason);
|
||||
const entry = record.entry;
|
||||
if (entry && !entry.settled) {
|
||||
entry.settled = true;
|
||||
const index = waiting.indexOf(entry);
|
||||
if (index >= 0) waiting.splice(index, 1);
|
||||
entry.abort(normalized);
|
||||
}
|
||||
record.controller.abort(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
pump();
|
||||
return { run, cancel };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Tracks where a log entry's visual fields came from, so an AI suggestion is never
|
||||
// shown as if the user had confirmed it.
|
||||
export const SUGGESTION_STATUS = {
|
||||
none: 'none',
|
||||
pending: 'pending',
|
||||
confirmed: 'confirmed',
|
||||
corrected: 'corrected',
|
||||
manual: 'manual',
|
||||
};
|
||||
|
||||
const VISUAL_FIELDS = ['bristolType', 'color'];
|
||||
|
||||
export function createSuggestionState(suggestion = null) {
|
||||
if (!suggestion || suggestion.status !== 'suggestion') {
|
||||
return { status: SUGGESTION_STATUS.none, suggestion: null, affirmed: false, correctedFields: [] };
|
||||
}
|
||||
return {
|
||||
status: SUGGESTION_STATUS.pending,
|
||||
suggestion: {
|
||||
bristolType: suggestion.bristolType,
|
||||
color: suggestion.color,
|
||||
confidence: Number(suggestion.confidence),
|
||||
},
|
||||
affirmed: false,
|
||||
correctedFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function confirmSuggestion(state = {}) {
|
||||
if (!state.suggestion || state.status !== SUGGESTION_STATUS.pending) return { ...state };
|
||||
return { ...state, status: SUGGESTION_STATUS.confirmed, affirmed: true };
|
||||
}
|
||||
|
||||
export function changeVisualField(state = {}, field, value) {
|
||||
const correctedFields = [...(state.correctedFields || [])];
|
||||
if (!VISUAL_FIELDS.includes(field)) return { ...state, correctedFields };
|
||||
const suggested = state.suggestion ? state.suggestion[field] : undefined;
|
||||
if (suggested === undefined) {
|
||||
return {
|
||||
...state,
|
||||
status: state.status === SUGGESTION_STATUS.none ? SUGGESTION_STATUS.manual : state.status,
|
||||
correctedFields,
|
||||
};
|
||||
}
|
||||
if (value === suggested) return { ...state, correctedFields };
|
||||
if (!correctedFields.includes(field)) correctedFields.push(field);
|
||||
return { ...state, status: SUGGESTION_STATUS.corrected, affirmed: false, correctedFields };
|
||||
}
|
||||
|
||||
function percent(value) {
|
||||
return Math.round(Number(value) * 100);
|
||||
}
|
||||
|
||||
export function provenanceCopy(state = {}, current = {}) {
|
||||
const suggestion = state.suggestion;
|
||||
const status = suggestion || state.status === SUGGESTION_STATUS.manual
|
||||
? state.status
|
||||
: SUGGESTION_STATUS.none;
|
||||
if (status === SUGGESTION_STATUS.none) return { status, label: '', detail: '', aria: '' };
|
||||
if (status === SUGGESTION_STATUS.manual) {
|
||||
return {
|
||||
status,
|
||||
label: 'YOUR OWN ENTRY',
|
||||
detail: 'You chose every field yourself. No AI suggestion is applied.',
|
||||
aria: `You chose Type ${current.bristolType}, ${current.color} yourself. No AI suggestion is applied.`,
|
||||
};
|
||||
}
|
||||
const confidence = percent(suggestion.confidence);
|
||||
const suggestedSummary = `Type ${suggestion.bristolType}, ${suggestion.color}`;
|
||||
if (status === SUGGESTION_STATUS.pending) {
|
||||
return {
|
||||
status,
|
||||
label: 'AI SUGGESTION',
|
||||
detail: `Suggested ${suggestedSummary} — provisional and not applied yet. You choose whether to use it.`,
|
||||
aria: `AI suggestion with ${confidence} percent confidence: ${suggestedSummary}. This is a visual suggestion only, not a diagnosis, and it is not saved until you confirm it.`,
|
||||
};
|
||||
}
|
||||
if (status === SUGGESTION_STATUS.corrected) {
|
||||
const chosen = `Type ${current.bristolType}, ${current.color}`;
|
||||
return {
|
||||
status,
|
||||
label: 'CORRECTED BY YOU',
|
||||
detail: `AI suggested ${suggestedSummary}. You changed it to ${chosen} — your values are saved, not the suggestion. Urgency, discomfort, notes, and symptoms must come from you.`,
|
||||
aria: `You changed the AI suggestion. It suggested ${suggestedSummary}; you chose ${chosen}. Your values are saved, not the suggestion.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status,
|
||||
label: 'AI PREFILLED',
|
||||
detail: 'AI suggested only form and visible color, and you confirmed it. Urgency, discomfort, notes, and symptoms must come from you.',
|
||||
aria: `You confirmed the AI suggestion with ${confidence} percent confidence: ${suggestedSummary}. You can still change any of it; the suggestion is a visual aid, not a diagnosis.`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Decides whether the photo-first flow can promise AI analysis.
|
||||
// When it cannot, logging must still reach manual Bristol entry in one action.
|
||||
const MANUAL_LABEL = 'Continue without AI';
|
||||
const DISABLED_LABEL = 'Log it manually';
|
||||
|
||||
export function visionReadiness(status) {
|
||||
if (!status) {
|
||||
return {
|
||||
phase: 'checking',
|
||||
ready: false,
|
||||
manualOnly: true,
|
||||
manualLabel: MANUAL_LABEL,
|
||||
message: 'Checking the vision worker. Manual logging is already available.',
|
||||
};
|
||||
}
|
||||
if (status.enabled === false) {
|
||||
return {
|
||||
phase: 'disabled',
|
||||
ready: false,
|
||||
manualOnly: true,
|
||||
manualLabel: DISABLED_LABEL,
|
||||
message: 'AI photo analysis is off in this build. Manual logging is fully available.',
|
||||
};
|
||||
}
|
||||
if (status.providerReady !== true) {
|
||||
return {
|
||||
phase: 'offline',
|
||||
ready: false,
|
||||
manualOnly: true,
|
||||
manualLabel: MANUAL_LABEL,
|
||||
message: 'Vision worker offline. Manual logging is still available.',
|
||||
};
|
||||
}
|
||||
return { phase: 'ready', ready: true, manualOnly: false, manualLabel: '', message: '' };
|
||||
}
|
||||
@@ -7,22 +7,18 @@ function providerEndpoint(baseUrl) {
|
||||
return `${url.toString().replace(/\/$/, '')}/chat/completions`;
|
||||
}
|
||||
|
||||
export async function analyzePhoto({ payload, fetchImpl = fetch, config, signal }) {
|
||||
export async function analyzePhoto({ payload, fetchImpl = fetch, config }) {
|
||||
const photo = validatePhotoPayload(payload);
|
||||
if (!config?.model) throw new Error('AI analysis is not configured.');
|
||||
const endpoint = providerEndpoint(config.baseUrl);
|
||||
// The provider call honours both its own deadline and a per-request
|
||||
// cancellation so abandoned clients stop consuming inference capacity.
|
||||
const timeoutSignal = AbortSignal.timeout(config.requestTimeoutMs || 60_000);
|
||||
const combinedSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer ' + String(config.apiKey || 'local-proxy'),
|
||||
authorization: `Bearer ${config.apiKey || 'local-proxy'}`,
|
||||
},
|
||||
body: JSON.stringify(buildVisionRequest({ imageDataUrl: photo.imageDataUrl, model: config.model })),
|
||||
signal: combinedSignal,
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs || 60_000),
|
||||
}).catch(() => { throw new Error('AI analysis is temporarily unavailable. Continue manually.'); });
|
||||
if (!response.ok) throw new Error('AI analysis is temporarily unavailable. Continue manually.');
|
||||
let data;
|
||||
|
||||
@@ -17,8 +17,32 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
|
||||
.btn{min-height:50px;border:0;border-radius:16px;padding:0 17px;font-weight:800;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px}.btn:active{transform:scale(.98)}.btn:disabled{opacity:.45;cursor:not-allowed}.btn-primary{background:var(--ink);color:#fff}.btn-secondary{background:var(--teal-soft);color:var(--teal)}.btn-ghost{background:transparent;border:1px solid var(--line)}.btn-danger{background:var(--red);color:#fff}.btn-wide{width:100%}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.fine{font-size:12px;color:var(--muted);line-height:1.45}.empty{text-align:center;padding:22px 10px;color:var(--muted)}.empty img{width:68px}.empty h3{color:var(--ink);margin-top:8px}.empty p{margin-bottom:0}
|
||||
.chat-page{display:flex;flex-direction:column;min-height:calc(100vh - 175px)}.chat-title{padding-bottom:8px}.agent-status{display:flex;align-items:center;gap:11px;padding:11px 13px;background:rgba(255,253,250,.7);border:1px solid var(--line);border-radius:17px;margin-bottom:12px}.agent-status>i{width:10px;height:10px;border-radius:50%;background:#a9a39d;box-shadow:0 0 0 5px rgba(169,163,157,.13)}.agent-status.connected>i{background:#209479;box-shadow:0 0 0 5px rgba(32,148,121,.13)}.agent-status.locked>i{background:#d19b27}.agent-status strong,.agent-status small{display:block}.agent-status strong{font-size:13px}.agent-status small{font-size:11px;color:var(--muted);margin-top:2px}.conversation{background:var(--surface);border:1px solid var(--line);border-radius:23px;padding:13px;box-shadow:0 10px 30px rgba(55,40,31,.05)}.chat{display:flex;flex-direction:column;gap:9px;min-height:195px;max-height:42vh;overflow:auto;padding:4px 1px 14px}.bubble{max-width:86%;padding:11px 13px;border-radius:17px;line-height:1.42;font-size:14px;white-space:pre-wrap}.bubble.timmy{align-self:flex-start;background:#efebe4;border-bottom-left-radius:5px}.bubble.user{align-self:flex-end;background:var(--teal);color:white;border-bottom-right-radius:5px}.thinking{display:flex;gap:4px}.thinking span{width:6px;height:6px;border-radius:50%;background:#8b847d;animation:blink 1s infinite}.thinking span:nth-child(2){animation-delay:.15s}.thinking span:nth-child(3){animation-delay:.3s}@keyframes blink{50%{opacity:.25;transform:translateY(-2px)}}.composer{display:grid;grid-template-columns:1fr 45px;gap:8px;align-items:end;background:#f0ece5;border-radius:18px;padding:6px}.composer textarea{border:0;background:transparent;resize:none;min-height:42px;max-height:110px;padding:10px 9px;outline:0;color:var(--ink)}.composer button{width:44px;height:44px;border:0;border-radius:14px;background:var(--ink);color:white;font-size:22px;cursor:pointer}.composer button:disabled{opacity:.4}.composer-note{font-size:10px;color:var(--muted);margin:7px 5px 0;line-height:1.35}.chat-error{font-size:12px;color:var(--red);margin:0 4px 8px}.safety-line{margin:13px 4px 0;color:var(--muted);font-size:11px;line-height:1.45}.safety-line strong{color:var(--ink)}.unlock-card{background:#fff8e6;border:1px solid #ead9a9;border-radius:20px;padding:14px;margin-bottom:12px}.unlock-card>label{display:block;font-size:12px;font-weight:800;margin-bottom:7px}.unlock-row{display:grid;grid-template-columns:1fr auto;gap:8px}.unlock-card .fine{margin:8px 2px 0}
|
||||
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}.progress-step-1{width:33.34%}.progress-step-2{width:66.68%}.progress-step-3{width:100%}
|
||||
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:22px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
||||
.draft-resume{display:flex;align-items:center;justify-content:space-between;gap:10px;background:#fdf3d8;border:1px solid #ecd9a3;border-radius:14px;padding:8px 11px;margin:0 0 14px;font-size:12px;line-height:1.35;color:#5d4a12}.draft-resume strong{font-weight:800}
|
||||
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.capture-choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.capture-status{background:var(--red-soft);color:var(--red);border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.4}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:16px 10px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
||||
.privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)}
|
||||
.staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72}
|
||||
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
||||
/* #13 AI suggestion provenance: provisional, confirmed, and corrected states never look alike */
|
||||
.scan-result.provisional{border:1.5px dashed var(--teal)}
|
||||
.ai-provenance[data-provenance="confirmed"]{border:1px solid #b7ded6;background:var(--teal-soft)}
|
||||
.ai-provenance[data-provenance="corrected"]{border:1px solid #e6cd9c;background:#fdf4e4}
|
||||
.ai-provenance[data-provenance="corrected"] .ai-badge{color:#8a6412}
|
||||
.provenance-note{font-size:12px;line-height:1.45;color:var(--muted);margin:8px 0 0;padding-left:9px;border-left:3px solid #cfc5b6}
|
||||
.ai-provenance[data-provenance="corrected"] .provenance-note{border-left-color:#e0b96a}
|
||||
.followup{background:#fdf7ea;border:1px solid #eeddb5;border-radius:18px;padding:13px;margin:15px 0 0}
|
||||
.followup h3{font-size:15px;margin:0 0 4px}
|
||||
.followup>.fine{margin-bottom:6px}
|
||||
.followup-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:9px 0;border-top:1px solid var(--line)}
|
||||
.followup-row>span{font-size:13px;line-height:1.35}
|
||||
.followup-map{display:block;font-size:10px;font-weight:600;color:var(--muted);margin-top:3px}
|
||||
.followup-toggle{display:flex;gap:6px}
|
||||
.followup-toggle button{min-height:40px;min-width:54px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--muted);font-weight:800;font-size:12px;cursor:pointer}
|
||||
.followup-toggle button.selected{background:var(--ink);color:#fff;border-color:var(--ink)}
|
||||
.followup-guidance{margin-top:12px;background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:12px}
|
||||
.followup-guidance>strong{display:block;font-size:13px}
|
||||
.followup-guidance ul{margin:8px 0 0;padding-left:18px}
|
||||
.followup-guidance li{font-size:12.5px;line-height:1.45;color:#4d453e;margin-bottom:4px}
|
||||
.followup-guidance .fine{margin:8px 0 0}
|
||||
.followup-guidance.escalated{background:var(--red-soft);border-color:#eec1ba}
|
||||
.followup-guidance.escalated>strong{color:#75261f}
|
||||
@@ -1,92 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js';
|
||||
|
||||
const origin = 'http://127.0.0.1:4173';
|
||||
const accessCode = 'test-agent-access-code-2026';
|
||||
const configured = () => resolveHermesAgentConfig({
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
|
||||
TIMMY_PUBLIC_ORIGIN: origin,
|
||||
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function unlock(service, token) {
|
||||
await service.unlock({ origin, accessCode });
|
||||
return token;
|
||||
}
|
||||
|
||||
test('chat requests beyond the queue depth get a stable sanitized overload fallback', async () => {
|
||||
let clock = 0;
|
||||
const gate = deferred();
|
||||
let started = 0;
|
||||
const service = createHermesAgentService({
|
||||
config: { ...configured(), maxQueueDepth: 1 },
|
||||
randomToken: () => 'queue-cookie',
|
||||
now: () => clock,
|
||||
runTurn: async () => {
|
||||
started += 1;
|
||||
return gate.promise;
|
||||
},
|
||||
});
|
||||
await unlock(service);
|
||||
|
||||
const first = service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'first', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(started, 1);
|
||||
|
||||
const second = service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'second', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(started, 1, 'second request waits in the bounded queue');
|
||||
|
||||
await assert.rejects(() => service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'third', ledger: [] } }), error => {
|
||||
assert.equal(error.status, 503);
|
||||
assert.match(error.message, /busy|try again/i);
|
||||
assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir/i);
|
||||
return true;
|
||||
}, 'request beyond max queue depth fails fast with sanitized copy');
|
||||
|
||||
gate.resolve({ reply: 'eventually', sessionId: 'private-session' });
|
||||
assert.match((await first).reply, /eventually/);
|
||||
assert.match((await second).reply, /eventually/);
|
||||
});
|
||||
|
||||
test('a chat request abandoned by its browser is cancelled without running Hermes and frees its slot', async () => {
|
||||
let clock = 0;
|
||||
const gate = deferred();
|
||||
const started = [];
|
||||
const service = createHermesAgentService({
|
||||
config: { ...configured(), maxQueueDepth: 2 },
|
||||
randomToken: () => 'disconnect-cookie',
|
||||
now: () => clock,
|
||||
runTurn: async ({ signal }) => {
|
||||
started.push('run');
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
gate.promise.then(resolve, reject);
|
||||
});
|
||||
},
|
||||
});
|
||||
await unlock(service, 'disconnect-cookie');
|
||||
|
||||
const active = service.chat({ origin, cookieToken: 'disconnect-cookie', payload: { message: 'holding slot', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const abandonSignal = new AbortController();
|
||||
const abandoned = service.chat({ origin, cookieToken: 'disconnect-cookie', payload: { message: 'queued', ledger: [] }, signal: abandonSignal.signal });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
abandonSignal.abort(new Error('client disconnected'));
|
||||
await assert.rejects(() => abandoned, error => error.status === 503 && /closed|disconnect/i.test(error.message));
|
||||
|
||||
gate.resolve({ reply: 'late answer', sessionId: 'private-session' });
|
||||
assert.match((await active).reply, /late answer/);
|
||||
assert.deepEqual(started, ['run'], 'the cancelled request never invoked a Hermes turn');
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
|
||||
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig, runHermesCliTurn } from '../src/hermes-agent-service.js';
|
||||
|
||||
const origin = 'http://127.0.0.1:4173';
|
||||
const accessCode = 'test-agent-access-code-2026';
|
||||
const configured = () => resolveHermesAgentConfig({
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
|
||||
TIMMY_PUBLIC_ORIGIN: origin,
|
||||
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
|
||||
});
|
||||
|
||||
async function waitUntil(check, timeoutMs = 3_000, stepMs = 20) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) return true;
|
||||
await new Promise(resolve => setTimeout(resolve, stepMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAlive(pid) {
|
||||
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
test('aborting an in-flight Hermes turn kills the child promptly and leaves no orphan process', async () => {
|
||||
const dir = await mkdir(join(tmpdir(), `timmy-orphan-${Date.now()}-`), { recursive: true });
|
||||
const pidFile = join(dir, 'child.pid');
|
||||
const fixture = join(dir, 'slow-hermes.sh');
|
||||
// Prints valid Hermes output immediately, then idles long enough that only
|
||||
// an explicit kill can end it. `exec` makes the recorded PID the sleeper.
|
||||
await writeFile(fixture, `#!/usr/bin/env bash\nprintf '%s\\n' $$ > '${pidFile}'\necho "session_id: fixture_kill_001"\necho "thinking"\nexec sleep 60\n`, { mode: fsConstants.S_IRWXU });
|
||||
|
||||
const controller = new AbortController();
|
||||
const turn = runHermesCliTurn({
|
||||
prompt: 'hello',
|
||||
hermesSessionId: null,
|
||||
config: { ...configured(), command: fixture, workdir: dir },
|
||||
signal: controller.signal,
|
||||
env: { TMPDIR: dir },
|
||||
});
|
||||
|
||||
const pidAppeared = await waitUntil(async () => {
|
||||
try { Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8')); return true; } catch { return false; }
|
||||
}, 4_000);
|
||||
assert.ok(pidAppeared, 'fixture child started');
|
||||
const pid = Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8'));
|
||||
assert.ok(isAlive(pid), 'child is running before cancellation');
|
||||
|
||||
const startedAt = Date.now();
|
||||
controller.abort(new Error('client disconnected'));
|
||||
await assert.rejects(() => Promise.race([
|
||||
turn,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('turn was not cancelled')), 5_000)),
|
||||
]), error => /disconnect|abort|killed|terminated/i.test(String(error?.message || error?.code || '')));
|
||||
const cancelMs = Date.now() - startedAt;
|
||||
assert.ok(cancelMs < 4_000, `cancellation returned promptly (took ${cancelMs}ms)`);
|
||||
|
||||
const reaped = await waitUntil(() => !isAlive(pid), 4_000);
|
||||
assert.ok(reaped, 'child process was actually killed — no orphan subprocess remains');
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}, { timeout: 20_000 });
|
||||
|
||||
test('a queued chat past the queue deadline is cleaned up with a sanitized timeout response and zero extra Hermes calls', async () => {
|
||||
let clock = 0;
|
||||
let started = 0;
|
||||
let releaseActive;
|
||||
const service = createHermesAgentService({
|
||||
config: { ...configured(), timeoutMs: 50 },
|
||||
randomToken: () => 'deadline-cookie',
|
||||
now: () => clock,
|
||||
runTurn: async () => {
|
||||
started += 1;
|
||||
return new Promise(resolve => { releaseActive = resolve; });
|
||||
},
|
||||
});
|
||||
await service.unlock({ origin, accessCode });
|
||||
|
||||
const active = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'wedge', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(started, 1);
|
||||
|
||||
const queued = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'waiting', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(started, 1);
|
||||
|
||||
clock += 200; // past the 50ms queue deadline
|
||||
// Attach the rejection expectation before the sweep runs so the expired
|
||||
// request never counts as an unhandled rejection.
|
||||
const expiryExpectation = assert.rejects(() => queued, error => {
|
||||
assert.ok(error instanceof AgentGatewayError);
|
||||
assert.equal(error.status, 503);
|
||||
assert.match(error.message, /timed out|busy/i);
|
||||
assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir|auth/i);
|
||||
return true;
|
||||
});
|
||||
service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'sweep trigger', ledger: [] } }).catch(() => {});
|
||||
await expiryExpectation;
|
||||
assert.equal(started, 1, 'expired request never reached Hermes');
|
||||
releaseActive({ reply: 'late but valid', sessionId: 'deadline_session_01' });
|
||||
assert.match((await active).reply, /late but valid/);
|
||||
});
|
||||
@@ -20,7 +20,9 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite'
|
||||
assert.match(workflow, /npm test/);
|
||||
assert.match(workflow, /npm run test:ui/);
|
||||
assert.match(workflow, /npm run test:photo/);
|
||||
assert.match(workflow, /npm run test:mobile-capture/);
|
||||
assert.match(workflow, /npm run test:sleek/);
|
||||
assert.match(workflow, /npm run test:manual-fallback/);
|
||||
assert.match(workflow, /npm audit --audit-level=high/);
|
||||
assert.match(workflow, /npm run check:syntax/);
|
||||
assert.match(workflow, /npm run check:diff/);
|
||||
@@ -38,6 +40,44 @@ test('default unit suite includes the self-host bootstrap contract', async () =>
|
||||
assert.match(packageJson.scripts.test, /tests\/selfhost-bootstrap\.test\.js/);
|
||||
});
|
||||
|
||||
test('default unit suite covers the manual fallback and draft contracts', async () => {
|
||||
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
|
||||
assert.match(packageJson.scripts.test, /tests\/draft\.test\.js/);
|
||||
assert.match(packageJson.scripts.test, /tests\/vision-readiness\.test\.js/);
|
||||
assert.equal(packageJson.scripts['test:manual-fallback'], 'node tests/manual-fallback.acceptance.mjs');
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check src\/draft\.js/);
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check src\/vision-readiness\.js/);
|
||||
});
|
||||
|
||||
test('provisional AI suggestion state is a unit-and-browser-gated contract', async () => {
|
||||
const [packageJsonText, workflow] = await Promise.all([
|
||||
readFile(packagePath, 'utf8'),
|
||||
readFile(workflowPath, 'utf8'),
|
||||
]);
|
||||
const packageJson = JSON.parse(packageJsonText);
|
||||
|
||||
assert.match(packageJson.scripts.test, /tests\/suggestion\.test\.js/);
|
||||
assert.equal(packageJson.scripts['test:provisional'], 'node tests/provisional-suggestion.acceptance.mjs');
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check src\/suggestion\.js/);
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/provisional-suggestion\.acceptance\.mjs/);
|
||||
assert.match(workflow, /npm run test:provisional/);
|
||||
});
|
||||
|
||||
test('hard-stool follow-up is a unit-and-browser-gated safety contract', async () => {
|
||||
const [packageJsonText, workflow] = await Promise.all([
|
||||
readFile(packagePath, 'utf8'),
|
||||
readFile(workflowPath, 'utf8'),
|
||||
]);
|
||||
const packageJson = JSON.parse(packageJsonText);
|
||||
|
||||
assert.match(packageJson.scripts.test, /tests\/followup\.test\.js/);
|
||||
assert.equal(packageJson.scripts['test:followup'], 'node tests/followup.acceptance.mjs');
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check src\/followup\.js/);
|
||||
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/followup\.acceptance\.mjs/);
|
||||
assert.match(workflow, /npm run test:followup/);
|
||||
});
|
||||
|
||||
test('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => {
|
||||
const [packageJson, workflow] = await Promise.all([
|
||||
readFile(packagePath, 'utf8').then(JSON.parse),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDraft, draftNotice, isDraftDirty } from '../src/draft.js';
|
||||
|
||||
test('a fresh draft is empty and reports nothing to resume', () => {
|
||||
const draft = createDraft();
|
||||
assert.equal(isDraftDirty(draft), false);
|
||||
assert.equal(draftNotice(draft), '');
|
||||
assert.equal(draft.bristolType, 4);
|
||||
assert.equal(draft.color, 'brown');
|
||||
assert.deepEqual(draft.symptoms, {});
|
||||
});
|
||||
|
||||
test('a fresh draft is independent so one log cannot leak into the next', () => {
|
||||
const first = createDraft();
|
||||
first.symptoms.blood = true;
|
||||
first.note = 'leaked';
|
||||
const second = createDraft();
|
||||
assert.equal(second.symptoms.blood, undefined);
|
||||
assert.equal(second.note, '');
|
||||
assert.equal(isDraftDirty(second), false);
|
||||
});
|
||||
|
||||
test('any user-entered context makes the draft worth resuming', () => {
|
||||
const cases = [
|
||||
[{ typeChosen: true, bristolType: 6 }, /type 6/i],
|
||||
[{ note: 'After lunch' }, /note/i],
|
||||
[{ urgency: 3 }, /urgency/i],
|
||||
[{ discomfort: 2 }, /discomfort/i],
|
||||
[{ photoDataUrl: 'data:image/jpeg;base64,abc' }, /photo/i],
|
||||
[{ symptoms: { blood: true } }, /symptom/i],
|
||||
];
|
||||
for (const [patch, expected] of cases) {
|
||||
const draft = { ...createDraft(), ...patch };
|
||||
assert.equal(isDraftDirty(draft), true, JSON.stringify(patch));
|
||||
assert.match(draftNotice(draft), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('untouched defaults are not treated as user context', () => {
|
||||
assert.equal(isDraftDirty({ ...createDraft(), bristolType: 7 }), false);
|
||||
assert.equal(isDraftDirty({ ...createDraft(), color: 'brown', urgency: 0, discomfort: 0, note: ' ' }), false);
|
||||
assert.equal(draftNotice({ ...createDraft(), color: 'brown' }), '');
|
||||
});
|
||||
|
||||
test('a changed color is user context and unconfirmed AI defaults are not', () => {
|
||||
assert.equal(isDraftDirty({ ...createDraft(), color: 'black' }), true);
|
||||
assert.match(draftNotice({ ...createDraft(), color: 'black' }), /color/i);
|
||||
});
|
||||
|
||||
test('draft copy never implies the photo or note left this device', () => {
|
||||
const notice = draftNotice({ ...createDraft(), typeChosen: true, photoDataUrl: 'data:image/jpeg;base64,abc', note: 'x' });
|
||||
assert.doesNotMatch(notice, /upload|server|sent|cloud/i);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Slow Hermes fixture for bounded-queue acceptance tests. Normal prompts
|
||||
// answer immediately; prompts containing HOLD block until a release file
|
||||
// appears inside the agent workdir (the value after `--in`), so tests can
|
||||
// saturate the queue deterministically without touching shared /tmp state.
|
||||
import { existsSync } from 'node:fs';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const promptIndex = args.indexOf('-q');
|
||||
const prompt = promptIndex >= 0 ? args[promptIndex + 1] : '';
|
||||
const workdirIndex = args.indexOf('--in');
|
||||
const workdir = workdirIndex >= 0 ? args[workdirIndex + 1] : '/tmp';
|
||||
appendFileSync(`${workdir}/fixture-pids.log`, `${process.pid}\n`);
|
||||
process.stdout.write('session_id: slow_fixture_2026\n');
|
||||
if (/HOLD/.test(prompt)) {
|
||||
const releaseFile = `${workdir}/hermes-release`;
|
||||
while (!existsSync(releaseFile)) await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
process.stdout.write('Hermes fixture answered the bounded request.\n');
|
||||
@@ -0,0 +1,104 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
await mkdir('artifacts', { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||
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));
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'suggestion', isStool: true, bristolType: 2, color: 'brown', confidence: 0.71,
|
||||
imageQuality: 'good', observations: 'Small, separate, hard-looking lumps.',
|
||||
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
||||
}),
|
||||
}));
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// 1. A hard/lumpy photo suggestion still contains only visible form, colour, and quality.
|
||||
await page.locator('[data-scan]').click();
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.locator('#ai-consent').check();
|
||||
await page.locator('#analyze-photo').click();
|
||||
const suggestion = page.locator('[data-provenance="pending"]');
|
||||
await suggestion.waitFor();
|
||||
const suggestionText = await suggestion.innerText();
|
||||
assert.match(suggestionText, /Type 2/);
|
||||
assert.doesNotMatch(suggestionText, /sore|dehydrat|tarry|difficult to pass|blood/i, 'the photo suggestion may never mention user-reported symptoms');
|
||||
|
||||
// 2. Confirming the suggestion and moving to the safety step offers the follow-up for a hard form.
|
||||
await page.locator('#use-suggestion').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
const block = page.locator('[data-followup-block]');
|
||||
await block.waitFor();
|
||||
assert.equal(await block.count(), 1, 'a Bristol 2 entry asks the hard-stool follow-up');
|
||||
assert.equal(await page.locator('[data-followup]').count(), 10, 'five questions, each with an explicit yes and no');
|
||||
const askedIds = await page.locator('.followup-row').evaluateAll(rows => rows.map(row => row.dataset.question));
|
||||
assert.deepEqual(askedIds.sort(), ['bloodVisible', 'dehydration', 'difficultToPass', 'soreness', 'tarryBlack']);
|
||||
assert.match(await block.innerText(), /never (guesses|infers)/i, 'the copy states these are user-reported, never inferred');
|
||||
assert.equal(await page.locator('[data-followup].selected').count(), 0, 'no question may be pre-answered');
|
||||
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'an unanswered follow-up never escalates');
|
||||
assert.match(await page.locator('#followup-output').innerText(), /answer/i, 'the user is asked to answer rather than given invented findings');
|
||||
|
||||
// 3. A mild hard-stool report gets proportionate non-diagnostic self-care and no escalation.
|
||||
for (const id of ['difficultToPass', 'soreness', 'dehydration']) await page.locator(`[data-followup="${id}"][data-answer="yes"]`).click();
|
||||
await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click();
|
||||
await page.locator('[data-followup="bloodVisible"][data-answer="no"]').click();
|
||||
const guidance = page.locator('#followup-guidance');
|
||||
await guidance.waitFor();
|
||||
assert.match(await guidance.innerText(), /not a diagnosis/i);
|
||||
assert.match(await guidance.innerText(), /cannot rule out/i);
|
||||
assert.match(await guidance.innerText(), /bleeding/i);
|
||||
assert.match(await guidance.innerText(), /water|hydrat/i);
|
||||
assert.match(await guidance.innerText(), /strain/i);
|
||||
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'dark brown plus soreness must not escalate');
|
||||
await page.screenshot({ path: 'artifacts/followup-hard-stool-mobile.png' });
|
||||
|
||||
// 4. Confirming truly black/tarry stool lands on the existing deterministic path.
|
||||
await page.locator('[data-followup="tarryBlack"][data-answer="yes"]').click();
|
||||
const alert = page.locator('#urgent-box .alert');
|
||||
await alert.waitFor();
|
||||
assert.match(await alert.innerText(), /Pause and get medical help\./);
|
||||
assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), true, 'the confirmed answer sets the app\'s own red-flag control');
|
||||
|
||||
// 5. Clearing that answer clears the escalation instead of sticking.
|
||||
await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click();
|
||||
assert.equal(await page.locator('#urgent-box .alert').count(), 0);
|
||||
assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), false);
|
||||
|
||||
// 6. Visible blood is the other existing deterministic flag.
|
||||
await page.locator('[data-followup="bloodVisible"][data-answer="yes"]').click();
|
||||
await page.locator('#urgent-box .alert').waitFor();
|
||||
assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true);
|
||||
|
||||
// 7. Non-hard forms never see the follow-up.
|
||||
await page.locator('#back').click();
|
||||
await page.locator('#back').click();
|
||||
await page.locator('[data-type="6"]').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
assert.equal(await page.locator('[data-followup-block]').count(), 0, 'loose forms are not asked hard-stool questions');
|
||||
assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true, 'an answer the user already confirmed is not silently dropped');
|
||||
assert.equal(await page.locator('#urgent-box .alert').count(), 1, 'the deterministic path still reflects that confirmed answer');
|
||||
await page.locator('[data-symptom="blood"]').uncheck();
|
||||
assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'clearing the confirmed flag clears the escalation');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
console.log('PASS hard-stool follow-up is user-reported, non-diagnostic for mild reports, and lands on the deterministic escalation path');
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { detectUrgentFlags } from '../src/domain.js';
|
||||
import {
|
||||
HARD_STOOL_QUESTIONS,
|
||||
HARD_STOOL_TYPES,
|
||||
createFollowUpAnswers,
|
||||
evaluateHardStoolFollowUp,
|
||||
isHardStoolForm,
|
||||
needsHardStoolFollowUp,
|
||||
} from '../src/followup.js';
|
||||
|
||||
// Field finding #67: after a hard/lumpy (Bristol 1-2) log, Timmy asks a short
|
||||
// user-reported follow-up. Nothing here may be inferred from the photo, dark brown
|
||||
// must never be silently escalated as black stool, and confirmed red flags must land
|
||||
// on the same deterministic escalation path the rest of the app already uses.
|
||||
|
||||
const questionIds = () => HARD_STOOL_QUESTIONS.map((question) => question.id);
|
||||
|
||||
test('the hard-stool follow-up only appears for Bristol 1 and 2', () => {
|
||||
assert.deepEqual([...HARD_STOOL_TYPES], [1, 2]);
|
||||
assert.equal(isHardStoolForm(1), true);
|
||||
assert.equal(isHardStoolForm(2), true);
|
||||
for (const type of [3, 4, 5, 6, 7]) assert.equal(isHardStoolForm(type), false, `Type ${type} is not hard/lumpy`);
|
||||
assert.equal(isHardStoolForm(undefined), false);
|
||||
assert.equal(isHardStoolForm('2'), true, 'a form value from the DOM is still the same form');
|
||||
assert.equal(needsHardStoolFollowUp({ bristolType: 2 }), true);
|
||||
assert.equal(needsHardStoolFollowUp({ bristolType: 4 }), false);
|
||||
assert.equal(needsHardStoolFollowUp({}), false);
|
||||
});
|
||||
|
||||
test('every follow-up question starts unanswered and is never pre-filled', () => {
|
||||
const answers = createFollowUpAnswers();
|
||||
|
||||
assert.deepEqual(questionIds().slice().sort(), [
|
||||
'bloodVisible',
|
||||
'dehydration',
|
||||
'difficultToPass',
|
||||
'soreness',
|
||||
'tarryBlack',
|
||||
]);
|
||||
assert.equal(HARD_STOOL_QUESTIONS.length, 5);
|
||||
for (const question of HARD_STOOL_QUESTIONS) {
|
||||
assert.equal(answers[question.id], null, `${question.id} must start unanswered`);
|
||||
assert.match(question.label, /was it|any |do you/i, `${question.id} must ask the user, not infer`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unanswered follow-up never escalates and never invents guidance', () => {
|
||||
const verdict = evaluateHardStoolFollowUp(createFollowUpAnswers());
|
||||
|
||||
assert.equal(verdict.answered, false);
|
||||
assert.equal(verdict.urgent, false);
|
||||
assert.deepEqual(verdict.flags, []);
|
||||
assert.equal(verdict.message, '');
|
||||
assert.deepEqual(verdict.guidance, [], 'unanswered questions must not produce self-care copy');
|
||||
});
|
||||
|
||||
test('a mild hard-stool report keeps non-diagnostic self-care and no escalation', () => {
|
||||
const verdict = evaluateHardStoolFollowUp({
|
||||
difficultToPass: true,
|
||||
soreness: true,
|
||||
bloodVisible: false,
|
||||
tarryBlack: false,
|
||||
dehydration: true,
|
||||
});
|
||||
|
||||
assert.equal(verdict.answered, true);
|
||||
assert.equal(verdict.urgent, false);
|
||||
assert.deepEqual(verdict.flags, []);
|
||||
assert.equal(verdict.message, '');
|
||||
assert.ok(verdict.guidance.length >= 3, 'a mild report still gets proportionate self-care');
|
||||
assert.equal(verdict.guidance.some((line) => /water|hydrat/i.test(line)), true);
|
||||
assert.equal(verdict.guidance.some((line) => /strain/i.test(line)), true);
|
||||
});
|
||||
|
||||
test('the mild guidance never implies the photo ruled out bleeding', () => {
|
||||
const verdict = evaluateHardStoolFollowUp({ soreness: true, bloodVisible: false, tarryBlack: false });
|
||||
|
||||
assert.match(verdict.note, /not a diagnosis/i);
|
||||
assert.match(verdict.note, /cannot rule out|did not rule out/i);
|
||||
assert.match(verdict.note, /bleeding/i);
|
||||
assert.doesNotMatch(verdict.note, /rules out|safe|cleared|no bleeding/i);
|
||||
});
|
||||
|
||||
test('confirmed visible blood maps onto the existing deterministic blood red flag', () => {
|
||||
const verdict = evaluateHardStoolFollowUp({ bloodVisible: true });
|
||||
|
||||
assert.equal(verdict.urgent, true);
|
||||
assert.deepEqual(verdict.flags, ['blood']);
|
||||
assert.equal(verdict.message, detectUrgentFlags({ blood: true }).message, 'urgent copy comes from the canonical path');
|
||||
assert.deepEqual(verdict.guidance, [], 'escalation copy must not be softened with self-care');
|
||||
});
|
||||
|
||||
test('confirmed black or tarry stool maps onto the existing deterministic blackOrDarkRed red flag', () => {
|
||||
const verdict = evaluateHardStoolFollowUp({ tarryBlack: true });
|
||||
|
||||
assert.equal(verdict.urgent, true);
|
||||
assert.deepEqual(verdict.flags, ['blackOrDarkRed']);
|
||||
assert.equal(verdict.message, detectUrgentFlags({ blackOrDarkRed: true }).message);
|
||||
});
|
||||
|
||||
test('dark brown is not silently escalated as black stool', () => {
|
||||
const explicitDarkBrown = evaluateHardStoolFollowUp({ difficultToPass: true, tarryBlack: false });
|
||||
const unansweredTarry = evaluateHardStoolFollowUp({ difficultToPass: true });
|
||||
const colorAlone = evaluateHardStoolFollowUp({ color: 'black', note: 'dark brown' });
|
||||
|
||||
assert.equal(explicitDarkBrown.urgent, false, 'answering "dark brown" must not escalate');
|
||||
assert.equal(unansweredTarry.urgent, false, 'an unanswered colour question is not a yes');
|
||||
assert.equal(colorAlone.urgent, false, 'a draft colour value is never treated as a confirmed tarry finding');
|
||||
});
|
||||
|
||||
test('an unanswered question is not silently treated as a no', () => {
|
||||
const partial = evaluateHardStoolFollowUp({ soreness: true });
|
||||
|
||||
assert.equal(partial.answered, true);
|
||||
assert.equal(partial.urgent, false);
|
||||
assert.deepEqual(partial.flags, []);
|
||||
assert.equal(partial.unanswered.length, 4, 'the verdict reports what the user still has not answered');
|
||||
const all = evaluateHardStoolFollowUp({ bloodVisible: false, tarryBlack: false, soreness: false, difficultToPass: false, dehydration: false });
|
||||
assert.deepEqual(all.unanswered, []);
|
||||
});
|
||||
@@ -193,21 +193,16 @@ test('Hermes child receives only an explicit non-secret environment allowlist',
|
||||
});
|
||||
});
|
||||
|
||||
test('saturated turns queue within bounds and excess load fails closed without leaking process detail', async () => {
|
||||
test('upstream failures and concurrency fail closed without leaking process detail', async () => {
|
||||
let release;
|
||||
const runTurn = () => new Promise(resolve => { release = resolve; });
|
||||
const service = createHermesAgentService({ config: { ...configured(), maxQueueDepth: 1 }, randomToken: () => 'browser-cookie', runTurn });
|
||||
const service = createHermesAgentService({ config: configured(), randomToken: () => 'browser-cookie', runTurn });
|
||||
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
|
||||
const active = service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'first', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const queued = service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'second', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'third', ledger: [] } }), 503);
|
||||
release({ reply: 'first done', sessionId: 'private-session' });
|
||||
assert.match((await active).reply, /first done/);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
release({ reply: 'queued done', sessionId: 'private-session' });
|
||||
assert.match((await queued).reply, /queued done/);
|
||||
await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'second', ledger: [] } }), 429);
|
||||
release({ reply: 'done', sessionId: 'private-session' });
|
||||
await active;
|
||||
|
||||
const broken = createHermesAgentService({ config: configured(), randomToken: () => 'broken-cookie', runTurn: async () => { throw new Error('spawn /root/.hermes/auth.json SECRET'); } });
|
||||
await broken.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('a queued request past its deadline is expired deterministically and its place is reusable', async () => {
|
||||
let clock = 0;
|
||||
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 100, now: () => clock });
|
||||
|
||||
const wedgeGate = deferred();
|
||||
const active = queue.run(signal => {
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('upstream aborted')));
|
||||
wedgeGate.promise.then(resolve, reject);
|
||||
});
|
||||
});
|
||||
active.catch(() => {});
|
||||
await Promise.resolve();
|
||||
|
||||
let started = false;
|
||||
const queued = queue.run(() => { started = true; return 'never'; });
|
||||
await Promise.resolve();
|
||||
|
||||
clock += 150; // deadline (submitted at t=0, limit 100) has elapsed
|
||||
const probe = queue.run(() => 'ok'); // queue activity triggers the deadline sweep
|
||||
|
||||
await assert.rejects(() => queued, error => {
|
||||
assert.ok(error instanceof OverloadError);
|
||||
assert.match(error.message, /timed out|busy/i);
|
||||
return true;
|
||||
});
|
||||
assert.equal(started, false, 'the expired request must never reach the provider');
|
||||
|
||||
// Capacity is intact: cancelling the wedged holder lets the surviving request through.
|
||||
queue.cancel(active, 'cleanup');
|
||||
assert.equal(await Promise.race([probe, new Promise((_, reject) => setTimeout(() => reject(new Error('probe starved')), 200))]), 'ok');
|
||||
});
|
||||
|
||||
test('client disconnect cancels a queued request before it reaches the provider', async () => {
|
||||
let clock = 0;
|
||||
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 2, requestTimeoutMs: 60_000, now: () => clock });
|
||||
|
||||
const wedgeGate = deferred();
|
||||
const active = queue.run(() => wedgeGate.promise);
|
||||
active.catch(() => {});
|
||||
await Promise.resolve();
|
||||
|
||||
let sawStart = false;
|
||||
const queued = queue.run(() => { sawStart = true; return 'too late'; });
|
||||
await Promise.resolve();
|
||||
|
||||
queue.cancel(queued, 'client disconnected');
|
||||
await assert.rejects(() => queued, error => error.message === 'client disconnected');
|
||||
assert.equal(sawStart, false, 'cancelled request never invoked its task');
|
||||
|
||||
const admitted = queue.run(() => 'admitted-after-cancel');
|
||||
wedgeGate.resolve('wedge-done');
|
||||
assert.equal(await admitted, 'admitted-after-cancel');
|
||||
assert.equal(await active, 'wedge-done');
|
||||
});
|
||||
|
||||
test('cancelling an already-running task aborts its signal so buffers can be released', async () => {
|
||||
let clock = 0;
|
||||
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 60_000, now: () => clock });
|
||||
|
||||
const observed = [];
|
||||
const running = queue.run(async signal => {
|
||||
observed.push(signal);
|
||||
signal.addEventListener('abort', () => observed.push(`aborted:${signal.reason?.message ?? signal.reason}`));
|
||||
await new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('task stopped')));
|
||||
setTimeout(resolve, 5_000);
|
||||
});
|
||||
});
|
||||
|
||||
// Let the wrapper admit the request and actually invoke the task body.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(observed.length, 1, 'task is genuinely running before cancellation');
|
||||
|
||||
queue.cancel(running, 'stop');
|
||||
await assert.rejects(() => running, /task stopped/);
|
||||
assert.ok(observed[0] instanceof AbortSignal, 'task received a real AbortSignal');
|
||||
assert.match(String(observed[1]), /stop/, 'running task observed the abort with its reason');
|
||||
|
||||
// The slot was released by the cancelled task, so the next request starts immediately.
|
||||
const next = queue.run(() => 'next-runs');
|
||||
assert.equal(await next, 'next-runs');
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const fixture = fileURLToPath(new URL('./fixtures/slow-hermes.mjs', import.meta.url));
|
||||
const origin = 'http://127.0.0.1:4187';
|
||||
const accessCode = 'queue-acceptance-code-2026';
|
||||
|
||||
async function waitReady(child) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}`);
|
||||
try { const response = await fetch(`${origin}/api/healthz`); if (response.ok) return; } catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error('server did not become ready');
|
||||
}
|
||||
|
||||
function post(path, body, { cookie = '', signal } = {}) {
|
||||
return fetch(`${origin}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
origin,
|
||||
'sec-fetch-site': 'same-origin',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
}).then(
|
||||
async response => ({ status: response.status, body: await response.json().catch(() => ({})) }),
|
||||
error => ({ failed: true, reason: String(error?.name || error) }),
|
||||
);
|
||||
}
|
||||
|
||||
test('bounded queue end-to-end: disconnect cleanup, overload fallback, urgent bypass, recovery', async t => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), 'timmy-queue-accept-'));
|
||||
const releaseFile = join(workdir, 'hermes-release');
|
||||
const pidLog = join(workdir, 'fixture-pids.log');
|
||||
await chmod(fixture, fsConstants.S_IRWXU);
|
||||
const child = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: '4187',
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
|
||||
TIMMY_PUBLIC_ORIGIN: origin,
|
||||
TIMMY_AGENT_WORKDIR: workdir,
|
||||
TIMMY_HERMES_COMMAND: fixture,
|
||||
TIMMY_AGENT_MAX_CONCURRENT_TURNS: '1',
|
||||
TIMMY_AGENT_MAX_QUEUE_DEPTH: '2',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let serverLogs = '';
|
||||
child.stderr.on('data', chunk => { serverLogs += String(chunk); });
|
||||
t.after(async () => {
|
||||
child.kill('SIGTERM');
|
||||
await rm(workdir, { recursive: true, force: true }).catch(() => {});
|
||||
await rm(releaseFile, { force: true }).catch(() => {});
|
||||
});
|
||||
await waitReady(child);
|
||||
|
||||
// Authenticate once and keep the cookie for the whole scenario.
|
||||
const rawResponse = await fetch(`${origin}/api/agent/unlock`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
|
||||
body: JSON.stringify({ accessCode }),
|
||||
});
|
||||
assert.equal(rawResponse.status, 200);
|
||||
const browserCookie = rawResponse.headers.get('set-cookie').split(';', 1)[0];
|
||||
|
||||
// Fill the slot: this HOLD runs until the fixture release file appears.
|
||||
const heldOne = post('/api/agent/chat', { message: 'HOLD one', ledger: [] }, { cookie: browserCookie });
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
|
||||
// 1. Disconnect cleanup: this request waits in the queue. When its client
|
||||
// vanishes, Timmy must drop it and free the queued place.
|
||||
const disconnectController = new AbortController();
|
||||
const doomed = post('/api/agent/chat', { message: 'HOLD doomed', ledger: [] }, { cookie: browserCookie, signal: disconnectController.signal });
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
disconnectController.abort();
|
||||
const doomedOutcome = await doomed;
|
||||
assert.equal(doomedOutcome.failed, true, 'abandoned client saw its request cancelled');
|
||||
|
||||
// If the freed place was reclaimed, both follow-up HOLDs are admitted
|
||||
// (slot + queue of two minus one freed place). A sanitized 503 here would
|
||||
// mean the cancelled client did not release capacity.
|
||||
const heldTwo = post('/api/agent/chat', { message: 'HOLD two', ledger: [] }, { cookie: browserCookie });
|
||||
const heldThree = post('/api/agent/chat', { message: 'HOLD three', ledger: [] }, { cookie: browserCookie });
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 2. Overload: everything is saturated now, so a further request must fail
|
||||
// fast with the stable manual-fallback copy and zero provider contact.
|
||||
const overloadResult = await post('/api/agent/chat', { message: 'overload probe', ledger: [] }, { cookie: browserCookie });
|
||||
assert.equal(overloadResult.status, 503);
|
||||
assert.match(overloadResult.body.error, /busy|try again|manually/i);
|
||||
assert.doesNotMatch(overloadResult.body.error, /spawn|hermes|session|token|workdir|fixture/i);
|
||||
|
||||
// 3. Urgent bypass while saturated: deterministic safety answer, zero calls.
|
||||
const urgentResult = await post('/api/agent/chat', { message: 'I have rectal bleeding', ledger: [] }, { cookie: browserCookie });
|
||||
assert.equal(urgentResult.status, 200);
|
||||
assert.equal(urgentResult.body.safetyOverride, true);
|
||||
assert.match(urgentResult.body.reply, /medical help/i);
|
||||
|
||||
// 4. Recovery: releasing the fixture lets every surviving request complete.
|
||||
await writeFile(releaseFile, 'go');
|
||||
for (const [label, promise] of [['one', heldOne], ['two', heldTwo], ['three', heldThree]]) {
|
||||
const settled = await Promise.race([
|
||||
promise,
|
||||
new Promise(resolve => setTimeout(() => resolve({ status: 'TEST-TIMEOUT' }), 12_000)),
|
||||
]);
|
||||
assert.equal(settled.status, 200, `${label} completed after recovery`);
|
||||
assert.match(settled.body.reply, /bounded request/);
|
||||
assert.doesNotMatch(JSON.stringify(settled.body), /slow_fixture_2026|session_id/);
|
||||
}
|
||||
|
||||
// 5. No orphan subprocesses: every fixture PID recorded at spawn is gone.
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
const recordedPids = ((await readFile(pidLog, 'utf8').catch(() => '')) || '')
|
||||
.split('\n').map(Number).filter(Boolean);
|
||||
assert.equal(recordedPids.length, 3, `exactly three turns ran (saw ${recordedPids.length})`);
|
||||
for (const pid of [...new Set(recordedPids)]) {
|
||||
let alive = true;
|
||||
try { process.kill(pid, 0); } catch { alive = false; }
|
||||
assert.equal(alive, false, `fixture process ${pid} outlived its turn`);
|
||||
}
|
||||
assert.doesNotMatch(serverLogs, /access-code|secret|token/i);
|
||||
}, { timeout: 40_000 });
|
||||
@@ -1,72 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('vision analysis runs with bounded concurrency and strict FIFO ordering', async () => {
|
||||
let clock = 0;
|
||||
const gates = new Map();
|
||||
const started = [];
|
||||
const queue = createInferenceQueue({ concurrency: 2, maxQueueDepth: 4, requestTimeoutMs: 10_000, now: () => clock });
|
||||
|
||||
const run = label => queue.run(signal => {
|
||||
started.push(label);
|
||||
const gate = deferred();
|
||||
gates.set(label, gate);
|
||||
gate.promise.catch(() => {});
|
||||
return gate.promise;
|
||||
});
|
||||
|
||||
const first = run('first');
|
||||
const second = run('second');
|
||||
const third = run('third');
|
||||
const fourth = run('fourth');
|
||||
while (started.length < 2) await Promise.resolve();
|
||||
assert.deepEqual(started, ['first', 'second'], 'only concurrency slots start immediately');
|
||||
|
||||
gates.get('first').resolve({ ok: 'first' });
|
||||
assert.equal((await first).ok, 'first');
|
||||
|
||||
// The slot freed by `first` must be granted to `third` (head of the queue).
|
||||
while (!gates.has('third')) await Promise.resolve();
|
||||
gates.get('third').resolve({ ok: 'third' });
|
||||
|
||||
// Only after `third` releases its slot may `fourth` start.
|
||||
while (!gates.has('fourth')) await Promise.resolve();
|
||||
gates.get('second').resolve({ ok: 'second' });
|
||||
gates.get('fourth').resolve({ ok: 'fourth' });
|
||||
|
||||
assert.deepEqual(await Promise.all([second, third, fourth]), [{ ok: 'second' }, { ok: 'third' }, { ok: 'fourth' }]);
|
||||
assert.equal(started.length, 4, 'every admitted request eventually started');
|
||||
assert.deepEqual([...new Set(started)], ['first', 'second', 'third', 'fourth'], 'each queued request started exactly once');
|
||||
assert.equal(started.indexOf('third') < started.indexOf('fourth'), true, 'queued requests were granted in FIFO order');
|
||||
});
|
||||
|
||||
test('overloaded submissions fail fast with a stable sanitized manual-fallback error', async () => {
|
||||
let clock = 0;
|
||||
const gates = [];
|
||||
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 10_000, now: () => clock });
|
||||
|
||||
const hold = queue.run(() => { const gate = deferred(); gates.push(gate); gate.promise.catch(() => {}); return gate.promise; });
|
||||
await Promise.resolve();
|
||||
const held = queue.run(() => new Promise(() => {}));
|
||||
await Promise.resolve();
|
||||
|
||||
await assert.rejects(() => queue.run(() => new Promise(() => {})), error => {
|
||||
assert.ok(error instanceof OverloadError);
|
||||
assert.match(error.message, /busy|full/i);
|
||||
assert.doesNotMatch(error.message, /stack|internal|fetch|127\.0\.0\.1/i);
|
||||
return true;
|
||||
}, 'excess request beyond maxQueueDepth is rejected immediately');
|
||||
|
||||
// Active and already-queued work continues unaffected by the rejected submission.
|
||||
gates[0].resolve('done');
|
||||
assert.equal(await hold, 'done');
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js';
|
||||
import { analyzePhoto } from '../src/vision-service.js';
|
||||
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
|
||||
|
||||
const origin = 'http://127.0.0.1:4173';
|
||||
const accessCode = 'test-agent-access-code-2026';
|
||||
const configured = () => resolveHermesAgentConfig({
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
|
||||
TIMMY_PUBLIC_ORIGIN: origin,
|
||||
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('urgent messages intercept before Hermes even when the queue is saturated', async () => {
|
||||
let clock = 0;
|
||||
let hermesCalls = 0;
|
||||
const gate = deferred();
|
||||
const service = createHermesAgentService({
|
||||
config: { ...configured(), maxQueueDepth: 0 },
|
||||
randomToken: () => 'urgent-cookie',
|
||||
now: () => clock,
|
||||
runTurn: async () => {
|
||||
hermesCalls += 1;
|
||||
return gate.promise;
|
||||
},
|
||||
});
|
||||
await service.unlock({ origin, accessCode });
|
||||
|
||||
// Saturate the single slot so any queued path would be rejected as overloaded.
|
||||
const active = service.chat({ origin, cookieToken: 'urgent-cookie', payload: { message: 'normal question', ledger: [] } });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(hermesCalls, 1);
|
||||
|
||||
const urgent = await service.chat({ origin, cookieToken: 'urgent-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } });
|
||||
|
||||
assert.equal(hermesCalls, 1, 'Hermes was called exactly once — only for the non-urgent request');
|
||||
assert.equal(urgent.safetyOverride, true);
|
||||
assert.match(urgent.reply, /medical help/i);
|
||||
gate.resolve({ reply: 'late', sessionId: 'urgent_session_01' });
|
||||
assert.match((await active).reply, /late/);
|
||||
});
|
||||
|
||||
test('vision analysis is bounded by the shared queue and overload returns the manual-fallback copy', async () => {
|
||||
let clock = 0;
|
||||
let providerCalls = 0;
|
||||
const gates = [];
|
||||
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 60_000, now: () => clock });
|
||||
|
||||
const visionFetch = async (url, options) => {
|
||||
providerCalls += 1;
|
||||
const gate = deferred();
|
||||
gates.push(gate);
|
||||
options.signal?.addEventListener('abort', () => gate.reject(new Error('aborted')));
|
||||
return new Promise((resolve, reject) => {
|
||||
gate.promise.then(() => resolve({ ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.8, imageQuality: 'good', observations: 'ok' }) } }] }) }), reject);
|
||||
});
|
||||
};
|
||||
|
||||
const payload = { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true };
|
||||
const config = { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'test-key-not-real', model: 'm' };
|
||||
|
||||
const first = queue.run(signal => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal }));
|
||||
while (providerCalls < 1) await Promise.resolve();
|
||||
const second = queue.run(signal => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal }));
|
||||
await Promise.resolve();
|
||||
|
||||
await assert.rejects(() => queue.run((signal) => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal })), error => {
|
||||
assert.ok(error instanceof OverloadError);
|
||||
assert.match(error.message, /continue manually/i);
|
||||
assert.doesNotMatch(error.message, /api|key|token|127\.0\.0\.1|fetch/i);
|
||||
return true;
|
||||
}, 'third request beyond depth is overloaded with sanitized copy');
|
||||
|
||||
assert.equal(providerCalls, 1, 'overloaded request never reached the provider');
|
||||
gates[0].resolve('go');
|
||||
const result = await Promise.race([first, new Promise(resolve => setTimeout(() => resolve('pending'), 100))]);
|
||||
assert.notEqual(result, 'pending');
|
||||
});
|
||||
|
||||
test('vision requests carry cancellation so disconnected clients release provider work', async () => {
|
||||
let capturedSignal;
|
||||
const controller = new AbortController();
|
||||
const gate = deferred();
|
||||
const fetchImpl = async (url, options) => {
|
||||
capturedSignal = options.signal;
|
||||
options.signal?.addEventListener('abort', () => gate.reject(new Error('aborted')));
|
||||
return gate.promise;
|
||||
};
|
||||
const payload = { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true };
|
||||
const config = { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'test-key-not-real', model: 'm' };
|
||||
|
||||
const attempt = analyzePhoto({ payload, fetchImpl, config, signal: controller.signal });
|
||||
while (!capturedSignal) await Promise.resolve();
|
||||
|
||||
controller.abort(new Error('client disconnected'));
|
||||
await assert.rejects(() => attempt, /continue manually|unavailable/i);
|
||||
assert.equal(capturedSignal.aborted, true, 'the provider fetch observed the cancellation');
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
await mkdir('artifacts', { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||
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 analysisRequests = 0;
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: false, modelSeen: false }),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => { analysisRequests += 1; return route.abort(); });
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// 1. Provider down: manual Bristol entry must be reachable in one action, with no dead control.
|
||||
await page.locator('[data-scan]').click();
|
||||
await page.getByText(/Vision worker offline/i).waitFor();
|
||||
assert.equal(await page.locator('#analyze-photo').count(), 0, 'offline flow must not offer a dead analyze control');
|
||||
const pickFallback = page.locator('#manual-from-scan');
|
||||
assert.equal(await pickFallback.isVisible(), true);
|
||||
assert.match(await pickFallback.innerText(), /without AI|manual/i);
|
||||
await pickFallback.click();
|
||||
assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'one action reaches manual Bristol entry');
|
||||
assert.equal(analysisRequests, 0, 'the offline path never uploads a photo');
|
||||
await page.screenshot({ path: 'artifacts/manual-fallback-offline-mobile.png' });
|
||||
|
||||
// 2. Provider down after a photo is chosen: the ready step still offers the one-action manual path.
|
||||
await page.locator('#close-sheet').click();
|
||||
await page.locator('[data-scan]').click();
|
||||
await page.getByText(/Vision worker offline/i).waitFor();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.getByText(/Ready for your review/i).waitFor();
|
||||
assert.equal(await page.locator('.consent-card').count(), 0, 'no consent is requested when the worker cannot analyze');
|
||||
assert.equal(await page.locator('#analyze-photo').count(), 0, 'no dead analyze control on the ready step');
|
||||
const readyFallback = page.locator('#manual-from-scan');
|
||||
assert.equal(await readyFallback.isVisible(), true);
|
||||
await readyFallback.click();
|
||||
assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'ready-step offline path is still one action');
|
||||
assert.equal(analysisRequests, 0, 'a chosen photo is never uploaded when analysis is unavailable');
|
||||
await page.screenshot({ path: 'artifacts/manual-fallback-ready-offline-mobile.png' });
|
||||
|
||||
// 3. Entered context and the chosen photo survive recoverable navigation.
|
||||
await page.locator('[data-type="6"]').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#urgency').fill('3');
|
||||
await page.locator('#note').fill('After lunch');
|
||||
await page.locator('#close-sheet').click();
|
||||
await page.locator('[data-view="calendar"]').first().click();
|
||||
await page.locator('[data-view="home"]').first().click();
|
||||
await page.locator('[data-log]').first().click();
|
||||
|
||||
assert.match(await page.locator('.draft-resume').innerText(), /draft kept/i);
|
||||
assert.equal(await page.locator('.draft-resume').innerText().then(t => /Type 6/i.test(t)), true);
|
||||
assert.equal(await page.locator('[data-type="6"]').getAttribute('class').then(v => v.includes('selected')), true, 'chosen type is restored');
|
||||
await page.locator('#next').click();
|
||||
assert.equal(await page.locator('#urgency').inputValue(), '3', 'urgency is restored');
|
||||
assert.equal(await page.locator('#note').inputValue(), 'After lunch', 'note is restored');
|
||||
await page.screenshot({ path: 'artifacts/draft-resumed-mobile.png' });
|
||||
|
||||
// 4. Start over clears the retained draft so nothing lingers silently.
|
||||
await page.locator('#discard-draft').click();
|
||||
assert.equal(await page.locator('.draft-resume').count(), 0);
|
||||
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), true, 'start over returns to an empty draft');
|
||||
assert.equal(await page.locator('#note').count(), 0, 'the draft is back at step 1');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
console.log('PASS offline one-action manual fallback, no dead controls, and resumable draft with explicit start over');
|
||||
@@ -0,0 +1,59 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const viewports = [
|
||||
{ name: '390x844', width: 390, height: 844 },
|
||||
{ name: 'iPhone 15 class', width: 393, height: 852 },
|
||||
];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 2,
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let analysisRequests = 0;
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'synthetic-test-model' }),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => {
|
||||
analysisRequests += 1;
|
||||
return route.abort();
|
||||
});
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.locator('[data-scan]').click();
|
||||
|
||||
const camera = page.locator('#camera-photo');
|
||||
const gallery = page.locator('#gallery-photo');
|
||||
assert.equal(await camera.getAttribute('capture'), 'environment', `${viewport.name}: camera input uses the rear camera`);
|
||||
assert.equal(await gallery.getAttribute('capture'), null, `${viewport.name}: gallery input does not force camera capture`);
|
||||
assert.equal(await page.getByText('Take photo', { exact: true }).isVisible(), true);
|
||||
assert.equal(await page.getByText('Choose from gallery', { exact: true }).isVisible(), true);
|
||||
|
||||
await camera.dispatchEvent('cancel');
|
||||
assert.match(await page.locator('[role="status"]').innerText(), /camera.*closed|permission.*denied/i);
|
||||
assert.equal(await page.getByText('Continue without AI', { exact: true }).isVisible(), true);
|
||||
assert.equal(analysisRequests, 0, `${viewport.name}: cancellation never uploads`);
|
||||
|
||||
await page.locator('#gallery-photo').setInputFiles({
|
||||
name: 'corrupt-synthetic.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
buffer: Buffer.from('not an image'),
|
||||
});
|
||||
assert.match(await page.locator('.scan-result').innerText(), /could not be read/i);
|
||||
await page.getByText('Try another photo', { exact: true }).click();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
|
||||
assert.equal(await page.locator('#retake-photo').isVisible(), true);
|
||||
assert.equal(analysisRequests, 0, `${viewport.name}: corrupt and unconsented photos never upload`);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('PASS camera/gallery paths recover from cancellation at 390x844 and iPhone-class viewport without upload');
|
||||
@@ -31,7 +31,7 @@ await page.locator('[data-scan]').click();
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await page.screenshot({ path: 'artifacts/selfhost-photo-first-mobile.png', fullPage: false });
|
||||
assert.equal(await page.getByText('One photo. Two useful suggestions.').isVisible(), true);
|
||||
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
|
||||
assert.match(await page.locator('.consent-card').innerText(), /self-hosted model server/i);
|
||||
assert.doesNotMatch(await page.locator('.consent-card').innerText(), /provider’s terms/i);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
await mkdir('artifacts', { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||
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));
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'suggestion', isStool: true, bristolType: 4, color: 'brown', confidence: 0.83,
|
||||
imageQuality: 'good', observations: 'Smooth, formed appearance.',
|
||||
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
||||
}),
|
||||
}));
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// 1. The AI suggestion is visibly provisional and its screen-reader copy states confidence and user control.
|
||||
await page.locator('[data-scan]').click();
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.locator('#ai-consent').check();
|
||||
await page.locator('#analyze-photo').click();
|
||||
await page.getByText(/83% confidence/i).waitFor();
|
||||
|
||||
const suggested = page.locator('[data-provenance="pending"]');
|
||||
assert.equal(await suggested.count(), 1, 'the AI result is a distinct provisional state');
|
||||
assert.match(await suggested.innerText(), /AI SUGGESTION/);
|
||||
const suggestedAria = await suggested.getAttribute('aria-label');
|
||||
assert.match(suggestedAria, /83 percent confidence/i, 'screen-reader copy states the AI confidence');
|
||||
assert.match(suggestedAria, /not saved until you confirm/i, 'screen-reader copy states that the user is in control');
|
||||
await page.screenshot({ path: 'artifacts/provisional-suggestion-pending-mobile.png' });
|
||||
|
||||
// 2. Confirming the suggestion produces a different, clearly labelled state.
|
||||
await page.locator('#use-suggestion').click();
|
||||
const confirmed = page.locator('[data-provenance="confirmed"]');
|
||||
assert.equal(await confirmed.count(), 1, 'confirmation is a distinct state from the raw suggestion');
|
||||
assert.equal(await page.locator('[data-provenance="pending"]').count(), 0);
|
||||
assert.match(await confirmed.innerText(), /AI PREFILLED/);
|
||||
assert.match(await confirmed.locator('[role="status"]').innerText(), /you confirmed/i);
|
||||
await page.screenshot({ path: 'artifacts/provisional-suggestion-confirmed-mobile.png' });
|
||||
|
||||
// 3. User context entered after the suggestion survives a correction.
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#urgency').fill('3');
|
||||
await page.locator('#note').fill('After lunch');
|
||||
await page.locator('#back').click();
|
||||
await page.locator('[data-type="2"]').click();
|
||||
|
||||
const corrected = page.locator('[data-provenance="corrected"]');
|
||||
assert.equal(await corrected.count(), 1, 'changing the suggestion clears AI-confirmed state');
|
||||
assert.equal(await confirmed.count(), 0);
|
||||
assert.equal(await page.locator('[data-provenance="confirmed"]').count(), 0);
|
||||
assert.match(await corrected.innerText(), /CORRECTED BY YOU/);
|
||||
assert.match(await corrected.innerText(), /AI suggested Type 4/);
|
||||
const correctedAria = await corrected.getAttribute('aria-label');
|
||||
assert.match(correctedAria, /You changed/i);
|
||||
assert.match(correctedAria, /Type 4/);
|
||||
assert.match(correctedAria, /Type 2/);
|
||||
assert.equal(await page.locator('[data-type="2"]').getAttribute('class').then(v => v.includes('selected')), true);
|
||||
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), false);
|
||||
await page.screenshot({ path: 'artifacts/provisional-suggestion-corrected-mobile.png' });
|
||||
|
||||
// 4. The correction keeps every user-entered field and reports it honestly on later steps.
|
||||
await page.locator('#next').click();
|
||||
assert.equal(await page.locator('#color').inputValue(), 'brown', 'the confirmed color is kept, not silently re-suggested');
|
||||
assert.equal(await page.locator('#urgency').inputValue(), '3', 'user context is not lost when a suggestion is corrected');
|
||||
assert.equal(await page.locator('#note').inputValue(), 'After lunch');
|
||||
assert.match(await page.locator('.provenance-note').last().innerText(), /You changed it to Type 2/);
|
||||
assert.match(await page.locator('.provenance-note').last().innerText(), /must come from you/i);
|
||||
|
||||
// 5. Correcting the second suggested field keeps both corrections in the provenance record.
|
||||
await page.locator('#color').selectOption('black');
|
||||
await page.locator('#back').click();
|
||||
const recolored = page.locator('[data-provenance="corrected"]');
|
||||
assert.equal(await recolored.count(), 1);
|
||||
assert.match(await recolored.getAttribute('aria-label'), /Type 2, black/);
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
console.log('PASS AI suggestions stay provisional, confirmation is explicit, and correcting a suggestion clears AI-confirmed state without losing user context');
|
||||
@@ -10,6 +10,9 @@ test('release demo visibly explains the CI-protected browser path without overst
|
||||
|
||||
assert.match(demo, /Automated checks replay this synthetic path before review/);
|
||||
assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./);
|
||||
assert.match(demo, /Camera closed cleanly — gallery and manual logging are still available/);
|
||||
assert.match(demo, /#camera-photo.*dispatchEvent\('cancel'\)/s);
|
||||
assert.match(demo, /#gallery-photo.*synthetic-type4\.jpg/s);
|
||||
assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/);
|
||||
assert.match(demo, /The pinned bootstrap verifies both model files before starting on private loopback/);
|
||||
assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
|
||||
@@ -20,10 +23,128 @@ test('release demo visibly explains the CI-protected browser path without overst
|
||||
assert.match(demo, /SLEEK\. SIMPLE\.<br>HERMES-POWERED\./);
|
||||
});
|
||||
|
||||
test('release demo exercises the vision-offline manual path and the retained draft', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
|
||||
assert.match(demo, /Vision worker offline — the AI and consent steps disappear instead of failing after upload/);
|
||||
assert.match(demo, /No consent prompt and no upload when analysis cannot run/);
|
||||
assert.match(demo, /One tap still reaches manual Bristol entry/);
|
||||
assert.match(demo, /Closing the log keeps the draft in this session instead of throwing it away/);
|
||||
assert.match(demo, /Start over clears the kept draft — nothing lingers silently/);
|
||||
assert.match(demo, /#manual-from-scan/);
|
||||
assert.match(demo, /#discard-draft/);
|
||||
assert.match(demo, /\.draft-resume/);
|
||||
// The offline pass must be a real state change driven by the app's own readiness contract.
|
||||
assert.match(demo, /providerReady: false|visionOffline/);
|
||||
});
|
||||
|
||||
test('release demo shows the provisional, confirmed, and corrected suggestion states on camera', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
|
||||
assert.match(demo, /\[data-provenance="pending"\]/);
|
||||
assert.match(demo, /The suggestion stays provisional — nothing is applied or saved until you confirm it/);
|
||||
assert.match(demo, /Confirming is explicit, and every suggested value is still yours to overrule/);
|
||||
assert.match(demo, /Change a suggested value and Timmy clears the AI-confirmed state/);
|
||||
assert.match(demo, /\[data-provenance="corrected"\]/);
|
||||
assert.match(demo, /Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours/);
|
||||
assert.match(demo, /Type 2/, 'the retained draft in the demo must show the value the user corrected to');
|
||||
});
|
||||
|
||||
test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => {
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
assert.match(builder, /"test:sleek"/);
|
||||
assert.match(builder, /"test:mobile-capture"/);
|
||||
assert.match(builder, /"test:manual-fallback"/);
|
||||
assert.match(builder, /Manual fallback: vision-offline logging stays one action away with an in-session draft/);
|
||||
assert.match(builder, /"manual_fallback_draft_recovery": "passed"/);
|
||||
assert.match(builder, /"src\/draft\.js"/);
|
||||
assert.match(builder, /"src\/vision-readiness\.js"/);
|
||||
assert.match(builder, /offline vision status/);
|
||||
assert.match(builder, /"sleek_hermes_chat_acceptance": "passed"/);
|
||||
assert.match(builder, /Sleek three-destination shell/);
|
||||
assert.match(builder, /"bash", "-n", "scripts\/bootstrap_selfhost_smolvlm\.sh"/);
|
||||
});
|
||||
|
||||
test('release builder gates the provisional suggestion states the same way CI does', async () => {
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
assert.match(builder, /"test:provisional"/);
|
||||
assert.match(builder, /"provisional_suggestion_state": "passed"/);
|
||||
assert.match(builder, /"src\/suggestion\.js"/);
|
||||
assert.match(builder, /AI suggestions stay provisional/);
|
||||
});
|
||||
|
||||
test('release demo visibly exercises the user-reported hard-stool follow-up and its red-flag handoff', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
|
||||
assert.match(demo, /\[data-followup-block\]/);
|
||||
assert.match(demo, /Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo/);
|
||||
assert.match(demo, /\[data-followup="bloodVisible"\]\[data-answer="no"\]/);
|
||||
assert.match(demo, /Every answer is yours to give, and a skipped question stays unanswered/);
|
||||
assert.match(demo, /#followup-guidance/);
|
||||
assert.match(demo, /Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis/);
|
||||
assert.match(demo, /And it never claims the photo ruled out bleeding/);
|
||||
assert.match(demo, /\[data-followup="tarryBlack"\]\[data-answer="yes"\]/);
|
||||
assert.match(demo, /Say it was truly black and tarry and the same deterministic red-flag path fires instead/);
|
||||
assert.match(demo, /Dark brown is not silently escalated, and the flag clears the moment the answer changes/);
|
||||
});
|
||||
|
||||
test('release builder gates the hard-stool follow-up the same way CI does', async () => {
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
assert.match(builder, /"test:followup"/);
|
||||
assert.match(builder, /"hard_stool_followup": "passed"/);
|
||||
assert.match(builder, /"src\/followup\.js"/);
|
||||
assert.match(builder, /Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions/);
|
||||
});
|
||||
|
||||
test('release demo states the declared release delta on camera and proves it with a receipt', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
|
||||
// The delta comes from the builder, so a documentation-only release cannot imply a new feature.
|
||||
assert.match(demo, /TIMMY_RELEASE_DEMO_DELTA/);
|
||||
assert.match(demo, /This release: \$\{releaseDelta\}/);
|
||||
assert.match(demo, /no new feature is claimed/);
|
||||
// A caption alone is not evidence; the demo must emit a machine-readable receipt of what rendered.
|
||||
assert.match(demo, /TIMMY_DEMO_CAPTIONS_OUT/);
|
||||
assert.match(demo, /captionsShown\.push\(text\)/);
|
||||
assert.match(demo, /writeFile\(captionsOut/);
|
||||
});
|
||||
|
||||
test('release demo shows the byte-bearing controls the threat model governs, without inventing UI', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
|
||||
assert.match(demo, /\[data-view="privacy"\]/);
|
||||
assert.match(demo, /Every byte-bearing control the model names/);
|
||||
assert.match(demo, /indicate\('#export'\)/);
|
||||
assert.match(demo, /indicate\('#delete-all'\)/);
|
||||
assert.match(demo, /Delete all local data clears the ledger in this browser; there is no server copy of it to keep/);
|
||||
// Real controls only: the selectors must exist in the app shell, not be demo-only scaffolding.
|
||||
const app = await readFile(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.match(app, /id="export"/);
|
||||
assert.match(app, /id="delete-all"/);
|
||||
});
|
||||
|
||||
test('release builder refuses to publish without the on-camera delta receipt', async () => {
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
assert.match(builder, /TIMMY_RELEASE_DELTA/);
|
||||
assert.match(builder, /TIMMY_DEMO_CAPTIONS_OUT/);
|
||||
assert.match(builder, /Release demo produced no on-camera caption receipt/);
|
||||
assert.match(builder, /Release demo did not state the declared release delta on camera/);
|
||||
assert.match(builder, /Release demo is missing a required on-camera caption/);
|
||||
assert.match(builder, /"release_delta_visible_on_camera": "passed"/);
|
||||
assert.match(builder, /"release_delta": release_delta/);
|
||||
});
|
||||
|
||||
test('release demo captions expire and the contact sheet spans the whole recording', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
|
||||
// A caption that outlives its stated duration hangs the previous view's claim over the next screen.
|
||||
assert.match(demo, /creates via removal after duration|A caption must not outlive its stated duration/);
|
||||
assert.match(demo, /setTimeout\(\(\) => node\.remove\(\), 220\)/);
|
||||
// The sheet must cover the entire demo, not just its opening.
|
||||
assert.match(builder, /12 \/ demo_duration/);
|
||||
assert.match(builder, /Release demo contact sheet was not produced/);
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, rm, readFile } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const fixture = fileURLToPath(new URL('./fixtures/slow-hermes.mjs', import.meta.url));
|
||||
|
||||
test('server shutdown cancels in-flight turns and leaves no orphan Hermes subprocesses', async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), 'timmy-shutdown-'));
|
||||
await chmod(fixture, fsConstants.S_IRWXU);
|
||||
const child = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: '127.0.0.1',
|
||||
PORT: '4188',
|
||||
TIMMY_AGENT_ENABLED: 'true',
|
||||
TIMMY_AGENT_ACCESS_TOKEN: 'shutdown-accept-code-2026',
|
||||
TIMMY_PUBLIC_ORIGIN: 'http://127.0.0.1:4188',
|
||||
TIMMY_AGENT_WORKDIR: workdir,
|
||||
TIMMY_HERMES_COMMAND: fixture,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let reportedOrigin = '';
|
||||
child.stdout.on('data', chunk => {
|
||||
const match = String(chunk).match(/listening on http:\/\/([^:]+):(\d+)/);
|
||||
if (match && !reportedOrigin) reportedOrigin = `http://${match[1]}:${match[2]}`;
|
||||
});
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!reportedOrigin && Date.now() < deadline && child.exitCode === null) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.ok(reportedOrigin, 'server reported its port');
|
||||
|
||||
const unlockResponse = await fetch(`${reportedOrigin}/api/agent/unlock`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: reportedOrigin, 'sec-fetch-site': 'same-origin' },
|
||||
body: JSON.stringify({ accessCode: 'shutdown-accept-code-2026' }),
|
||||
});
|
||||
assert.equal(unlockResponse.status, 200);
|
||||
const browserCookie = unlockResponse.headers.get('set-cookie').split(';', 1)[0];
|
||||
|
||||
// Start a turn wedged on the fixture's HOLD gate.
|
||||
fetch(`${reportedOrigin}/api/agent/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: reportedOrigin, 'sec-fetch-site': 'same-origin', cookie: browserCookie },
|
||||
body: JSON.stringify({ message: 'HOLD shutdown probe', ledger: [] }),
|
||||
}).catch(() => {});
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
const pidLog = join(workdir, 'fixture-pids.log');
|
||||
// Wait until the fixture for this run's HOLD turn is actually alive.
|
||||
let lastPid = null;
|
||||
const spawnDeadline = Date.now() + 8_000;
|
||||
while (Date.now() < spawnDeadline) {
|
||||
const recorded = ((await readFile(pidLog, 'utf8').catch(() => '')) || '')
|
||||
.split('\n').map(Number).filter(Boolean);
|
||||
const candidate = recorded.at(-1);
|
||||
if (candidate) {
|
||||
let alive = true;
|
||||
try { process.kill(candidate, 0); } catch { alive = false; }
|
||||
if (alive) {
|
||||
lastPid = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.ok(lastPid, 'fixture subprocess was spawned for the in-flight turn');
|
||||
let aliveBefore = true;
|
||||
try { process.kill(lastPid, 0); } catch { aliveBefore = false; }
|
||||
assert.ok(aliveBefore, `fixture ${lastPid} is running before shutdown`);
|
||||
|
||||
// Shut the server down while the turn is still running.
|
||||
child.kill('SIGTERM');
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('server did not exit after SIGTERM')), 5_000);
|
||||
child.on('exit', code => { clearTimeout(timeout); resolve(code); });
|
||||
});
|
||||
|
||||
// The in-flight fixture must be gone — no orphan subprocess may survive.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
let aliveAfter = true;
|
||||
try { process.kill(lastPid, 0); } catch { aliveAfter = false; }
|
||||
assert.equal(aliveAfter, false, `fixture process ${lastPid} survived server shutdown`);
|
||||
await rm(workdir, { recursive: true, force: true }).catch(() => {});
|
||||
}, { timeout: 30_000 });
|
||||
@@ -95,3 +95,38 @@ test('no-store shell responses are returned without being cached', async () => {
|
||||
|
||||
assert.deepEqual(puts, []);
|
||||
});
|
||||
|
||||
function headerReader(values) {
|
||||
return name => values[String(name).toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
test('image bodies are never written into the shell cache', async () => {
|
||||
const request = { method: 'GET', url: 'https://example.test/timmy-staging/history/entry-photo.jpg' };
|
||||
const imageResponse = { headers: { get: headerReader({ 'content-type': 'image/jpeg' }) }, clone: () => ({}) };
|
||||
const { listeners, puts, getCacheOpenCount } = loadWorker({
|
||||
scope: 'https://example.test/timmy-staging/',
|
||||
fetchImpl: async () => imageResponse,
|
||||
});
|
||||
|
||||
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||
await response;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(await response, imageResponse);
|
||||
assert.deepEqual(puts, []);
|
||||
assert.equal(getCacheOpenCount(), 0);
|
||||
});
|
||||
|
||||
test('ordinary shell assets are still cached so offline logging keeps working', async () => {
|
||||
const request = { method: 'GET', url: 'https://example.test/app.js' };
|
||||
const assetResponse = { headers: { get: headerReader({ 'content-type': 'text/javascript' }) }, clone: () => ({}) };
|
||||
const { listeners, puts } = loadWorker({ fetchImpl: async () => assetResponse });
|
||||
|
||||
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||
await response;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(puts, [request.url]);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from '../src/suggestion.js';
|
||||
|
||||
test('keeps an AI suggestion provisional until the user acts on it', () => {
|
||||
const state = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
|
||||
|
||||
assert.equal(state.status, 'pending');
|
||||
assert.equal(state.affirmed, false);
|
||||
assert.deepEqual(state.suggestion, { bristolType: 4, color: 'brown', confidence: 0.83 });
|
||||
});
|
||||
|
||||
test('treats a missing or unusable suggestion as no suggestion at all', () => {
|
||||
assert.deepEqual(createSuggestionState(null).status, 'none');
|
||||
assert.deepEqual(createSuggestionState(undefined).suggestion, null);
|
||||
assert.deepEqual(createSuggestionState({ status: 'needs_user_input', reason: 'The image is too uncertain to prefill safely.' }).status, 'none');
|
||||
});
|
||||
|
||||
test('confirms a suggestion only when the user explicitly accepts it', () => {
|
||||
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
|
||||
const confirmed = confirmSuggestion(pending);
|
||||
|
||||
assert.equal(confirmed.status, 'confirmed');
|
||||
assert.equal(confirmed.affirmed, true);
|
||||
assert.deepEqual(confirmed.suggestion, pending.suggestion);
|
||||
|
||||
const nothing = confirmSuggestion(createSuggestionState(null));
|
||||
assert.equal(nothing.status, 'none');
|
||||
assert.equal(nothing.affirmed, false);
|
||||
assert.equal(confirmSuggestion(confirmed).status, 'confirmed');
|
||||
});
|
||||
|
||||
test('changing a suggested field clears AI-confirmed state but keeps the context', () => {
|
||||
const confirmed = confirmSuggestion(createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 }));
|
||||
const corrected = changeVisualField(confirmed, 'bristolType', 2);
|
||||
|
||||
assert.equal(corrected.status, 'corrected');
|
||||
assert.equal(corrected.affirmed, false);
|
||||
assert.deepEqual(corrected.suggestion, { bristolType: 4, color: 'brown', confidence: 0.83 });
|
||||
assert.deepEqual(corrected.correctedFields, ['bristolType']);
|
||||
|
||||
const recorrected = changeVisualField(corrected, 'color', 'black');
|
||||
assert.deepEqual(recorrected.correctedFields, ['bristolType', 'color']);
|
||||
assert.deepEqual(changeVisualField(recorrected, 'note', 'ignored').correctedFields, ['bristolType', 'color']);
|
||||
});
|
||||
|
||||
test('does not invent an AI correction when the user never had a suggestion', () => {
|
||||
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
|
||||
|
||||
assert.equal(manual.status, 'manual');
|
||||
assert.equal(manual.suggestion, null);
|
||||
assert.deepEqual(manual.correctedFields, []);
|
||||
});
|
||||
|
||||
test('re-picking the suggested value is not treated as a correction', () => {
|
||||
const confirmed = confirmSuggestion(createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 }));
|
||||
|
||||
assert.equal(changeVisualField(confirmed, 'bristolType', 4).status, 'confirmed');
|
||||
assert.deepEqual(changeVisualField(confirmed, 'bristolType', 4).correctedFields, []);
|
||||
});
|
||||
|
||||
test('labels suggested, confirmed, corrected, and manual states distinctly', () => {
|
||||
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
|
||||
const confirmed = confirmSuggestion(pending);
|
||||
const corrected = changeVisualField(confirmed, 'bristolType', 2);
|
||||
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
|
||||
|
||||
const labels = [pending, confirmed, corrected, manual].map(state => provenanceCopy(state, { bristolType: 2, color: 'brown' }).label);
|
||||
|
||||
assert.deepEqual(labels, ['AI SUGGESTION', 'AI PREFILLED', 'CORRECTED BY YOU', 'YOUR OWN ENTRY']);
|
||||
assert.equal(provenanceCopy(createSuggestionState(null), {}).label, '');
|
||||
});
|
||||
|
||||
test('screen-reader copy carries the AI confidence and who is in control', () => {
|
||||
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
|
||||
const confirmed = confirmSuggestion(pending);
|
||||
const corrected = changeVisualField(confirmed, 'bristolType', 2);
|
||||
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
|
||||
|
||||
assert.match(provenanceCopy(pending, { bristolType: 4, color: 'brown' }).aria, /83 percent confidence/i);
|
||||
assert.match(provenanceCopy(pending, { bristolType: 4, color: 'brown' }).aria, /not saved until you confirm/i);
|
||||
assert.match(provenanceCopy(confirmed, { bristolType: 4, color: 'brown' }).aria, /83 percent confidence/i);
|
||||
assert.match(provenanceCopy(confirmed, { bristolType: 4, color: 'brown' }).aria, /You confirmed/i);
|
||||
|
||||
const correctedAria = provenanceCopy(corrected, { bristolType: 2, color: 'brown' }).aria;
|
||||
assert.match(correctedAria, /You changed/i);
|
||||
assert.match(correctedAria, /Type 4/);
|
||||
assert.match(correctedAria, /Type 2/);
|
||||
assert.equal(corrected.affirmed, false);
|
||||
assert.match(provenanceCopy(manual, { bristolType: 6, color: 'brown' }).aria, /No AI suggestion/i);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const threatModelPath = new URL('../docs/PHOTO-DATAFLOW-THREAT-MODEL.md', import.meta.url);
|
||||
const productDecisionPath = new URL('../docs/PRODUCT-DECISIONS.md', import.meta.url);
|
||||
const roadmapPath = new URL('../ROADMAP.md', import.meta.url);
|
||||
|
||||
// Every hop that can hold, move, or leak a user's photo bytes has to be named
|
||||
// in the diagram, not only in prose.
|
||||
const REQUIRED_DIAGRAM_NODES = [
|
||||
['browser', /\bbrowser\b/i],
|
||||
['API ingress', /\bapi[- ]ingress\b/i],
|
||||
['model worker', /\bmodel worker\b/i],
|
||||
['transient buffers', /\btransient buffer/i],
|
||||
['training opt-in store', /\btraining opt-in\b/i],
|
||||
['logs', /\blogs?\b/i],
|
||||
['exports', /\bexports?\b/i],
|
||||
['deletion', /\bdeletion\b/i],
|
||||
];
|
||||
|
||||
// The abuse cases the ticket names explicitly, in the order they appear there.
|
||||
const REQUIRED_THREATS = [
|
||||
['replay', /\breplay\b/i],
|
||||
['oversized input', /\boversized input\b/i],
|
||||
['metadata leakage', /\bmetadata leakage\b/i],
|
||||
['poisoning', /\bpoisoning\b/i],
|
||||
['unauthorized training', /\bunauthorized training\b/i],
|
||||
['operator access', /\boperator access\b/i],
|
||||
];
|
||||
|
||||
function diagramBody(doc) {
|
||||
const match = doc.match(/```mermaid\r?\n([\s\S]*?)```/);
|
||||
assert.ok(match, 'the threat model must contain a fenced mermaid data-flow diagram');
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function threatBlocks(doc) {
|
||||
const blocks = new Map();
|
||||
const pattern = /^###\s+(T\d+)\s+—\s+(.+)$/gm;
|
||||
const headings = [...doc.matchAll(pattern)];
|
||||
headings.forEach((heading, index) => {
|
||||
const start = heading.index + heading[0].length;
|
||||
const end = index + 1 < headings.length ? headings[index + 1].index : doc.length;
|
||||
blocks.set(heading[1], { name: heading[2].trim(), body: doc.slice(start, end) });
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function knownIssueNumbers(roadmap) {
|
||||
return new Set([...roadmap.matchAll(/issues\/(\d+)/g)].map((match) => Number(match[1])));
|
||||
}
|
||||
|
||||
test('photo data-flow threat model diagrams every component that can hold or leak image bytes', async () => {
|
||||
const doc = await readFile(threatModelPath, 'utf8');
|
||||
const diagram = diagramBody(doc);
|
||||
|
||||
for (const [label, pattern] of REQUIRED_DIAGRAM_NODES) {
|
||||
assert.match(diagram, pattern, `the data-flow diagram must show the ${label} component`);
|
||||
}
|
||||
|
||||
// The diagram has to end somewhere: the model worker's bytes must not flow on.
|
||||
assert.match(diagram, /no retention|dropped|discarded|never persist/i,
|
||||
'the diagram must show that worker input is not retained');
|
||||
});
|
||||
|
||||
test('every required abuse case has a mitigation, an owner, and a real follow-up ticket', async () => {
|
||||
const [doc, roadmap] = await Promise.all([
|
||||
readFile(threatModelPath, 'utf8'),
|
||||
readFile(roadmapPath, 'utf8'),
|
||||
]);
|
||||
const known = knownIssueNumbers(roadmap);
|
||||
const blocks = threatBlocks(doc);
|
||||
|
||||
assert.ok(blocks.size > 0, 'threats must be numbered headings so their coverage is checkable');
|
||||
const named = [...blocks.values()].map((block) => block.name).join('\n');
|
||||
|
||||
for (const [label, pattern] of REQUIRED_THREATS) {
|
||||
assert.match(named, pattern, `the ${label} abuse case must be its own numbered threat`);
|
||||
|
||||
const block = [...blocks.values()].find((entry) => pattern.test(entry.name));
|
||||
assert.match(block.body, /^\s*-\s*\*\*Owner:\*\*\s*\S+/m,
|
||||
`${label} needs a named owner`);
|
||||
assert.match(block.body, /^\s*-\s*\*\*Testable follow-up:\*\*\s*#\d+/m,
|
||||
`${label} needs a testable follow-up ticket`);
|
||||
assert.match(block.body, /^\s*-\s*\*\*Mitigation:\*\*\s*\S+/m,
|
||||
`${label} needs a concrete mitigation, not a restatement of the risk`);
|
||||
assert.match(block.body, /^\s*-\s*\*\*Evidence:\*\*\s*\S+/m,
|
||||
`${label} needs the artifact that will prove the mitigation works`);
|
||||
|
||||
const followUp = Number(block.body.match(/^\s*-\s*\*\*Testable follow-up:\*\*\s*#(\d+)/m)[1]);
|
||||
assert.ok(known.has(followUp),
|
||||
`${label} points at #${followUp}, which is not a tracked ticket in ROADMAP.md`);
|
||||
assert.notEqual(followUp, 20, `${label} must delegate to work other than this threat model itself`);
|
||||
}
|
||||
});
|
||||
|
||||
test('threat model keeps the photo path non-diagnostic and free of real medical images', async () => {
|
||||
const doc = await readFile(threatModelPath, 'utf8');
|
||||
|
||||
assert.match(doc, /non-diagnostic/i);
|
||||
assert.match(doc, /never diagnos/i);
|
||||
assert.match(doc, /provisional/i);
|
||||
assert.match(doc, /synthetic/i, 'fixtures must be synthetic, not real medical images');
|
||||
assert.match(doc, /no real (?:medical |stool )?images? (?:are |may )?(?:ever )?(?:added|used|shipped|included)/i,
|
||||
'the model must state plainly that no real medical images enter the repo');
|
||||
assert.match(doc, /deterministic (?:red-flag )?(?:escalation|urgent-symptom)/i,
|
||||
'the model must note that escalation is deterministic and never model-gated');
|
||||
assert.doesNotMatch(doc, /\b(?:bathroom photo|patient photo|clinical image|real stool image)\b/i,
|
||||
'the model must not promise or reference real medical imagery');
|
||||
assert.doesNotMatch(doc, /\b(?:clinically validated|clinically proven|medical device|FDA-cleared|CE-marked)\b/i,
|
||||
'the model must not overstate the clinical status of the vision path');
|
||||
});
|
||||
|
||||
test('the accepted product decision record links the photo data-flow threat model', async () => {
|
||||
const decision = await readFile(productDecisionPath, 'utf8');
|
||||
assert.match(decision, /\.\/PHOTO-DATAFLOW-THREAT-MODEL\.md/,
|
||||
'the governing decision record must point reviewers at the threat model');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { visionReadiness } from '../src/vision-readiness.js';
|
||||
|
||||
test('unsolved readiness means manual logging is always reachable in one action', () => {
|
||||
for (const status of [null, undefined, { enabled: false }, { enabled: true, providerReady: false }]) {
|
||||
const readiness = visionReadiness(status);
|
||||
assert.equal(readiness.ready, false, JSON.stringify(status));
|
||||
assert.equal(readiness.manualOnly, true, JSON.stringify(status));
|
||||
assert.equal(typeof readiness.manualLabel, 'string');
|
||||
assert.match(readiness.manualLabel, /manual|without AI/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('reports distinct phases so the UI never claims a worker is ready when it is not', () => {
|
||||
assert.equal(visionReadiness(null).phase, 'checking');
|
||||
assert.equal(visionReadiness({ enabled: false }).phase, 'disabled');
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: false }).phase, 'offline');
|
||||
assert.equal(visionReadiness({ enabled: true, providerReady: true }).phase, 'ready');
|
||||
});
|
||||
|
||||
test('a ready provider keeps AI analysis available and does not force the manual path', () => {
|
||||
const readiness = visionReadiness({ enabled: true, providerReady: true, model: 'SmolVLM2-2.2B-Instruct' });
|
||||
assert.equal(readiness.ready, true);
|
||||
assert.equal(readiness.manualOnly, false);
|
||||
assert.equal(readiness.manualLabel, '');
|
||||
});
|
||||
|
||||
test('readiness never promises a symptom or diagnosis capability', () => {
|
||||
for (const status of [null, { enabled: false }, { enabled: true, providerReady: false }, { enabled: true, providerReady: true }]) {
|
||||
const readiness = visionReadiness(status);
|
||||
const copy = `${readiness.manualLabel} ${readiness.message}`;
|
||||
assert.doesNotMatch(copy, /diagnos|bleeding|cancer|symptom/i);
|
||||
}
|
||||
});
|
||||