Compare commits

..
Author SHA1 Message Date
timmy 9118b69057 feat: graceful shutdown, disconnect-driven cancellation, and queue acceptance coverage
Quality gates / quality (pull_request) Successful in 1m58s
- server.mjs tracks in-flight chats: browser disconnects abort the
  queued turn (releasing its slot), and SIGTERM/SIGINT cancels every
  in-flight turn so no Hermes subprocess is orphaned.
- vision-service forwards a per-request AbortSignal combined with the
  provider deadline via AbortSignal.any.
- acceptance suites cover overload fallback, urgent bypass under
  saturation, disconnect cleanup, recovery, and shutdown orphan checks.
- zero-call invariant tests prove urgent text never reaches Hermes or
  the vision provider under load.
2026-08-22 23:01:20 +00:00
timmy 5dcaaae4d6 feat: queue agent turns with cancellation and orphan-free timeout kills
Chat turns now flow through the bounded inference queue: FIFO with
configurable depth (TIMMY_AGENT_MAX_QUEUE_DEPTH) and concurrency
(TIMMY_AGENT_MAX_CONCURRENT_TURNS), sanitized overload/timeout
fallbacks, browser-disconnect cancellation via AbortSignal, and child
process SIGTERM/SIGKILL teardown so no Hermes subprocess survives a
cancelled turn.
2026-08-22 22:04:17 +00:00
timmy 38a59c8ee9 feat: bounded inference queue with deterministic cancellation
Shared primitive for Timmy #17: concurrency slots, FIFO max-depth
overload rejection with sanitized manual-fallback copy, queue-deadline
expiry, per-request AbortSignal delivery, immediate slot release on
external cancel.
2026-08-22 21:39:55 +00:00
28 changed files with 1001 additions and 1306 deletions

No files matched your search

-1
View File
@@ -51,7 +51,6 @@ jobs:
npm run test:ui
npm run test:photo
npm run test:sleek
npm run test:portability
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
-1
View File
@@ -5,4 +5,3 @@ __pycache__/
*.pyc
.env
.env.*
.worktrees/
+6 -37
View File
@@ -1,4 +1,4 @@
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, estimateLedgerBytes, exportLedger, hasUrgentLedgerContext, importLedger, MAX_IMPORT_BYTES, mergeLedgers, migrateStoredLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage, utf8ByteLength } from './src/domain.js';
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
import { mergeVisualSuggestion } from './src/analysis.js';
const runtimeConfig = {
@@ -35,18 +35,8 @@ const draft = () => ({ bristolType: 4, color: 'brown', urgency: 0, discomfort: 0
let form = draft();
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function loadEntries() {
// Storage is validated and migrated BEFORE any render: malformed history
// (invalid dates, duplicate ids, junk rows) is repaired here so it can never
// blank the UI or crash boot. Healthy storage round-trips untouched.
const raw = localStorage.getItem(STORE);
const { entries: migrated, changed } = migrateStoredLedger(raw);
if (changed) persistLedger(migrated);
return migrated;
}
// Persistence is the single choke point for ledger writes so every caller
// gets identical quota handling.
function persistLedger(nextEntries) { localStorage.setItem(STORE, JSON.stringify(nextEntries)); }
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); }
function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value)); }
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
@@ -129,29 +119,8 @@ function privacy(){
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
}
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
async function importData(e){
try{
const file=e.target.files[0];if(!file)return;
if(file.size>MAX_IMPORT_BYTES)throw new RangeError('That file is too large to be a Timmy export.');
// Transactional import: parse and merge into a CANDIDATE ledger first.
// Nothing user-visible changes until the candidate is fully persisted, so
// a quota failure or any error rolls back memory and storage together.
const text=await file.text();
const result=mergeLedgers(entries,importLedger(text));
if(estimateLedgerBytes(result.merged)>MAX_IMPORT_BYTES||utf8ByteLength(JSON.stringify(entries))>MAX_IMPORT_BYTES){
throw new RangeError('That file is too large to fit in your portable local storage.');
}
persistLedger(result.merged);
entries=result.merged;
render();
toast(result.added.length?`Ledger imported: ${result.added.length} new log${result.added.length===1?'':'s'}`:`Already in your ledger: ${result.skippedIds.length} log${result.skippedIds.length===1?'':'s'} skipped (kept your saved version)`)
}catch(err){
// Roll back in-memory state to whatever storage actually holds.
try{entries=migrateStoredLedger(localStorage.getItem(STORE)).entries}catch{entries=entries}
toast(err&&err.name==='QuotaExceededError'?`Import failed: your browser storage is full. Export your ledger, delete some large photos, and try again.`:(err?.message||'Import failed.'))
}
}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);if(BASE_PATH==='/')localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
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()}
async function loadVisionStatus(){
@@ -204,7 +173,7 @@ function bindStep(step){
}
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)}}
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);persistLedger(entries)}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;persistLedger(entries);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,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 render(){({home,calendar,timmy,privacy}[view]||home)()}
render();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

+4 -3
View File
@@ -4,13 +4,14 @@
"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-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/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:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:portability": "node tests/ledger-portability.acceptance.mjs",
"test:queue-acceptance": "node tests/inference-queue.acceptance.mjs",
"test:shutdown-acceptance": "node tests/server-shutdown.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/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.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/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:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs"
},
+29 -1
View File
@@ -39,6 +39,34 @@ 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');
@@ -62,7 +90,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);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
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)}
}
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()}
+1 -1
View File
@@ -1,7 +1,7 @@
const ROOT = new URL(self.registration.scope).pathname;
const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`;
const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`;
const CACHE = `${CACHE_NAMESPACE}v7`;
const CACHE = `${CACHE_NAMESPACE}v5`;
const ASSETS = [
'',
'index.html',
+1 -1
View File
@@ -42,7 +42,7 @@ export function parseVisionResponse(raw) {
export function mergeVisualSuggestion(form, suggestion) {
if (suggestion?.status !== 'suggestion') return { ...form };
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color, provenance: { origin: 'ai-suggestion' } };
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color };
}
export function validatePhotoPayload(payload = {}) {
+15 -281
View File
@@ -1,6 +1,4 @@
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
const UTF8_ENCODER = new TextEncoder();
const KNOWN_PROVENANCE_ORIGINS = Object.freeze(new Set(['user', 'ai-suggestion']));
const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
const URGENT_TEXT_PATTERNS = Object.freeze([
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
@@ -55,128 +53,21 @@ export function buildTimmySummary(entries = []) {
return `${pieces.join(' · ')}. Patterns matter more than one entry. You choose what to eat; I only help you notice changes.`;
}
// Current-schema strictness: numeric fields must be true integers inside the
// clinical bounds (no silent clamping of out-of-range values), and photos must
// be canonical raster JPEG/PNG/WebP data URLs only.
// Canonical raster photo contract:
// - strict data URL grammar for exactly image/jpeg, image/png, image/webp;
// - base64 must be canonical (round-trip byte-exact, no whitespace, no
// base64url alphabet, no excess padding), so smuggled encodings fail;
// - decoded binary carries the declared format's real magic signature, so a
// mislabeled SVG/HTML/text payload can never pose as a raster photo;
// - decoded size stays within the app-wide 4 MiB photo ceiling (analysis.js)
// and above trivial-junk size, so noncanonical tiny blobs are rejected.
const MAX_PHOTO_DECODED_BYTES = 4 * 1024 * 1024;
const MIN_PHOTO_DECODED_BYTES = 32;
const PNG_MAGIC = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
const JPEG_MAGIC = [0xFF, 0xD8, 0xFF];
const PHOTO_DATA_URL = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/]+={0,2})$/;
const BASE64_CHAR = /[A-Za-z0-9+/]/;
// Linear-time canonical-base64 check: standard alphabet only, length a
// multiple of four, padding only ever as one trailing '=' or two '=='.
// A regex quantifier loop is avoided deliberately: multi-megabyte photo
// payloads must not depend on regexp engine internals.
function isCanonicalBase64(text) {
const { length } = text;
if (length === 0 || length % 4 !== 0) return false;
let padStart = length;
if (text[length - 1] === '=') {
padStart = text[length - 2] === '=' ? length - 2 : length - 1;
if (!BASE64_CHAR.test(text[padStart - 1])) return false;
}
for (let i = 0; i < padStart; i += 1) {
if (!BASE64_CHAR.test(text[i])) return false;
}
return true;
}
function asciiSignature(bytes, start, end) {
return String.fromCharCode(...bytes.subarray(start, end));
}
function canonicalRasterPhoto(value) {
if (typeof value !== 'string') return '';
const match = PHOTO_DATA_URL.exec(value);
if (!match) return '';
const [, mime, base64] = match;
if (!isCanonicalBase64(base64)) return '';
let bytes;
try {
// Decode once and verify canonicality arithmetically: standard alphabet
// plus exact trailing padding decodes to the same bytes every time, so a
// byte-exact re-encode is implied by the grammar checks above. This avoids
// btoa entirely (it throws on chars above U+00FF) and avoids multi-MB
// string churn on large photos.
const decoded = atob(base64);
bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) bytes[i] = decoded.charCodeAt(i);
} catch {
return '';
}
if (bytes.length < MIN_PHOTO_DECODED_BYTES || bytes.length > MAX_PHOTO_DECODED_BYTES) return '';
if (mime === 'image/jpeg') {
if (bytes.length < JPEG_MAGIC.length || !JPEG_MAGIC.every((byte, index) => bytes[index] === byte)) return '';
} else if (mime === 'image/png') {
if (bytes.length < PNG_MAGIC.length || !PNG_MAGIC.every((byte, index) => bytes[index] === byte)) return '';
} else if (asciiSignature(bytes, 0, 4) !== 'RIFF' || asciiSignature(bytes, 8, 12) !== 'WEBP') {
return '';
}
return value;
}
function toSchemaInteger(value, { min, max, fallback }) {
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) return fallback;
return value;
}
// Invalid, missing, or actively hostile dates must never throw and never
// persist Invalid Date values: anything that cannot be read safely becomes a
// fresh valid ISO timestamp instead.
export function safeIsoTimestamp(value) {
try {
if (value instanceof Date) {
const time = value.getTime();
if (Number.isFinite(time)) return new Date(time).toISOString();
} else if (typeof value === 'string' || typeof value === 'number') {
const parsed = new Date(value);
const time = parsed.getTime();
if (Number.isFinite(time)) return parsed.toISOString();
}
} catch { /* hostile toString/getTime/valueOf stays contained */ }
return new Date().toISOString();
}
function schemaEntryId(value) {
if (typeof value === 'string' && value.trim() !== '') return value;
return globalThis.crypto?.randomUUID
? `entry-${globalThis.crypto.randomUUID()}`
: `entry-${Date.now()}-${Math.random()}`;
}
export function sanitizeEntry(input = {}) {
const symptoms = {};
for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true;
const bristolType = toSchemaInteger(input.bristolType, { min: 1, max: 7, fallback: 4 });
const provenanceOrigin = input.provenance && typeof input.provenance === 'object'
&& typeof input.provenance.origin === 'string'
&& KNOWN_PROVENANCE_ORIGINS.has(input.provenance.origin)
? input.provenance.origin
: null;
const entry = {
id: schemaEntryId(input.id),
occurredAt: safeIsoTimestamp(input.occurredAt || Date.now()),
const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4));
return {
id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`),
occurredAt: new Date(input.occurredAt || Date.now()).toISOString(),
bristolType,
color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.color) ? input.color : 'brown',
urgency: toSchemaInteger(input.urgency, { min: 0, max: 4, fallback: 0 }),
discomfort: toSchemaInteger(input.discomfort, { min: 0, max: 4, fallback: 0 }),
note: typeof input.note === 'string' ? input.note.trim().slice(0, 500) : '',
photoDataUrl: canonicalRasterPhoto(input.photoDataUrl),
urgency: Math.min(4, Math.max(0, Number(input.urgency) || 0)),
discomfort: Math.min(4, Math.max(0, Number(input.discomfort) || 0)),
note: String(input.note || '').trim().slice(0, 500),
photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '',
symptoms,
};
if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin };
return entry;
}
export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) {
@@ -186,176 +77,19 @@ export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 }
return 'Ready for your review. Choose the matching Bristol form yourself; Timmy does not interpret the picture.';
}
// Total portability budget: storage, exports, and imports are all bounded by
// the same 16 MiB of UTF-8 bytes, measured on the exact document that would be
// written or read. Enforcing the TOTAL (not just the inbound file) keeps every
// export re-importable and prevents doomed writes from ever being attempted.
export function estimateLedgerBytes(entries) {
return utf8ByteLength(JSON.stringify(Array.isArray(entries) ? entries : []));
}
export function exportLedger(entries, exportedAt = new Date().toISOString()) {
const text = JSON.stringify({
product: PRODUCT_NAME,
schemaVersion: SCHEMA_VERSION,
return JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt,
entries: Array.isArray(entries) ? entries.map(sanitizeEntry) : [],
entries: Array.isArray(entries) ? entries : [],
}, null, 2);
if (utf8ByteLength(text) > MAX_IMPORT_BYTES) {
throw new RangeError('This ledger has grown past the portable size limit. Remove some large photos, then export again.');
}
return text;
}
export function utf8ByteLength(text) {
return UTF8_ENCODER.encode(text).length;
}
// Collision-safe deterministic merge with ID repair:
// - Every distinct existing (user-owned) record survives. Duplicate ids inside
// stored data are repaired, never dropped or silently merged: the first-seen
// row keeps its id and later twins get a deterministic `id#2`, `id#3`, …
// suffix, skipping suffixes already owned by real records.
// - Incoming rows are added only when their id is unused; intra-file duplicate
// ids keep exactly the first occurrence; every collision is reported back for
// explicit user feedback. Re-importing a file can therefore never duplicate,
// overwrite, or shadow user records.
// - Hostile id values (Symbol, BigInt, objects, missing) cannot throw: such
// records are repaired onto fresh deterministic string ids like "entry-3".
function repairLedgerIds(entries) {
const owned = new Set();
const repaired = [];
for (const entry of entries) {
let id = typeof entry?.id === 'string' ? entry.id : '';
if (!id || owned.has(id)) {
const base = id || 'entry';
let n = 2;
while (owned.has(`${base}#${n}`)) n += 1;
id = `${base}#${n}`;
}
owned.add(id);
repaired.push(entry);
if (repaired[repaired.length - 1] !== entry || entry.id !== undefined) entry.id = id;
}
return repaired;
}
export function mergeLedgers(existing, incoming) {
const local = repairLedgerIds(
Array.isArray(existing)
? existing.filter(entry => entry && typeof entry === 'object' && !Array.isArray(entry))
: [],
);
for (const entry of local) entry.id = String(entry.id);
const seen = new Set(local.map(entry => entry.id));
const merged = [...local];
const added = [];
const skippedIds = [];
const source = Array.isArray(incoming) ? incoming : [];
for (const rawEntry of source) {
const rawId = rawEntry && typeof rawEntry === 'object' && !Array.isArray(rawEntry)
? (typeof rawEntry.id === 'string' ? rawEntry.id : '')
: '';
const entry = sanitizeEntry(rawEntry || {});
if (!entry.id || seen.has(entry.id)) {
skippedIds.push(rawId || entry.id);
continue;
}
seen.add(entry.id);
merged.push(entry);
added.push(entry);
}
return { merged, added, skippedIds };
}
// Portability policy (explicit bound, symmetric by construction):
// - The app accepts photos up to 4 MiB of decoded binary (analysis.js
// MAX_IMAGE_BYTES); base64 encoding expands that to ~5.6 MiB and JSON adds a
// small envelope per entry, so any export this app can produce fits inside
// 16 MiB.
// - Imports are rejected past MAX_IMPORT_BYTES UTF-8 bytes before parsing user
// data. Every app-produced export therefore re-imports byte-symmetrically,
// while hostile or runaway files stay bounded.
export const MAX_IMPORT_BYTES = 16 * 1024 * 1024;
const PRODUCT_NAME = 'Timmy the Talking Turd';
const SCHEMA_VERSION = 1;
const KNOWN_SCHEMA_VERSIONS = new Set([0, SCHEMA_VERSION]);
// A bare top-level JSON array is accepted only when it matches the strict
// Timmy legacy ledger shape: at least one row, every row a plain object
// carrying a nonempty string id and an integer Bristol type (1–7). Arbitrary
// arrays of unrelated objects are rejected outright — import must never
// invent plausible medical defaults from JSON that was never a Timmy export.
function looksLikeLegacyTimmyArray(value) {
return Array.isArray(value)
&& value.length > 0
&& value.every(row => row !== null && typeof row === 'object' && !Array.isArray(row)
&& typeof row.id === 'string' && row.id.trim() !== ''
&& Number.isInteger(row.bristolType)
&& row.bristolType >= 1 && row.bristolType <= 7);
}
export function importLedger(text) {
if (typeof text !== 'string' || text.length === 0) throw new Error('This is not a supported Timmy export.');
if (utf8ByteLength(text) > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a Timmy export.');
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('This is not a supported Timmy export.');
}
if (Array.isArray(parsed)) {
if (!looksLikeLegacyTimmyArray(parsed)) throw new Error('This is not a supported Timmy export.');
return parsed.map(sanitizeEntry);
}
const hasLedgerEnvelope = typeof parsed?.product === 'string'
&& Number.isInteger(parsed?.schemaVersion)
&& Array.isArray(parsed?.entries);
if (!hasLedgerEnvelope || parsed.product !== PRODUCT_NAME) {
throw new Error('This is not a supported Timmy export.');
}
if (!KNOWN_SCHEMA_VERSIONS.has(parsed.schemaVersion)) {
throw new RangeError(`This export uses schema version ${parsed.schemaVersion} from a newer Timmy app. Update Timmy first, then import again.`);
}
const entries = parsed.entries.map(sanitizeEntry);
if (estimateLedgerBytes(entries) > MAX_IMPORT_BYTES) {
throw new RangeError('That file would expand past the portable size limit once migrated.');
}
return entries;
}
// Storage migration contract: whatever is in localStorage is validated and
// migrated BEFORE the UI renders, so malformed history can never blank the
// journal or crash boot.
// - Unparseable or wrong-shaped payloads collapse to an empty ledger instead
// of throwing; the caller learns via `changed` that a repairing rewrite is
// warranted.
// - Every distinct record survives: hostile fields are sanitized per entry,
// invalid dates become valid ISO timestamps, duplicate ids are repaired
// deterministically. Rows carrying neither an id nor a Bristol type are
// junk, never fabricated into records.
// - Healthy storage round-trips byte-for-byte and reports `changed: false`,
// so the app never rewrites what does not need repair.
export function migrateStoredLedger(raw) {
const empty = { entries: [], changed: false };
if (typeof raw !== 'string' || raw.trim() === '') return empty;
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return { entries: [], changed: true };
}
if (!Array.isArray(parsed)) return { entries: [], changed: true };
const rows = parsed.filter(row => row !== null && typeof row === 'object' && !Array.isArray(row)
&& ((typeof row.id === 'string' && row.id.trim() !== '') || Number.isInteger(row.bristolType)));
const entries = repairLedgerIds(rows.map(row => sanitizeEntry(row || {})));
let changed;
try {
changed = rows.length !== parsed.length || JSON.stringify(entries) !== JSON.stringify(rows);
} catch {
changed = true; // hostile stored structures always warrant a repairing rewrite
}
return { entries, changed };
const parsed = JSON.parse(text);
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.');
return parsed.entries.map(sanitizeEntry);
}
export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);
+66 -15
View File
@@ -2,6 +2,7 @@ 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;
@@ -44,6 +45,8 @@ 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,
@@ -86,12 +89,30 @@ export function parseHermesCliOutput(output) {
return { sessionId: marker[1], reply };
}
function execFilePromise(command, args, options) {
function execFilePromise(command, args, options, signal) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
// 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;
}
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 });
});
}
@@ -103,7 +124,8 @@ export function buildHermesEnvironment(source = process.env) {
return env;
}
export async function runHermesCliTurn({ prompt, hermesSessionId, config, execImpl = execFilePromise }) {
export async function runHermesCliTurn({ prompt, hermesSessionId, config, signal, execImpl = execFilePromise }) {
if (signal?.aborted) throw normalizeAbortReason(signal.reason);
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, {
@@ -112,10 +134,15 @@ export async function runHermesCliTurn({ prompt, hermesSessionId, config, execIm
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 => ({
@@ -139,6 +166,7 @@ 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 = [];
@@ -182,7 +210,7 @@ export function createHermesAgentService({
return { cookieToken, public: config.publicStatus(true) };
},
async chat({ origin, cookieToken, payload }) {
async chat({ origin, cookieToken, payload, signal }) {
requireOrigin(origin);
const session = lookup(cookieToken);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new AgentGatewayError(400, 'Invalid chat request.');
@@ -195,25 +223,48 @@ 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 || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.');
if (session.requests.length >= config.maxRequestsPerMinute) throw new AgentGatewayError(429, 'Too many messages in a row. Try again shortly.');
session.requests.push(now());
session.busy = true;
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 });
try {
const result = await runTurn({
prompt: buildPrompt(message, ledger, !session.hermesSessionId),
hermesSessionId: session.hermesSessionId,
config,
});
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 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');
session.hermesSessionId = result.sessionId;
if (!session.hermesSessionId) 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.');
}
+130
View File
@@ -0,0 +1,130 @@
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 };
}
+7 -3
View File
@@ -7,18 +7,22 @@ function providerEndpoint(baseUrl) {
return `${url.toString().replace(/\/$/, '')}/chat/completions`;
}
export async function analyzePhoto({ payload, fetchImpl = fetch, config }) {
export async function analyzePhoto({ payload, fetchImpl = fetch, config, signal }) {
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 ${config.apiKey || 'local-proxy'}`,
authorization: 'Bearer ' + String(config.apiKey || 'local-proxy'),
},
body: JSON.stringify(buildVisionRequest({ imageDataUrl: photo.imageDataUrl, model: config.model })),
signal: AbortSignal.timeout(config.requestTimeoutMs || 60_000),
signal: combinedSignal,
}).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;
+92
View File
@@ -0,0 +1,92 @@
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');
});
+109
View File
@@ -0,0 +1,109 @@
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/);
});
+1 -11
View File
@@ -57,17 +57,7 @@ test('rejects malformed model output instead of guessing defaults', () => {
test('merges only visual fields and preserves user-reported context', () => {
const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } };
const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 });
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true }, provenance: { origin: 'ai-suggestion' } });
});
test('records ai-suggestion provenance only when a suggestion is actually applied', () => {
const form = { bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: '', symptoms: {} };
const abstained = mergeVisualSuggestion(form, { status: 'needs_user_input', reason: 'too uncertain' });
assert.deepEqual(abstained, form);
assert.equal(abstained.provenance, undefined);
const suggested = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 5, color: 'yellow', confidence: 0.9 });
assert.deepEqual(suggested.provenance, { origin: 'ai-suggestion' });
assert.equal(suggested.urgency, form.urgency, 'nonvisual fields stay user-owned');
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } });
});
test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => {
-606
View File
@@ -8,12 +8,6 @@ import {
detectUrgentText,
exportLedger,
hasUrgentLedgerContext,
importLedger,
estimateLedgerBytes,
MAX_IMPORT_BYTES,
mergeLedgers,
migrateStoredLedger,
utf8ByteLength,
photoQualityMessage,
sanitizeEntry,
} from '../src/domain.js';
@@ -124,40 +118,6 @@ test('sanitizes a user entry to the MVP data contract', () => {
assert.equal(entry.unexpected, undefined);
});
test('keeps only a bounded provenance origin and strips smuggled secrets', () => {
const entry = sanitizeEntry({
id: 'prov-1',
bristolType: 4,
provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' },
});
assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' });
assert.doesNotMatch(JSON.stringify(entry), /secret|session/i);
});
test('omits provenance entirely when none was recorded', () => {
const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 });
assert.equal(entry.provenance, undefined);
});
test('rejects provenance origins outside the recorded vocabulary', () => {
for (const bogus of ['clinician', 'self-diagnosis', '']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } });
assert.equal(entry.provenance, undefined, bogus);
}
});
test('provenance membership is own-property safe: inherited Object names are not origins', () => {
for (const poisoned of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf', 'isPrototypeOf']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: poisoned } });
assert.equal(entry.provenance, undefined, poisoned);
assert.doesNotMatch(JSON.stringify(entry), new RegExp(poisoned), poisoned);
}
// Even a null-prototype provenance carrying a real origin stays acceptable.
const nullProto = sanitizeEntry({ id: 'y', bristolType: 4, provenance: Object.assign(Object.create(null), { origin: 'user' }) });
assert.deepEqual(nullProto.provenance, { origin: 'user' });
});
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i);
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i);
@@ -172,569 +132,3 @@ test('export ledger is portable JSON with version and entries', () => {
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
assert.equal(parsed.entries.length, 1);
});
test('import migrates the legacy bare-array ledger to the current versioned envelope', () => {
const legacy = JSON.stringify([
{ id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' },
{ id: 'legacy-2', bristolType: 7 },
]);
const entries = importLedger(legacy);
assert.equal(entries.length, 2);
assert.equal(entries[0].id, 'legacy-1');
assert.equal(entries[0].bristolType, 2);
assert.equal(entries[0].note, 'older export');
});
test('import accepts every prior schema version and migrates entries forward', () => {
for (const version of [0, 1]) {
const payload = version === 0
? [{ id: `v${version}`, bristolType: 3 }]
: { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] };
const entries = importLedger(JSON.stringify(payload));
assert.equal(entries.length, 1, `schemaVersion ${version}`);
assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`);
}
});
test('import fails safely on a newer schema version instead of guessing', () => {
for (const schemaVersion of [2, 99]) {
assert.throws(
() => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })),
error => error instanceof RangeError && /newer Timmy app/i.test(error.message),
`schemaVersion ${schemaVersion}`,
);
}
});
test('import fails safely on malformed or wrong-shaped payloads', () => {
for (const payload of [
'not json',
'{"schemaVersion":1,"entries":{}}',
'{"entries":[]}',
'{"product":"Other App","schemaVersion":1,"entries":[]}',
null,
42,
]) {
assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40));
}
});
test('import rejects oversized ledgers before parsing user data', () => {
const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] });
assert.ok(huge.length > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('utf8ByteLength measures UTF-8 bytes, not UTF-16 code units', () => {
assert.equal(utf8ByteLength(''), 0);
assert.equal(utf8ByteLength('abc'), 3);
// é is 1 UTF-16 unit but 2 UTF-8 bytes; 💩 is 2 UTF-16 units but 4 UTF-8 bytes.
assert.equal(utf8ByteLength('é'), 2);
assert.equal(utf8ByteLength('💩'), 4);
assert.equal(utf8ByteLength('aé💩b'), 1 + 2 + 4 + 1);
const emojiBlob = '💩'.repeat(1000);
assert.equal(emojiBlob.length, 2000, 'sanity: two code units each');
assert.equal(utf8ByteLength(emojiBlob), 4000);
});
test('import rejects oversized payloads by UTF-8 bytes regardless of composition', () => {
const asciiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES) + '"}]}';
assert.ok(utf8ByteLength(asciiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(asciiOver), RangeError);
// 4 bytes per glyph: byte size crosses the cap at half the code-unit count.
const emojiOver = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + '💩'.repeat(Math.ceil(MAX_IMPORT_BYTES / 2)) + '"}]}';
assert.ok(emojiOver.length <= MAX_IMPORT_BYTES * 1.01, 'code-unit count must not be what trips this');
assert.ok(utf8ByteLength(emojiOver) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(emojiOver), RangeError);
});
test('import accepts a dense multibyte payload just under the byte cap', () => {
// Many small multibyte entries packed deterministically so that both the raw
// file and the fully migrated ledger stay inside the total portability
// budget (migration expands each row to the strict current schema).
const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[';
const makeEntry = i => ({ id: `m${i}`, occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕', photoDataUrl: '', symptoms: {} });
const sampleMigrated = JSON.stringify(sanitizeEntry(makeEntry(0)));
const perRowRaw = utf8ByteLength(JSON.stringify(makeEntry(0))) + 1; // + comma
const perRowMigrated = utf8ByteLength(sampleMigrated) + 1;
// Fixed-width ids keep every row the same size, so packing is exact. The
// fill factor keeps BOTH documents inside the budget: the migrated ledger
// near the cap, the raw file above half of it.
const budget = Math.floor((MAX_IMPORT_BYTES * 0.9 - utf8ByteLength(head) - 2) / perRowMigrated);
const count = Math.max(1, budget);
const payload = `${head}${Array.from({ length: count }, (_, i) => JSON.stringify({ ...makeEntry(i), id: `m${String(i).padStart(8, '0')}` })).join(',')}]}`;
assert.ok(utf8ByteLength(payload) <= MAX_IMPORT_BYTES);
assert.ok(utf8ByteLength(payload) > MAX_IMPORT_BYTES * 0.5, 'payload must carry real multibyte mass');
const imported = importLedger(payload);
assert.equal(imported.length, count, 'every packed entry survives');
assert.ok(estimateLedgerBytes(imported) <= MAX_IMPORT_BYTES, 'migrated total must respect the portability budget');
assert.equal(imported[imported.length - 1].note, 'café ☕');
});
test('a maximal app-produced export with a 4 MiB photo round trips byte-symmetrically', () => {
// 4 MiB binary is the app-wide photo ceiling (analysis.js MAX_IMAGE_BYTES).
const photoBytes = 4 * 1024 * 1024;
let b64 = Buffer.from('a'.repeat(photoBytes)).toString('base64');
const entry = sanitizeEntry({
id: 'big-photo',
bristolType: 4,
photoDataUrl: `data:image/jpeg;base64,${b64}`,
note: 'boundary photo',
});
const exported = exportLedger([entry], '2026-08-22T00:00:00.000Z');
assert.ok(utf8ByteLength(exported) <= MAX_IMPORT_BYTES, 'largest producible export must stay inside the import cap');
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 1);
assert.equal(roundTripped[0].photoDataUrl, entry.photoDataUrl, 'photo survives the round trip without silent loss');
});
test('import rejects payloads past the explicit portability ceiling before parsing', () => {
const huge = '{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"' + 'n'.repeat(MAX_IMPORT_BYTES + 1024) + '"}]}';
assert.ok(utf8ByteLength(huge) > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('merge keeps every distinct record and never duplicates or overwrites user-owned entries', () => {
const local = [
{ id: 'a', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, note: 'local version of shared id' },
{ id: 'b', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'local b', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([
{ id: 'b', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, color: 'black', urgency: 4, note: 'hostile rewrite of existing id' },
{ id: 'c', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 6, note: 'new from file' },
]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 3, 'one row per unique id');
assert.equal(result.merged.filter(entry => entry.id === 'b').length, 1, 'no duplicate ids');
assert.equal(result.merged.find(entry => entry.id === 'b').note, 'local b', 'existing user-owned entry is never overwritten');
assert.deepEqual(result.added.map(entry => entry.id), ['c'], 'only genuinely new records are added');
assert.deepEqual(result.skippedIds, ['b'], 'collisions are reported explicitly');
// Order stays deterministic: local rows first in their stored order, then additions in incoming order.
assert.deepEqual(result.merged.map(entry => entry.id), ['a', 'b', 'c']);
});
test('re-importing the same file twice changes nothing (idempotent)', () => {
const base = [{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }];
const file = importLedger(JSON.stringify([{ id: 'seed', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2 }, { id: 'extra', occurredAt: '2026-08-22T08:30:00.000Z', bristolType: 3 }]));
const first = mergeLedgers(base, file);
assert.deepEqual(first.added.map(entry => entry.id), ['extra']);
const second = mergeLedgers(first.merged, file);
assert.equal(second.merged.length, first.merged.length, 'second import adds nothing');
assert.deepEqual(second.added, [], 'second import reports no additions');
assert.deepEqual(second.skippedIds.sort(), ['extra', 'seed'], 'both already-present ids are reported as skipped');
});
test('duplicate ids inside stored data are repaired deterministically, never dropped or silently merged', () => {
// Two genuinely different local records that share one id (legacy storage
// corruption or a double-save bug) must BOTH survive with distinct stable ids.
const local = [
{ id: 'twins', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first twin', photoDataUrl: '', symptoms: {} },
{ id: 'twins', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second twin', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([{ id: 'fresh', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4 }]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 3, 'every distinct record survives a duplicate-id collision');
const ids = result.merged.map(entry => entry.id);
assert.equal(new Set(ids).size, 3, 'no duplicate ids remain after repair');
assert.equal(result.merged[0].id, 'twins', 'first-seen record keeps its original id');
const secondTwin = result.merged.find(entry => entry.note === 'second twin');
assert.ok(secondTwin, 'second twin still present');
assert.equal(secondTwin.id, 'twins#2', 'derived id is deterministic, not random');
assert.equal(secondTwin.bristolType, 6, 'repaired record keeps its own data');
assert.deepEqual(result.added.map(entry => entry.id), ['fresh'], 'import still reports additions normally');
// Re-merging the repaired ledger is idempotent: nothing changes, nothing new.
const second = mergeLedgers(result.merged, incoming);
assert.equal(second.merged.length, 3, 'repaired ledger re-merges without growth');
assert.deepEqual(second.added, []);
});
test('repair ids never collide with real records: chained suffixes are skipped', () => {
const local = [
{ id: 'twins', bristolType: 2, note: 'a' },
{ id: 'twins#2', bristolType: 3, note: 'real record that owns the derived slot' },
{ id: 'twins', bristolType: 4, note: 'b' },
];
const result = mergeLedgers(local, []);
assert.equal(result.merged.length, 3, 'all three records survive');
const ids = result.merged.map(entry => entry.id);
assert.equal(new Set(ids).size, 3, 'derived id must not steal the real record\'s id');
assert.equal(result.merged.find(entry => entry.note === 'real record that owns the derived slot').id, 'twins#2');
assert.equal(result.merged.find(entry => entry.note === 'b').id, 'twins#3', 'duplicate scans past every owned suffix');
});
test('duplicate ids inside one imported file keep exactly the first occurrence', () => {
const incoming = importLedger(JSON.stringify([
{ id: 'dupe', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'first in file' },
{ id: 'dupe', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile later rewrite' },
]));
const result = mergeLedgers([], incoming);
assert.equal(result.merged.length, 1, 'one row per id, first occurrence wins deterministically');
assert.equal(result.merged[0].note, 'first in file');
assert.deepEqual(result.skippedIds, ['dupe'], 'later duplicates are reported, not silently dropped');
});
test('local duplicate ids still win over an incoming file that reuses their id', () => {
const local = [
{ id: 'twin', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'local twin one', photoDataUrl: '', symptoms: {} },
{ id: 'twin', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 3, note: 'local twin two', photoDataUrl: '', symptoms: {} },
];
const incoming = importLedger(JSON.stringify([
{ id: 'twin', occurredAt: '2026-01-01T00:00:00.000Z', bristolType: 7, note: 'hostile import' },
]));
const result = mergeLedgers(local, incoming);
assert.equal(result.merged.length, 2, 'both local records survive; hostile import adds nothing');
assert.ok(result.merged.every(entry => entry.note.startsWith('local twin')), 'import never shadows user-owned records');
assert.deepEqual(result.added, []);
assert.deepEqual(result.skippedIds, ['twin']);
});
test('merge tolerates hostile id types without throwing', () => {
const hostile = [
{ id: Symbol('sym'), bristolType: 2, note: 'symbol id' },
{ id: 123n, bristolType: 3, note: 'bigint id' },
{ bristolType: 4, note: 'no id at all' },
];
let result;
assert.doesNotThrow(() => { result = mergeLedgers(hostile, []); });
assert.equal(result.merged.length, 3, 'records with unusable ids are repaired, not dropped');
const ids = result.merged.map(entry => entry.id);
assert.equal(new Set(ids).size, 3);
assert.ok(ids.every(id => typeof id === 'string' && id !== ''), 'every repaired id is a nonempty string');
});
test('bare top-level arrays are accepted only as a strict Timmy legacy ledger', () => {
const legacy = [
{ id: 'l1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, note: '' },
{ id: 'l2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, color: 'green' },
];
assert.equal(importLedger(JSON.stringify(legacy)).length, 2, 'genuine legacy exports stay importable');
});
test('ambiguous arrays of unrelated objects are rejected, never defaulted into medical records', () => {
for (const payload of [
[{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }],
[{ name: 'Alice', role: 'admin' }, { name: 'Bob', role: 'user' }],
[{ sku: 'X1', quantity: 3 }],
[],
[42],
['2026-08-22T09:00:00.000Z'],
[null],
]) {
let entries;
assert.throws(
() => { entries = importLedger(JSON.stringify(payload)); },
/not a supported Timmy export/,
`must reject: ${JSON.stringify(payload).slice(0, 60)}`,
);
assert.equal(entries, undefined, 'no invented defaults may leak from rejected payloads');
}
});
test('legacy array rows need a nonempty string id and a Bristol type; one bad row rejects the batch', () => {
assert.throws(() => importLedger(JSON.stringify([{ id: 'x' }])), /not a supported Timmy export/, 'missing bristolType');
assert.throws(() => importLedger(JSON.stringify([{ bristolType: 4 }])), /not a supported Timmy export/, 'missing id');
assert.throws(() => importLedger(JSON.stringify([{ id: '', bristolType: 4 }])), /not a supported Timmy export/, 'empty id');
const mixed = JSON.stringify([{ id: 'ok-row', bristolType: 3 }, { foo: 1 }]);
assert.throws(() => importLedger(mixed), /not a supported Timmy export/, 'partial acceptance would invent data');
});
test('photos validate canonically: strict grammar, base64 round-trip, decoded size floor, and magic bytes', () => {
const jpegBytes = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]), Buffer.alloc(600, 0x33)]);
const pngBytes = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(600, 0x44)]);
const webpBytes = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x60, 0x02, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(500, 0x55)]);
const url = bytes => `base64:${bytes.toString('base64')}`;
const okJpeg = `data:image/jpeg;base64,${jpegBytes.toString('base64')}`;
const okPng = `data:image/png;base64,${pngBytes.toString('base64')}`;
const okWebp = `data:image/webp;base64,${webpBytes.toString('base64')}`;
assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg);
assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng);
assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp);
// Mislabeled content is rejected even with perfect base64 grammar: the
// decoded bytes must carry the declared format's real magic signature.
const svgPayload = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
const svgAsJpeg = `data:image/jpeg;base64,${svgPayload.toString('base64')}`;
const htmlAsPng = `data:image/png;base64,${Buffer.from('<html><body>hi</body></html>').toString('base64')}`;
const pngBytesAsJpeg = `data:image/jpeg;base64,${pngBytes.toString('base64')}`;
for (const bad of [
svgAsJpeg,
htmlAsPng,
pngBytesAsJpeg,
'data:image/svg+xml;base64,PHN2Zy8+',
'data:image/gif;base64,R0lGODlh',
// Grammar violations: whitespace/newlines, excess padding, base64url alphabet.
`data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/(.{20})/, '$1\n')}`,
'data:image/jpeg;base64,aGVsbG8===',
`data:image/jpeg;base64,${jpegBytes.toString('base64').replace(/A/g, '_')}`,
'data:image/jpeg;base64,%2Dencoded',
// Noncanonical tiny junk: grammatically valid but far below any real image.
`data:image/jpeg;base64,${Buffer.from('ok').toString('base64')}`,
`data:image/png;base64,${Buffer.alloc(31, 0x89).toString('base64')}`,
'http://example.com/photo.jpg',
42,
undefined,
]) {
const label = typeof bad === 'string' ? bad.slice(0, 44) : String((bad && bad.constructor && bad.constructor.name) || 'value');
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${label}`);
}
// Decoded-binary ceiling mirrors the app-wide 4 MiB photo limit.
const overDecoded = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 3, 0x77)]).toString('base64')}`;
assert.ok(Buffer.from(overDecoded.slice(23), 'base64').length > 4 * 1024 * 1024);
assert.equal(sanitizeEntry({ id: 'huge', photoDataUrl: overDecoded }).photoDataUrl, '', 'decoded payload past the 4 MiB app ceiling is rejected');
});
test('sanitizeEntry absorbs Symbol, BigInt, and hostile date values without throwing', () => {
const hostile = {
id: Symbol('sym'),
occurredAt: { toString() { throw new Error('date toString boom'); } },
bristolType: 4n,
color: Symbol('green'),
urgency: BigInt(3),
discomfort: { valueOf() { throw new Error('valueOf boom'); } },
note: Object.create(Object.prototype, { toString: { value() { throw new Error('note boom'); } } }),
photoDataUrl: Symbol('photo'),
symptoms: null,
provenance: 'user',
extraSymbolKey: Symbol('ignored'),
};
hostile[Symbol('poison')] = 'never';
let entry;
assert.doesNotThrow(() => { entry = sanitizeEntry(hostile); });
assert.equal(typeof entry.id, 'string');
assert.match(entry.id, /^entry-/);
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, 'hostile date becomes a safe ISO timestamp');
assert.equal(entry.bristolType, 4, 'BigInt Bristol type falls back to neutral default');
assert.equal(entry.color, 'brown', 'Symbol color falls back to the default');
assert.equal(entry.urgency, 0, 'BigInt urgency is not a schema integer');
assert.equal(entry.discomfort, 0);
assert.equal(entry.note, '', 'non-coercible notes become empty instead of crashing');
assert.equal(entry.photoDataUrl, '');
assert.deepEqual(entry.symptoms, { blood: false, blackOrDarkRed: false, severePain: false, vomiting: false, fever: false, cannotPassGas: false });
assert.doesNotThrow(() => JSON.stringify(entry), 'result must stay serializable');
const poisonedDate = new Date('2026-08-01T00:00:00.000Z');
Object.defineProperty(poisonedDate, 'getTime', { value() { throw new Error('getTime boom'); } });
let survived;
assert.doesNotThrow(() => { survived = sanitizeEntry({ id: 'pd', occurredAt: poisonedDate }); });
assert.equal(Number.isNaN(new Date(survived.occurredAt).getTime()), false);
let symbolDate;
assert.doesNotThrow(() => { symbolDate = sanitizeEntry({ id: 'sd', occurredAt: Symbol('nope') }); });
assert.match(symbolDate.occurredAt, /^\d{4}-\d{2}-\d{2}T/);
});
test('stored localStorage payloads are validated and migrated before any render', () => {
// Malformed real-world storage: an invalid date that would blank the UI,
// a duplicate id, and one garbage row that is not an entry at all.
const stored = JSON.stringify([
{ id: 'keep-1', occurredAt: 'not-a-real-date', bristolType: 3, color: 'brown', urgency: 2, discomfort: 1, note: 'broken date', photoDataUrl: '', symptoms: {} },
{ id: 'keep-2', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'healthy row', photoDataUrl: '', symptoms: {} },
{ id: 'keep-2', occurredAt: '2026-08-22T10:15:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'twin row', photoDataUrl: '', symptoms: {} },
{ completely: 'not an entry' },
'a bare string row',
17,
]);
let migrated;
assert.doesNotThrow(() => { migrated = migrateStoredLedger(stored); });
assert.equal(migrated.entries.length, 3, 'every distinct record survives; junk rows are dropped, never fabricated');
const ids = migrated.entries.map(entry => entry.id);
assert.equal(new Set(ids).size, 3, 'duplicate stored ids are repaired');
assert.equal(Number.isNaN(new Date(migrated.entries.find(entry => entry.note === 'broken date').occurredAt).getTime()), false,
'invalid dates become valid ISO timestamps so the UI can never blank out');
assert.equal(migrated.entries.find(entry => entry.note === 'twin row').id, 'keep-2#2');
assert.equal(typeof migrated.changed, 'boolean');
});
test('valid stored ledgers pass through migration unchanged', () => {
const healthy = [
sanitizeEntry({ id: 'h1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 }),
sanitizeEntry({ id: 'h2', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 2, provenance: { origin: 'user' } }),
];
const result = migrateStoredLedger(JSON.stringify(healthy));
assert.deepEqual(result.entries, healthy, 'healthy storage is byte-for-byte stable through migration');
assert.equal(result.changed, false, 'no rewrite is flagged when nothing needed repair');
});
test('corrupt or wrong-shaped stored values migrate to an empty ledger instead of crashing boot', () => {
for (const corrupt of ['', ' ', '{not json', 'null', '"just a string"', '{"entries":[]}', '[]', 'undefined']) {
let result;
assert.doesNotThrow(() => { result = migrateStoredLedger(corrupt); }, `corrupt value: ${corrupt.slice(0, 24)}`);
assert.deepEqual(result.entries, [], `empty ledger for corrupt value: ${corrupt.slice(0, 24)}`);
if (result.changed !== undefined) assert.equal(typeof result.changed, 'boolean');
}
});
test('hostile stored values cannot crash the migration pass', () => {
const raw = JSON.stringify([{ id: 'x', occurredAt: '2026-08-01T09:00:00.000Z', bristolType: 4 }, { id: 'y', occurredAt: { evil: true }, bristolType: { deep: [1, 2] } }]);
let result;
assert.doesNotThrow(() => { result = migrateStoredLedger(raw); });
assert.equal(result.entries.length, 2);
assert.equal(Number.isNaN(new Date(result.entries[0].occurredAt).getTime()), false);
// Circular structures and hostile toJSON must also stay contained.
const circular = {};
circular.self = circular;
assert.doesNotThrow(() => migrateStoredLedger(circular));
const hostileToJson = [{ toJSON() { throw new Error('toJSON boom'); } }];
assert.doesNotThrow(() => migrateStoredLedger(hostileToJson));
});
test('ledger size is measured as the exact UTF-8 bytes storage will hold', () => {
const entries = [sanitizeEntry({ id: 'e1', bristolType: 4, note: 'café ☕' })];
assert.equal(estimateLedgerBytes(entries), utf8ByteLength(JSON.stringify(entries)));
assert.equal(estimateLedgerBytes([]), utf8ByteLength('[]'));
const withPhoto = [sanitizeEntry({ id: 'e2', bristolType: 2, photoDataUrl: `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(2048, 0x44)]).toString('base64')}` })];
assert.equal(estimateLedgerBytes(withPhoto), utf8ByteLength(JSON.stringify(withPhoto)));
});
test('exports past the total portability budget are refused instead of producing non-importable files', () => {
const bigPhoto = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(4 * 1024 * 1024 - 4, 0x66)]).toString('base64')}`;
const entries = [1, 2, 3, 4].map(n => sanitizeEntry({ id: `big-${n}`, bristolType: 4, photoDataUrl: bigPhoto }));
let text;
assert.throws(() => { text = exportLedger(entries, '2026-08-22T00:00:00.000Z'); }, RangeError,
'an export that could never re-import must not be produced');
assert.equal(text, undefined, 'no oversized document may leak from a refused export');
// A single maximal photo entry still exports fine.
assert.doesNotThrow(() => exportLedger([entries[0]], '2026-08-22T00:00:00.000Z'));
});
test('imports whose migrated total would exceed the portability budget are rejected before the caller can mutate', () => {
// Raw bytes stay just under the cap; the migrated ledger (every row expanded
// to the full current schema with explicit symptom fields) crosses it, so
// import must refuse up front instead of letting a doomed write happen.
const head = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[';
const makeRawEntry = i => `{"id":"x${String(i).padStart(7, '0')}","bristolType":4,"occurredAt":"x"}`;
// Solve the row count directly with uniform-width ids: raw bytes sit just
// under the cap while the migrated expansion crosses it.
const rowLen = utf8ByteLength(makeRawEntry(0));
const available = MAX_IMPORT_BYTES - 4096 - utf8ByteLength(head) - 3; // "],}" tail
const count = Math.max(1, Math.floor((available + 1) / (rowLen + 1))); // + comma per row
const raw = `${head}${Array.from({ length: count }, (_, i) => makeRawEntry(i)).join(',')}]}`
+ '';
assert.ok(utf8ByteLength(raw) <= MAX_IMPORT_BYTES, 'fixture raw bytes must stay inside the cap');
assert.ok(utf8ByteLength(raw) > MAX_IMPORT_BYTES * 0.9, 'fixture must sit close to the raw boundary');
let entries;
assert.throws(() => { entries = importLedger(raw); }, error => error instanceof RangeError && /expand past/.test(error.message),
'migrated total past the budget must refuse before returning entries');
assert.equal(entries, undefined, 'no entries may leak from a refused import');
});
test('merge sanitizes incoming entries so imports cannot smuggle hostile fields into storage', () => {
const incoming = [{ id: 'proto-entry', bristolType: 4, __proto__: { poisoned: true }, extra: 'strip me' }, { id: 'ctor-entry', bristolType: 4, sessionCookie: 'SID=x' }];
const result = mergeLedgers([], incoming);
assert.equal(result.merged.length, 2, 'both records still import as data');
for (const entry of result.merged) {
assert.equal(Object.getPrototypeOf(entry), Object.prototype, `plain-object entry ${entry.id}`);
assert.equal(entry.poisoned, undefined, 'prototype payload must not leak');
assert.equal(entry.extra, undefined, 'unknown fields stay out of storage');
assert.equal(entry.sessionCookie, undefined, 'smuggled secrets stay out of storage');
}
const serialized = JSON.stringify(result.merged);
assert.doesNotMatch(serialized, /poisoned|extra|sessionCookie|SID=/);
});
test('current-schema numeric fields are strict integers within clinical bounds', () => {
for (const bogus of [4.5, '3', 0, 8, NaN, null, true, [3]]) {
const entry = sanitizeEntry({ id: 'n', bristolType: bogus });
assert.equal(entry.bristolType, 4, `bristolType ${JSON.stringify(String(bogus))} falls back to the neutral default`);
}
assert.equal(sanitizeEntry({ id: 'ok1', bristolType: 1 }).bristolType, 1);
assert.equal(sanitizeEntry({ id: 'ok2', bristolType: 7 }).bristolType, 7);
const mixed = sanitizeEntry({ id: 'm', urgency: 2.5, discomfort: -1 });
assert.equal(mixed.urgency, 0);
assert.equal(mixed.discomfort, 0);
const strings = sanitizeEntry({ id: 's', urgency: '3', discomfort: 11 });
assert.equal(strings.urgency, 0, 'numeric strings are not schema integers');
assert.equal(strings.discomfort, 0);
const valid = sanitizeEntry({ id: 'v', urgency: 3, discomfort: 4 });
assert.equal(valid.urgency, 3);
assert.equal(valid.discomfort, 4);
});
test('photo fields accept only approved raster JPEG/PNG/WebP base64 data URLs', () => {
// Canonical, magic-valid samples of every supported format (large enough to
// satisfy the canonical photo contract's decoded-size floor).
const okJpeg = `data:image/jpeg;base64,${Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF]), Buffer.alloc(120, 0x11)]).toString('base64')}`;
const okPng = `data:image/png;base64,${Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), Buffer.alloc(120, 0x22)]).toString('base64')}`;
const okWebp = `data:image/webp;base64,${Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x70, 0x00, 0x00, 0x00]), Buffer.from('WEBPVP8 '), Buffer.alloc(110, 0x33)]).toString('base64')}`;
assert.equal(sanitizeEntry({ id: 'p1', photoDataUrl: okJpeg }).photoDataUrl, okJpeg);
assert.equal(sanitizeEntry({ id: 'p2', photoDataUrl: okPng }).photoDataUrl, okPng);
assert.equal(sanitizeEntry({ id: 'p3', photoDataUrl: okWebp }).photoDataUrl, okWebp);
for (const bad of [
'data:image/svg+xml;base64,PHN2Zy8+',
'data:image/svg+xml,<svg onload="alert(1)">',
'data:image/gif;base64,R0lGODlh',
'data:image/jpeg;base64,!!!not-base64!!!',
'data:image/jpeg,percent%2Dencoded',
'data:text/html;base64,PGh0bWw+',
'http://example.com/photo.jpg',
42,
// Grammar-valid but non-raster or undersized payloads are dropped too:
// the canonical contract rejects tiny junk that merely parses as base64.
'data:image/jpeg;base64,b2s=',
`data:image/jpeg;base64,${Buffer.from('plain text, not an image').toString('base64')}`,
]) {
assert.equal(sanitizeEntry({ id: 'bad', photoDataUrl: bad }).photoDataUrl, '', `rejected: ${String(bad).slice(0, 40)}`);
}
});
test('invalid or missing dates never throw and never persist Invalid Date values', () => {
for (const bad of ['not-a-date', '2026-13-45T99:99:99Z', {}, ['2026-01-01'], true]) {
let entry;
assert.doesNotThrow(() => { entry = sanitizeEntry({ id: 'd', occurredAt: bad }); }, String(bad));
assert.match(entry.occurredAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
assert.equal(Number.isNaN(new Date(entry.occurredAt).getTime()), false, `safe ISO for ${String(bad)}`);
}
const blank = sanitizeEntry({ id: 'd2', occurredAt: '' });
assert.equal(Number.isNaN(new Date(blank.occurredAt).getTime()), false);
const kept = sanitizeEntry({ id: 'd3', occurredAt: '2026-08-01T10:00:00.000Z' });
assert.equal(kept.occurredAt, '2026-08-01T10:00:00.000Z', 'valid dates pass through unchanged');
});
test('imports containing invalid dates migrate forward instead of crashing the whole ledger', () => {
const payload = JSON.stringify([
{ id: 'bad-date', occurredAt: 'garbage-date-value', bristolType: 3 },
{ id: 'good-date', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4 },
]);
const imported = importLedger(payload);
assert.equal(imported.length, 2, 'one bad field cannot destroy the batch');
assert.equal(Number.isNaN(new Date(imported[0].occurredAt).getTime()), false, 'bad date becomes a safe ISO timestamp');
assert.equal(imported[1].occurredAt, '2026-08-20T09:00:00.000Z');
});
test('round trip preserves confirmed values and provenance without leaking secrets', () => {
const saved = [
sanitizeEntry({
id: 'r1',
occurredAt: '2026-08-19T08:30:00.000Z',
bristolType: 2,
color: 'green',
urgency: 3,
discomfort: 2,
note: 'rough morning',
provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' },
}),
{
id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4,
note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack',
},
];
const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z');
assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/);
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 2);
assert.deepEqual(
{ id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note },
{ id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' },
);
assert.equal(roundTripped[0].symptoms.blood, false);
assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' });
assert.equal(roundTripped[1].bristolType, 4, 'out-of-range Bristol type falls back to the neutral default under strict schema');
assert.equal(roundTripped[1].color, 'brown');
assert.equal(roundTripped[1].urgency, 0, 'out-of-range urgency falls back to the neutral default');
assert.equal(roundTripped[1].discomfort, 0);
assert.deepEqual(roundTripped[1].provenance, undefined);
});
test('re-exporting an imported ledger converges to the same portable document', () => {
const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }];
const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z'));
const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z'));
assert.deepEqual(second, first);
});
+20
View File
@@ -0,0 +1,20 @@
#!/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');
+10 -5
View File
@@ -193,16 +193,21 @@ test('Hermes child receives only an explicit non-secret environment allowlist',
});
});
test('upstream failures and concurrency fail closed without leaking process detail', async () => {
test('saturated turns queue within bounds and excess load fails closed without leaking process detail', async () => {
let release;
const runTurn = () => new Promise(resolve => { release = resolve; });
const service = createHermesAgentService({ config: configured(), randomToken: () => 'browser-cookie', runTurn });
const service = createHermesAgentService({ config: { ...configured(), maxQueueDepth: 1 }, 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));
await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'second', ledger: [] } }), 429);
release({ reply: 'done', sessionId: 'private-session' });
await active;
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/);
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' });
@@ -0,0 +1,96 @@
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');
});
+136
View File
@@ -0,0 +1,136 @@
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 });
+72
View File
@@ -0,0 +1,72 @@
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');
});
+108
View File
@@ -0,0 +1,108 @@
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');
});
-316
View File
@@ -1,316 +0,0 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const ROOT_STORE = 'timmy:/:ledger-v1';
const LEGACY_STORE = 'timmy-ledger-v1';
await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, 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));
page.on('dialog', dialog => dialog.accept());
await page.goto(process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// Seed one confirmed, user-owned entry with provenance.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{
id: 'seed-1', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green',
urgency: 3, discomfort: 2, note: 'seeded confirmed entry', photoDataUrl: '',
symptoms: { blood: false }, provenance: { origin: 'ai-suggestion' },
},
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 1, 'seeded entry renders');
// Export writes the versioned envelope with provenance and no secrets.
await page.locator('[data-view="calendar"]').last().click();
await page.locator('[data-view="privacy"]').click();
const downloadPromise = page.waitForEvent('download');
await page.locator('#export').click();
const download = await downloadPromise;
const exportedText = await download.path().then(readFile).then(buffer => buffer.toString('utf8'));
const exported = JSON.parse(exportedText);
assert.equal(exported.schemaVersion, 1);
assert.equal(exported.entries[0].provenance.origin, 'ai-suggestion');
assert.doesNotMatch(exportedText, /apiToken|sessionId|sk-/i);
// Importing a prior-version export merges instead of replacing user data.
const legacyPath = join(workDir, 'legacy-ledger.json');
await writeFile(legacyPath, JSON.stringify([
{ id: 'legacy-9', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'legacy import' },
]));
await page.setInputFiles('#import', legacyPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const mergedIds = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(mergedIds.sort(), ['legacy-9', 'seed-1'], 'import must merge, not replace');
// A future schema version fails safely and leaves the ledger untouched.
const futurePath = join(workDir, 'future-ledger.json');
await writeFile(futurePath, JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 2, entries: [{ id: 'from-the-future' }] }));
await page.setInputFiles('#import', futurePath);
await page.getByText(/newer Timmy app/).waitFor({ timeout: 5000 });
const afterFuture = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterFuture.sort(), ['legacy-9', 'seed-1'], 'failed import must not mutate the ledger');
// Malformed JSON fails safely and leaves the ledger untouched.
const malformedPath = join(workDir, 'malformed-ledger.json');
await writeFile(malformedPath, '{"schemaVersion":1,"entries":');
await page.setInputFiles('#import', malformedPath);
await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 });
const afterMalformed = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterMalformed.sort(), ['legacy-9', 'seed-1'], 'malformed import must not mutate the ledger');
await page.screenshot({ path: 'artifacts/portability-import-mobile.png', fullPage: false });
// Byte-limit enforcement in the browser: File.size is checked BEFORE the
// file is read, and UTF-8 bytes (not JS characters) are the measured unit.
const MAX_IMPORT_BYTES = await page.evaluate(async () => (await import('/src/domain.js')).MAX_IMPORT_BYTES);
const oversizePath = join(workDir, 'oversize-ledger.json');
await writeFile(oversizePath, Buffer.concat([
Buffer.from('{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"'),
Buffer.alloc(MAX_IMPORT_BYTES + 1, 0x6e),
Buffer.from('"}]}'),
]));
let textReads = 0;
await page.evaluate(() => {
const original = File.prototype.text;
File.prototype.text = function (...args) {
window.__fileTextReads = (window.__fileTextReads || 0) + 1;
return original.apply(this, args);
};
});
await page.setInputFiles('#import', oversizePath);
await page.getByText(/too large/i).waitFor({ timeout: 5000 });
textReads = await page.evaluate(() => window.__fileTextReads || 0);
assert.equal(textReads, 0, 'oversized files must be rejected by File.size before File.text()');
const afterOversize = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterOversize.sort(), ['legacy-9', 'seed-1'], 'oversized import must not mutate the ledger');
// Multibyte boundary: a payload whose UTF-8 byte size exceeds the cap while
// its JS character count does not must still be rejected (byte-exact limit).
const emojiHead = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[{"id":"e","occurredAt":"2026-08-20T09:00:00.000Z","bristolType":4,"color":"brown","urgency":0,"discomfort":0,"note":"';
const emojiTail = '"}]}';
const emojiNoteUnits = Math.ceil(MAX_IMPORT_BYTES / 3); // ~1.33x cap in UTF-8 bytes, ~0.67x cap in JS units
const multibyteOverBytesPath = join(workDir, 'multibyte-over-bytes.json');
await writeFile(multibyteOverBytesPath, Buffer.from(emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail, 'utf8'));
const multibyteStats = await page.evaluate(payload => {
return { bytes: new TextEncoder().encode(payload).length, units: payload.length };
}, emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail);
assert.ok(multibyteStats.bytes > MAX_IMPORT_BYTES, 'fixture must exceed the cap in UTF-8 bytes');
assert.ok(multibyteStats.units <= MAX_IMPORT_BYTES, 'fixture must stay under the cap in JS characters');
await page.setInputFiles('#import', multibyteOverBytesPath);
await page.getByText(/too large/i).waitFor({ timeout: 5000 });
const afterMultibyte = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterMultibyte.sort(), ['legacy-9', 'seed-1'], 'multibyte over-byte import must not mutate the ledger');
// A dense multibyte payload just UNDER the byte cap still imports cleanly.
const underBytesPath = join(workDir, 'multibyte-under-bytes.json');
const underPayload = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt: '2026-08-22T00:00:00.000Z',
entries: [{ id: 'under-1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕'.repeat(2000), photoDataUrl: '', symptoms: {} }],
});
const underBytes = await page.evaluate(payload => new TextEncoder().encode(payload).length, underPayload);
assert.ok(underBytes <= MAX_IMPORT_BYTES && underBytes > 10000, 'under-cap fixture must carry real multibyte mass');
await writeFile(underBytesPath, Buffer.from(underPayload, 'utf8'));
await page.setInputFiles('#import', underBytesPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const afterUnderBytes = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.ok(afterUnderBytes.includes('under-1'), 'valid multibyte import lands in the ledger');
// Collision-safe merge: re-importing a file whose ids already exist must
// not duplicate or overwrite anything and must say so explicitly.
const beforeReimport = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
await page.setInputFiles('#import', underBytesPath);
await page.getByText(/already in your ledger/i).waitFor({ timeout: 5000 });
const afterReimport = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterReimport.sort(), JSON.parse(beforeReimport).map(entry => entry.id).sort(), 're-import is idempotent: no duplicates, no overwrites');
// RED 1 — transactional import: a quota failure during save must roll back
// BOTH the in-memory ledger and localStorage together.
const beforeQuota = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
const domCountBeforeQuota = await page.locator('.entry').count();
await page.evaluate(() => {
// Simulate storage exhaustion at the exact moment of persistence,
// keeping the original setter around for a faithful restore.
const real = Object.getOwnPropertyDescriptor(Storage.prototype, 'setItem');
window.__realSetItem = real;
Object.defineProperty(Storage.prototype, 'setItem', {
...real,
value: function setItem() { throw new DOMException('quota exceeded', 'QuotaExceededError'); },
});
});
const quotaImportPath = join(workDir, 'quota-import.json');
await writeFile(quotaImportPath, JSON.stringify([
{ id: 'quota-new-1', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'arrives right before quota failure', photoDataUrl: '', symptoms: {} },
]));
await page.setInputFiles('#import', quotaImportPath);
await page.getByText(/storage is full/i).waitFor({ timeout: 5000 });
const afterQuotaStorage = await page.evaluate(([storeKey]) => localStorage.getItem(storeKey), [ROOT_STORE]);
assert.equal(afterQuotaStorage, beforeQuota, 'localStorage must be untouched after a failed save');
const inMemoryIds = await page.evaluate(() => Array.from(document.querySelectorAll('.entry strong')).map(node => node.textContent));
assert.equal(await page.locator('.entry').count(), domCountBeforeQuota, 'rendered journal shows no partially imported rows');
assert.ok(!JSON.stringify(inMemoryIds).includes('quota-new-1'), 'in-memory ledger rolled back with storage');
await page.evaluate(() => {
Object.defineProperty(Storage.prototype, 'setItem', window.__realSetItem);
delete window.__realSetItem;
});
// RED 2 — pre-render migration: malformed stored history (invalid dates,
// duplicate ids) is repaired before render instead of blanking the UI.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{ id: 'broken-date', occurredAt: 'not-a-real-date', bristolType: 3, note: 'date was corrupted' },
{ id: 'twin-row', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'twin one' },
{ id: 'twin-row', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, note: 'twin two' },
]));
});
await page.reload({ waitUntil: 'networkidle' });
assert.ok((await page.getByText(/Timmy noticed|Journal/).count()) > 0 || (await page.locator('#app').innerText()).length > 0, 'app renders over malformed storage');
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 3, 'invalid date did not blank the UI; every distinct record renders');
const migratedStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE);
const brokenRow = migratedStore.find(entry => entry.note === 'date was corrupted');
assert.ok(brokenRow && !Number.isNaN(new Date(brokenRow.occurredAt).getTime()), 'stored invalid dates become valid ISO timestamps');
const twinRows = migratedStore.filter(entry => entry.id.startsWith('twin-row'));
assert.deepEqual(twinRows.map(entry => entry.id).sort(), ['twin-row', 'twin-row#2'], 'duplicate ids repaired deterministically in storage');
// RED 3 — ambiguous top-level arrays are rejected wholesale in the browser.
const unrelatedPath = join(workDir, 'unrelated-array.json');
await writeFile(unrelatedPath, JSON.stringify([{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }]));
const beforeUnrelated = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE);
await page.locator('[data-view="privacy"]').click();
await page.setInputFiles('#import', unrelatedPath);
await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 });
assert.equal(
await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE),
beforeUnrelated,
'rejected unrelated arrays never mutate the ledger',
);
// RED 4 — mislabeled SVG photos are dropped by canonical raster validation
// while genuinely canonical photos survive import untouched.
const svgB64 = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64');
const fakePhotoEntry = {
id: 'fake-photo', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown',
urgency: 0, discomfort: 0, note: 'smuggled svg', symptoms: {},
photoDataUrl: `data:image/jpeg;base64,${svgB64}`,
};
const realJpeg = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(600, 0x33)]).toString('base64');
const realPhotoEntry = {
id: 'real-photo', occurredAt: '2026-08-20T10:00:00.000Z', bristolType: 2, color: 'green',
urgency: 1, discomfort: 0, note: 'genuine raster', symptoms: {},
photoDataUrl: `data:image/jpeg;base64,${realJpeg}`,
};
const photoImportPath = join(workDir, 'photo-contract.json');
await writeFile(photoImportPath, JSON.stringify([fakePhotoEntry, realPhotoEntry]));
await page.setInputFiles('#import', photoImportPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const afterPhotos = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE);
const fakeStored = afterPhotos.find(entry => entry.id === 'fake-photo');
assert.ok(fakeStored, 'the record itself still imports');
assert.equal(fakeStored.photoDataUrl, '', 'mislabeled SVG payload is stripped from the entry');
const realStored = afterPhotos.find(entry => entry.id === 'real-photo');
assert.equal(realStored.photoDataUrl, `data:image/jpeg;base64,${realJpeg}`, 'canonical raster photos survive byte-for-byte');
// RED 5 — duplicate-ID repair preserves both distinct local records in the
// full app flow, and the repair lands in storage.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{ id: 'dup-pair', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first distinct record', photoDataUrl: '', symptoms: {} },
{ id: 'dup-pair', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second distinct record', photoDataUrl: '', symptoms: {} },
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 2, 'both duplicate-id records render as distinct rows');
const dupStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE);
const dupIds = dupStore.map(entry => entry.id).sort();
assert.deepEqual(dupIds, ['dup-pair', 'dup-pair#2'], 'storage holds two distinct deterministic ids');
// Delete Everything removes every namespaced copy of the local ledger.
await page.locator('[data-view="privacy"]').click();
await page.locator('#delete-all').click();
await page.getByText('Local ledger deleted').waitFor({ timeout: 5000 });
const storesAfterDelete = await page.evaluate(([rootStore, legacyStore]) => ({
root: localStorage.getItem(rootStore),
legacy: localStorage.getItem(legacyStore),
}), [ROOT_STORE, LEGACY_STORE]);
assert.equal(storesAfterDelete.root, null, 'namespaced store cleared');
assert.equal(storesAfterDelete.legacy, null, 'legacy store cleared');
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 0, 'journal is empty after delete-all');
// Base-path deployments keep their ledger in an isolated namespace.
const staging = spawn(process.execPath, ['server.mjs'], {
env: { ...process.env, PORT: '4179', HOST: '127.0.0.1', TIMMY_BASE_PATH: '/timmy-staging' },
stdio: 'ignore',
});
try {
let up = false;
for (let attempt = 0; attempt < 40 && !up; attempt += 1) {
up = await fetch('http://127.0.0.1:4179/timmy-staging/api/healthz').then(response => response.ok).catch(() => false);
if (!up) await sleep(250);
}
assert.ok(up, 'staging server must start');
const stagingContext = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
const stagingPage = await stagingContext.newPage();
stagingPage.on('dialog', dialog => dialog.accept());
await stagingPage.goto('http://127.0.0.1:4179/timmy-staging', { waitUntil: 'networkidle' });
await stagingPage.evaluate(() => {
localStorage.clear();
localStorage.setItem('timmy:/timmy-staging:ledger-v1', JSON.stringify([
{ id: 'staging-1', occurredAt: '2026-08-21T10:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'staging only', photoDataUrl: '', symptoms: {} },
]));
// Legacy root-deployment ledger living in the same origin's storage.
localStorage.setItem('timmy-ledger-v1', JSON.stringify([
{ id: 'root-legacy-1', occurredAt: '2026-08-19T09:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'root legacy ledger', photoDataUrl: '', symptoms: {} },
]));
});
await stagingPage.reload({ waitUntil: 'networkidle' });
await stagingPage.locator('[data-view="calendar"]').last().click();
assert.equal(await stagingPage.locator('.entry').count(), 1, 'staging entry renders under its base path');
const isolation = await stagingPage.evaluate(() => ({
staging: localStorage.getItem('timmy:/timmy-staging:ledger-v1'),
root: localStorage.getItem('timmy:/:ledger-v1'),
legacy: localStorage.getItem('timmy-ledger-v1'),
}));
assert.ok(isolation.staging, 'staging store keeps its data');
assert.equal(isolation.root, null, 'root-namespaced store untouched by staging data');
assert.ok(isolation.legacy && JSON.parse(isolation.legacy).some(entry => entry.id === 'root-legacy-1'), 'legacy store untouched by staging session');
await stagingPage.locator('[data-view="privacy"]').click();
await stagingPage.locator('#delete-all').click();
await stagingPage.getByText('Local ledger deleted').waitFor({ timeout: 5000 });
const stagingAfterDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy:/timmy-staging:ledger-v1'));
assert.equal(stagingAfterDelete, null, 'delete-all clears the base-path store');
const legacyAfterStagingDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy-ledger-v1'));
assert.ok(
legacyAfterStagingDelete && JSON.parse(legacyAfterStagingDelete).some(entry => entry.id === 'root-legacy-1'),
'base-path delete-all must not erase another namespace’s global legacy ledger',
);
await stagingContext.close();
} finally {
staging.kill();
}
assert.deepEqual(errors, [], 'no console or page errors');
await context.close();
console.log('PASS ledger portability: export round trip, merge import, safe failures, isolation, delete-all');
} finally {
await browser.close();
await rm(workDir, { recursive: true, force: true });
}
});
+1 -2
View File
@@ -23,8 +23,7 @@ await page.route('**/api/analyze', route => route.fulfill({
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
}),
}));
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173';
await page.goto(appUrl, { waitUntil: 'networkidle' });
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
+93
View File
@@ -0,0 +1,93 @@
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 });
+2 -2
View File
@@ -43,7 +43,7 @@ async function dispatchFetch(handler, request) {
return response;
}
test('root activation deletes only its obsolete Timmy caches including the legacy v4 and previous v5 shells', async () => {
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
const { listeners, deleted } = loadWorker({
cacheKeys: [
'timmy-shell-v4',
@@ -56,7 +56,7 @@ test('root activation deletes only its obsolete Timmy caches including the legac
await dispatchExtendable(listeners.get('activate'));
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']);
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
});
test('offline shell lookup uses only the current named cache', async () => {
+1 -18
View File
@@ -148,23 +148,6 @@ test('malformed and escaping base paths are rejected at startup', async () => {
'/timmy\nstaging',
];
// The rejection must happen at startup, so the wait budget is anchored to
// the measured cold-start time of the server itself rather than a magic
// constant that silently breaks on loaded or cold machines.
const timedSpawn = () => new Promise(resolve => {
const startedAt = Date.now();
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: '/' },
stdio: ['ignore', 'ignore', 'ignore'],
});
child.once('exit', () => resolve(Date.now() - startedAt));
setTimeout(() => { child.kill('SIGKILL'); }, 5000);
});
await timedSpawn(); // warm caches once; timing after this reflects steady state
const baseline = await timedSpawn();
const budget = Math.max(2000, baseline * 4 + 500);
for (const value of invalid) {
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
@@ -175,7 +158,7 @@ test('malformed and escaping base paths are rejected at startup', async () => {
child.stderr.on('data', chunk => { stderr += chunk; });
const exitCode = await Promise.race([
new Promise(resolve => child.once('exit', resolve)),
new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, budget)),
new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, 800)),
]);
assert.notEqual(exitCode, 'timeout', `${JSON.stringify(value)} was accepted`);
assert.notEqual(exitCode, 0, `${JSON.stringify(value)} exited successfully`);
+1 -2
View File
@@ -9,8 +9,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));
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173';
await page.goto(appUrl, { waitUntil: 'networkidle' });
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });