${e(task.activity||'Public activity unavailable.')}
${task.input?`${e(task.input.prompt.slice(0,240))}
`:''}diff --git a/experiments/command-center/model.mjs b/experiments/command-center/model.mjs new file mode 100644 index 0000000..732695e --- /dev/null +++ b/experiments/command-center/model.mjs @@ -0,0 +1 @@ +export * from '../../src/command-model.mjs'; diff --git a/experiments/command-center/replies.mjs b/experiments/command-center/replies.mjs new file mode 100644 index 0000000..57163bd --- /dev/null +++ b/experiments/command-center/replies.mjs @@ -0,0 +1 @@ +export * from '../../src/command-replies.mjs'; diff --git a/experiments/command-center/scenario.mjs b/experiments/command-center/scenario.mjs new file mode 100644 index 0000000..ad5add0 --- /dev/null +++ b/experiments/command-center/scenario.mjs @@ -0,0 +1,24 @@ +/** Entirely fictional evidence, deliberately separate from the live feed. */ +export function createScenario(now=Date.now()){ + const minute=60_000; + const pr=(repo,number,reviewState,extra={})=>({url:`https://example.invalid/illustrative/${repo}/pull/${number}`,number,authority:'hub',state:'open',reviewState,checkedAt:now-2*minute,headSha:'fictional-head',reviewedHeadSha:'fictional-head',checks:[{name:'Illustrative Node checks',status:'COMPLETED',conclusion:'SUCCESS'}],...extra}); + const issue=(repo,number,title)=>({url:`https://example.invalid/illustrative/${repo}/issues/${number}`,number,title}); + const base=(id,town,task,extra={})=>({id,taskId:`scenario:${id}`,town,project:town,task,status:'working',source:'illustrative',illustrative:true,inventoryScope:'dashboard',taskStartedAt:now-32*minute,sessionStartedAt:now-80*minute,updatedAt:now-20_000,lastProgressAt:now-2*minute,harness:'Codex',model:'GPT-6.1',originalAsk:task,activity:'Checking the smallest useful change.',conversation:{available:false,reason:'Illustrative work has no live transport.'},...extra}); + const shared=pr('hub',9101,'waiting-ci',{reason:'Waiting for the illustrative inventory contract checks.'}); + const agents=[ + base('hub-question','HubTown','Choose the inventory retention window',{status:'blocked',harness:'Claude Code',model:'Opus 4.6',inputRequest:{id:'illustrative-retention-question',kind:'question',prompt:'Keep finished inventory records for 7 days or 30 days?',options:[{label:'7 days',description:'A smaller recent-work window.'},{label:'30 days',description:'More context for recurring work.'}],source:'illustrative',updatedAt:now-4*minute},conversation:{available:true,mode:'respond',checkedAt:now,messageUrl:'/agents/hub-question/messages'},issues:[issue('hub',8101,'Illustrative inventory retention')],activity:'The retention choice is ready for your answer.'}), + base('hub-inventory','HubTown','Preserve source identity in task inventory',{pr:shared,issues:[issue('hub',8102,'Illustrative source identity')],activity:'Verifying that task starts remain separate from session starts.'}), + base('hub-contract','HubTown','Exercise the shared inventory contract',{parent:'hub-inventory',harness:'Claude Code',model:'Sonnet 4.6',pr:shared,activity:'Running the same contract against two provider fixtures.'}), + base('mem-quiet','MemTown','Measure memory graph recall coverage',{harness:'Claude Code',model:'Opus 4.6',lastProgressAt:now-24*minute,activity:'Last recorded progress: completed the first graph sample.',issues:[issue('memory',8201,'Illustrative recall coverage')]}), + base('mem-ready','MemTown','Make association evidence inspectable',{status:'idle',pr:pr('memory',9201,'ready',{reason:'Hub reports current-head review and checks complete.'}),activity:'The illustrative review is complete.'}), + base('app-source-question','AppTown','Decide the compact task card hierarchy',{status:'blocked',harness:'Cursor',model:'Sonnet 4.6',inputRequest:{id:'illustrative-card-question',kind:'question',prompt:'Should task age or last progress take the second line?',options:['Task age','Last progress'],source:'illustrative:cursor',updatedAt:now-6*minute},conversation:{available:false,reason:'Source-only session. Answer in the original Cursor workflow.'},activity:'Waiting for a visual hierarchy decision.'}), + base('app-stack','AppTown','Add compact evidence cards after inventory lands',{status:'idle',pr:pr('app',9301,'blocked',{reason:'Depends on illustrative Hub PR #9101; the upstream contract is still running.'}),activity:'Waiting for the recorded dependency.'}), + base('app-session','AppTown','Inspect narrow-layout card spacing',{harness:'Cursor',model:'GPT-6.1',taskStartedAt:null,sessionStartedAt:now-12*minute,activity:'The source supplies session timing only.'}), + base('fusion-unknown','FusionTown','Inspect webhook delivery observations',{harness:'Unknown',model:'Unknown',pr:{state:'unknown',reason:'This illustrative source has not supplied PR evidence.'},lastProgressAt:null,activity:'Activity is observable; execution harness is unavailable.'}), + base('fusion-checks','FusionTown','Check customer tag mapping fixtures',{harness:'Claude Code',model:'Sonnet 4.6',pr:pr('fusion',9401,'waiting-codex',{reason:'Hosted review has not reported a result yet.'}),activity:'Local fixtures passed; awaiting the recorded review.'}), + base('vault-done','VaultTown','Validate a signed skill resource manifest',{status:'done',terminal:true,endedAt:now-9*minute,durationMs:23*minute,harness:'Codex',pr:pr('vault',9501,'merged',{state:'merged'}),activity:'Manifest verified and illustrative work ended.'}), + base('vault-pack','VaultTown','Package read-only capability examples',{harness:'OpenCode',model:'Qwen 3.6',issues:[issue('vault',8501,'Illustrative capability samples')],activity:'Documenting the supported local routes.'}), + ]; + for(const agent of agents)agent.events=[{id:`${agent.id}:request`,kind:'request',text:agent.originalAsk,timestamp:agent.taskStartedAt??agent.sessionStartedAt},{id:`${agent.id}:progress`,kind:'progress',text:agent.activity,timestamp:agent.lastProgressAt??agent.updatedAt}]; + return {source:'illustrative',illustrative:true,checkedAt:now,agents,relationships:[{from:'pr:example.invalid/illustrative/hub#9101',to:'pr:example.invalid/illustrative/app#9301',kind:'depends-on'}]}; +} diff --git a/src/command-model.mjs b/src/command-model.mjs new file mode 100644 index 0000000..e2d528d --- /dev/null +++ b/src/command-model.mjs @@ -0,0 +1,113 @@ +/** Shared presentation model. Observation, progress, execution and review stay independent. */ +import {normalizePr,prStage as stageOf,prCi,prKey as keyOf} from './pr.mjs'; +import {normalizeInputRequest} from './input-request.mjs'; +import {conversationCapability} from './conversation.mjs'; +import {validTime} from './feed-client.mjs'; +import {classifyOccupancy} from './occupancy.mjs'; +import {taskText} from './task-text.mjs'; +import {isTerminalForMessage} from './command-replies.mjs'; + +const text=value=>typeof value==='string'?value.trim():''; +const publicKinds=new Set(['request','progress','summary','tool','result','status','handoff']); +export const QUIET_AFTER_MS=15*60*1000; +/** Join only accepted identities; the renderer's PR normalization remains authoritative. */ +export function commandSnapshot(agents,meta={}, {stale=false}={}) { + const evidence=new Map((meta.agents||[]).filter(a=>a&&a.id!=null).map(a=>[String(a.id),a])); + const snapshot={...meta,stale:Boolean(stale||meta.stale),agents:(agents||[]).map(agent=>{ + const raw=evidence.get(String(agent.id)); + return raw?{...raw,...agent,model:raw.model,status:raw.status,terminal:raw.terminal}:{...agent}; + })}; + const shared=new Map(fleetView(snapshot,{includeSettled:true}).prs.filter(group=>group.taskIds.length>1).map(group=>[group.key,group.pr])); + if(shared.size)snapshot.agents=snapshot.agents.map(agent=>shared.has(keyOf(agent.pr))?{...agent,pr:shared.get(keyOf(agent.pr))}:agent); + return snapshot; +} +export function safeUrl(value){ + try{const url=new URL(value);return ['http:','https:'].includes(url.protocol)&&!url.username&&!url.password?url.href:'';}catch{return '';} +} +export function durationLabel(milliseconds){ + if(!Number.isFinite(milliseconds)||milliseconds<0)return 'Unavailable'; + let seconds=Math.floor(milliseconds/1000);const parts=[]; + for(const [size,unit] of [[86400,'d'],[3600,'h'],[60,'m'],[1,'s']]){ + const amount=Math.floor(seconds/size);seconds%=size; + if(amount||parts.length)parts.push(`${amount}${unit}`); + if(parts.length===2)break; + } + return parts.join(' ')||'0s'; +} +const age=(value,now)=>{const time=validTime(value);return time!==null&&time<=now?now-time:null;}; +function issuesOf(agent){ + const supplied=[...(Array.isArray(agent.issues)?agent.issues:[]),...(agent.issue?[agent.issue]:[]),...(Array.isArray(agent.resultLinks)?agent.resultLinks:[]).filter(link=>link?.kind==='issue'||link?.type==='issue')]; + const issues=new Map(); + for(const value of supplied){const raw=typeof value==='string'?{url:value}:value;const url=safeUrl(raw?.url);if(url)issues.set(url,{...raw,url,title:text(raw.title)||'Recorded issue'});} + return [...issues.values()]; +} +export function taskView(agent,{now=Date.now(),stale=false,endpoint='http://127.0.0.1:8790/agents',builtInDemo=false}={}){ + const raw=agent||{},sourceStale=Boolean(stale||raw.isStale||raw.freshness?.isStale); + // Canonical Hub readiness survives an outage. Standalone readiness needs fresh evidence. + const pr=normalizePr(raw.pr?.authority==='hub'?raw.pr:{...raw.pr,...(sourceStale?{stale:true}:{})},now); + const input=normalizeInputRequest(raw.inputRequest); + const terminal=raw.terminal===true||['done','offline','completed','failed','cancelled','interrupted'].includes(raw.status); + const end=terminal?validTime(raw.endedAt):now,start=validTime(raw.sessionStartedAt); + const sessionAgeMs=start!==null&&end!==null&&end>=start?end-start:null; + // Hub durationMs measures execution only; Task age includes input/review/merge waits. + const taskStart=validTime(raw.taskStartedAt); + const taskAgeMs=taskStart!==null&&end!==null&&end>=taskStart&&end<=now?end-taskStart:null; + const recordedExecutionMs=Number.isFinite(raw.durationMs)&&raw.durationMs>=0?raw.durationMs:null; + const progressAgeMs=age(raw.lastProgressAt??raw.telemetry?.progressAt,now); + const status=text(raw.status)||'unknown'; + const capability=builtInDemo&&raw.source==='demo'&&input?{available:true,mode:'respond',demo:true}:isTerminalForMessage(raw)?{available:false,reason:'This task has finished.'}:conversationCapability(raw,endpoint,{stale:sourceStale,now}); + const events=(Array.isArray(raw.events)?raw.events:Array.isArray(raw.activityEvents)?raw.activityEvents:[]).filter(event=>publicKinds.has(event?.kind)&&typeof event.text==='string').map(event=>({...event})); + return {id:String(raw.id??raw.taskId??''),raw,title:taskText(raw.task)||text(raw.title)||text(raw.name)||'Untitled task', + project:text(raw.project?.name)||text(raw.project)||text(raw.town)||text(raw.repo)||'Unassigned',town:text(raw.town)||'Unknown town', + harness:text(raw.harness)||text(raw.execution?.harness)||'Unknown',model:text(raw.model)||'Unknown',status, + taskAgeMs,recordedExecutionMs,sessionAgeMs,observedAgeMs:age(raw.updatedAt,now),progressAgeMs, + quiet:!terminal&&status==='working'&&progressAgeMs!==null&&progressAgeMs>=QUIET_AFTER_MS, + input,capability,pr,prKey:keyOf(pr),prStage:stageOf(pr,now),ci:prCi(pr), + attention:Boolean(input||status==='blocked'||pr.stage==='blocked'||text(raw.attention)), + activity:text(raw.activity)||text(raw.lastLine),events,issues:issuesOf(raw),parent:raw.parent==null?null:String(raw.parent),sourceStale}; +} + +export function fleetView(snapshot,options={}){ + const now=options.now??Date.now(),envelope=Array.isArray(snapshot)?{agents:snapshot}:snapshot||{}; + const stale=Boolean(options.stale||envelope.stale),all=Array.isArray(envelope.agents)?envelope.agents:[]; + const settled=agent=>agent.inventoryScope==='history'||(agent.inventoryScope!=='dashboard'&&(agent.occupancy?agent.occupancy==='settled':classifyOccupancy(agent,now)==='settled')); + let tasks=all.filter(agent=>options.includeSettled||!settled(agent)).map(agent=>taskView(agent,{...options,now,stale})); + const projects=new Map(),groups=new Map(); + for(const task of tasks){ + if(task.prKey){if(!groups.has(task.prKey))groups.set(task.prKey,[]);groups.get(task.prKey).push(task);} + } + const prs=[...groups].map(([key,members])=>{ + const canonical=members.filter(task=>task.pr.authority==='hub');const candidates=canonical.length?canonical:members; + const latest=Math.max(...candidates.map(task=>task.pr.checkedAt||0));const current=candidates.filter(task=>(task.pr.checkedAt||0)===latest); + const conflict=new Set(current.map(task=>task.prStage)).size>1||new Set(current.map(task=>task.pr.headSha).filter(Boolean)).size>1; + const pr=conflict?{...current[0].pr,state:'unknown',stage:'unknown',reviewState:'unknown',reviewUncertain:true,reason:'Shared PR observations conflict at the same verification time.'}:current[0].pr; + return {key,pr,stage:pr.stage,taskIds:members.map(task=>task.id)}; + }); + const canonicalByKey=new Map(prs.map(group=>[group.key,group])); + tasks=tasks.map(task=>{ + const group=canonicalByKey.get(task.prKey);if(!group)return task; + return {...task,pr:group.pr,prStage:group.stage,ci:prCi(group.pr), + attention:Boolean(task.input||task.status==='blocked'||group.stage==='blocked'||text(task.raw.attention))}; + }); + for(const task of tasks){ + if(!projects.has(task.project))projects.set(task.project,{id:task.project,name:task.project,count:0,working:0,attention:0}); + const project=projects.get(task.project);project.count++;project.working+=Number(task.status==='working');project.attention+=Number(task.attention); + } + const nodes=new Set(tasks.map(task=>`task:${task.id}`)),aliases=new Map(); + for(const task of tasks){aliases.set(task.id,task.id);if(task.raw.taskId)aliases.set(String(task.raw.taskId),task.id);for(const issue of task.issues)nodes.add(`issue:${issue.url}`);if(task.prKey)nodes.add(`pr:${task.prKey}`);} + const edges=new Map(); + function edge(from,to,kind){if(from!==to&&nodes.has(from)&&nodes.has(to)&&kind)edges.set(`${from}|${to}|${kind}`,{from,to,kind});} + for(const task of tasks){ + if(aliases.has(task.parent))edge(`task:${aliases.get(task.parent)}`,`task:${task.id}`,'parent'); + for(const issue of task.issues)edge(`issue:${issue.url}`,`task:${task.id}`,'implements'); + if(task.prKey)edge(`task:${task.id}`,`pr:${task.prKey}`,'produces'); + } + // Unstructured project paths and prose never become task relationships. + for(const relation of Array.isArray(envelope.relationships)?envelope.relationships:[]){ + if(typeof relation?.from==='string'&&typeof relation?.to==='string')edge(relation.from,relation.to,text(relation.kind)); + } + return {tasks,projects:[...projects.values()],prs,edges:[...edges.values()],counts:{tasks:tasks.length,working:tasks.filter(t=>t.status==='working').length, + attention:tasks.filter(t=>t.attention).length,answerable:tasks.filter(t=>t.input&&t.capability.available&&t.capability.mode==='respond').length, + quiet:tasks.filter(t=>t.quiet).length,ready:prs.filter(p=>p.stage==='ready').length,blockedPr:prs.filter(p=>p.stage==='blocked').length,settled:all.length-tasks.length}, + stale,checkedAt:validTime(envelope.checkedAt)??validTime(envelope.timestamp)??null}; +} diff --git a/src/command-render.mjs b/src/command-render.mjs new file mode 100644 index 0000000..8a9c8c4 --- /dev/null +++ b/src/command-render.mjs @@ -0,0 +1,59 @@ +/** Read-only command surfaces. Every source string passes through escapeHTML. */ +import {durationLabel, safeUrl} from './command-model.mjs'; +export const escapeHTML=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +const e=escapeHTML; +export const townOf=task=>task.raw.town||task.raw.role||'WildTown'; +export const ownerOf=task=>typeof task.raw.owner==='string'&&task.raw.owner.trim()?task.raw.owner:task.harness; +export const stageLabel=stage=>({'none':'No PR recorded','unknown':'PR unavailable','waiting-codex':'Awaiting review','waiting-ci':'Awaiting CI','active':'Review active','ready':'Ready','blocked':'Blocked','merged':'Merged','closed':'Closed','open':'Open'}[stage]||'PR unavailable'); +export const isAnswerable=task=>Boolean(task.input&&task.capability.available&&task.capability.mode==='respond'); +export function attentionQueue(tasks){return tasks.filter(task=>task.attention).sort((a,b)=>Number(isAnswerable(b))-Number(isAnswerable(a))||Number(Boolean(b.input))-Number(Boolean(a.input)));} +export function filteredTasks(tasks,filter){return tasks.filter(task=>filter==='all'||(filter==='attention'?task.attention:filter==='working'?task.status==='working':filter==='quiet'?task.quiet:filter==='ready'?task.prStage==='ready':true));} +export function searchTasks(tasks,query){const words=query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);return tasks.filter(task=>words.every(word=>[task.title,task.raw.name,task.activity,task.id,task.town,task.project,ownerOf(task),task.model,task.pr.number,task.input?.prompt,...task.issues.flatMap(issue=>[issue.title,issue.number,issue.url])].join(' ').toLocaleLowerCase().includes(word))).slice(0,30);} +export function graphData(tasks,fleet){ + const nodes=new Map(); + for(const task of tasks){ + nodes.set(`task:${task.id}`,{id:`task:${task.id}`,kind:'task',task,title:task.title,caption:`${ownerOf(task)} · ${task.status}`}); + for(const issue of task.issues)if(safeUrl(issue.url))nodes.set(`issue:${issue.url}`,{id:`issue:${issue.url}`,kind:'issue',issue,task,title:issue.title,caption:issue.number?`Issue #${issue.number}`:'Recorded issue'}); + if(task.prKey){const group=fleet.prs.find(pr=>pr.key===task.prKey);nodes.set(`pr:${task.prKey}`,{id:`pr:${task.prKey}`,kind:'pr',task,group,title:group?.pr.title||`Pull request #${task.pr.number}`,caption:`${stageLabel(group?.stage||task.prStage)}${group?.taskIds.length>1?` · ${group.taskIds.length} cottages`:''}`});} + } + return {nodes,edges:fleet.edges.filter(edge=>nodes.has(edge.from)&&nodes.has(edge.to))}; +} +function prButton(task){return ``;} +const clock=(label,ms,suffix='')=>`${label} ${e(durationLabel(ms))}${ms===null?'':suffix}`; +export function dispatchHTML(tasks,{filter='all',selectedId=null,counts={}}={}){ + const rows=filteredTasks(tasks,filter); + return `
${filter==='quiet'?'Quiet means no explicit progress for at least 15 minutes; execution can still be working.':'Task age includes waits. Execution, session and progress are separate clocks. Shared PRs count once.'}
${e(task.activity||'Public activity unavailable.')}
${task.input?`${e(task.input.prompt.slice(0,240))}
`:''}No cottages match this filter in the current town.
'}No cottages in the current town. Choose another town to follow its recorded work.
'; + const selectedKey=`task:${active.id}`,prKey=active.prKey?`pr:${active.prKey}`:null; + const direct=edges.filter(edge=>edge.from===selectedKey||edge.to===selectedKey||(prKey&&(edge.from===prKey||edge.to===prKey))); + const connected=new Set([selectedKey,...direct.flatMap(edge=>[edge.from,edge.to])]); + const trace=`No issue, parent, dependency or PR relationships recorded for this cottage.
'}Issue links are not recorded in this feed. Known parent, dependency and PR links appear below.
':''}`; + if(width<700||nodes.size>36)return intro+`Following one cottage keeps its recorded path readable here.
`+trace; + const columns={issue:[],task:[],pr:[]},positions=new Map();for(const node of nodes.values())columns[node.kind].push(node); + const lanes=['issue','task','pr'].filter(kind=>columns[kind].length); + const laneWidth=(96-6*(lanes.length-1))/lanes.length; + const laneLeft=index=>1+index*(laneWidth+6); + lanes.forEach((kind,lane)=>columns[kind].forEach((node,index)=>positions.set(node.id,{x:laneLeft(lane),y:20+index*128,w:laneWidth}))); + const height=Math.max(240,...[...positions.values()].map(point=>point.y+120)); + const paths=edges.map(edge=>{ + const from=positions.get(edge.from),to=positions.get(edge.to),same=from.x===to.x; + const x1=(from.x+from.w)*width/100,x2=(same?to.x+to.w:to.x)*width/100,y1=from.y+42,y2=to.y+42; + const bend=same?x1+24:(x1+x2)/2; + return `${e(task.pr.reason||'No additional review reason recorded.')}
CI ${e(task.ci.state)}${task.ci.total?` · ${task.ci.passed}/${task.ci.total} checks passed`:''} · ${e(task.pr.authority==='hub'?'Hub authority':'Source observation')}${task.pr.checkedAt?`Verified ${e(new Date(task.pr.checkedAt).toLocaleString())}`:'Verification time unavailable'}${task.sourceStale?'Source is stale; canonical Hub evidence is retained.':''}`; +} diff --git a/src/command-replies.mjs b/src/command-replies.mjs new file mode 100644 index 0000000..16a2e46 --- /dev/null +++ b/src/command-replies.mjs @@ -0,0 +1,85 @@ +/** Explicit Send only. The server still owns authoritative validation and receipts. */ +import {conversationCapability,MAX_MESSAGE_LENGTH} from './conversation.mjs'; +import {normalizeInputRequest,inputRequestVersion} from './input-request.mjs'; + +/** Explicit current lifecycle flags outrank an end timestamp retained from an earlier attempt. */ +export function isTerminalForMessage(agent){ + return agent?.terminal===true||['done','offline','completed','cancelled','failed','interrupted'].includes(agent?.status)||Boolean(agent?.endedAt&&agent?.terminal!==false); +} + +export function createReplyClient({endpoint='http://127.0.0.1:8790/agents',fetchImpl=globalThis.fetch,storage,now=Date.now}={}){ + const feed=new URL(endpoint),states=new Map(),active=new Set(); + if(storage===undefined){try{storage=globalThis.localStorage;}catch{storage=null;}} + const key=taskId=>`cottagecode:command-center:reply:${feed.href}:${encodeURIComponent(taskId)}`; + const idle=()=>({phase:'idle',notice:''}); + const uncertainNotice='Delivery is unconfirmed. Check the original task before sending again. Automatic retry is locked.'; + function state(taskId,{persistent=false}={}){ + if(!persistent&&active.has(taskId))return {...states.get(taskId)}; + try{ + const raw=storage?.getItem(key(taskId)); + if(raw){const saved=JSON.parse(raw);if(!saved||saved.taskId!==taskId||!['sending','uncertain','sent','error'].includes(saved.phase))throw new Error('Invalid receipt'); + return {...saved,...(saved.phase==='sending'?{phase:'uncertain',notice:uncertainNotice}:{})};} + }catch{return {phase:'uncertain',notice:'The saved delivery receipt is unavailable. Check the original task before sending again.'};} + return {...(states.get(taskId)||idle())}; + } + function remember(taskId,value){ + if(!storage||typeof storage.setItem!=='function'||typeof storage.getItem!=='function')throw new Error('Persistent receipt storage is unavailable.'); + const record={...value,taskId,at:now()};storage.setItem(key(taskId),JSON.stringify(record)); + if(storage.getItem(key(taskId))!==JSON.stringify(record))throw new Error('The delivery receipt could not be verified.'); + states.set(taskId,record);return {...record}; + } + function fail(taskId,notice){const result={phase:'error',notice};states.set(taskId,result);return {...result};} + function capability(agent,stale=false){return conversationCapability(agent,feed.href,{now:now(),stale:Boolean(stale||agent?.isStale||agent?.freshness?.isStale)});} + function exactRoute(agent,cap){try{const route=new URL(cap.url);return route.origin===feed.origin&&route.pathname===`/agents/${encodeURIComponent(agent.id)}/messages`&&!route.search&&!route.hash;}catch{return false;}} + async function send(agent,message){ + const taskId=agent?.taskId; + if(!taskId)return {phase:'error',notice:'The source has not identified a supported task.'}; + if(active.has(taskId))return {...states.get(taskId)}; + const shownInput=normalizeInputRequest(agent.inputRequest),version=inputRequestVersion(shownInput),previous=state(taskId); + if(['sending','uncertain'].includes(previous.phase)||(shownInput&&agent.conversation?.mode==='respond'&&previous.phase==='sent'&&previous.inputVersion===version))return previous; + if(isTerminalForMessage(agent))return fail(taskId,'This task has finished. Start any follow-up in its original workflow.'); + if(agent.illustrative||agent.source==='illustrative')return fail(taskId,'Illustrative replies stay local. No live message was sent.'); + const value=typeof message==='string'?message.trim():''; + if(!value||value.length>MAX_MESSAGE_LENGTH)return fail(taskId,`Write a message of 1–${MAX_MESSAGE_LENGTH} characters.`); + const shownCapability=capability(agent); + if(!shownCapability.available||!exactRoute(agent,shownCapability))return fail(taskId,shownCapability.reason||'The task has no supported local message route.'); + active.add(taskId);states.set(taskId,{phase:'checking',notice:'Checking the current task and question…'}); + let reserved=false,requestId; + try{ + const response=await fetchImpl(feed.href,{credentials:'omit',redirect:'error',headers:{accept:'application/json'},signal:AbortSignal.timeout(10_000)}); + if(!response.ok)return fail(taskId,'The feed could not verify the current task. Nothing was sent.'); + const snapshot=await response.json(),agents=Array.isArray(snapshot)?snapshot:snapshot.agents; + const matches=Array.isArray(agents)?agents.filter(current=>String(current.id)===String(agent.id)):[]; + const latest=matches.length===1?matches[0]:null; + if(!latest||latest.taskId!==taskId||latest.status!==agent.status||isTerminalForMessage(latest)||latest.inventoryScope==='history'||latest.occupancy==='settled')return fail(taskId,'The task changed. Refresh before sending.'); + const currentInput=normalizeInputRequest(latest.inputRequest); + if((currentInput?.id??null)!==(shownInput?.id??null)||inputRequestVersion(currentInput)!==version)return fail(taskId,'The question or its choices changed. Refresh before replying.'); + const supported=capability(latest,snapshot.stale); + if(!supported.available)return fail(taskId,supported.reason); + if(!exactRoute(latest,supported)||supported.mode!==shownCapability.mode||supported.url!==shownCapability.url)return fail(taskId,'The supported message route changed. Refresh before sending.'); + if(supported.mode==='respond'&&!currentInput)return fail(taskId,'The source has not supplied an exact current question.'); + if(supported.mode==='redirect'&¤tInput)return fail(taskId,'The task has a pending question. Refresh before replying.'); + // Check persistent state again after the read preflight, before reserving the write. + const saved=state(taskId,{persistent:true}); + if(['sending','uncertain'].includes(saved.phase)||(supported.mode==='respond'&¤tInput&&saved.phase==='sent'&&saved.inputVersion===version))return saved; + requestId=globalThis.crypto.randomUUID(); + const reservation={phase:'sending',notice:'Submitting your message…',requestId,inputVersion:version}; + try{remember(taskId,reservation);}catch{return fail(taskId,'The delivery receipt could not be saved. Nothing was sent.');} + reserved=true; + const payload={taskId,message:value,requestId,...(supported.mode==='respond'?{inputRequestId:currentInput.id,inputRequestVersion:version}:{})}; + const result=await fetchImpl(supported.url,{method:'POST',credentials:'omit',redirect:'error',signal:AbortSignal.timeout(12_000),headers:{'content-type':'application/json','x-cottagecode-request':'user-message'},body:JSON.stringify(payload)}); + const receipt=await result.json(); + let next; + if(result.ok&&receipt.ok===true&&['accepted','submitted'].includes(receipt.delivery)&&receipt.requestId===requestId) + next={...reservation,phase:'sent',notice:receipt.delivery==='accepted'?'Answer accepted by the task source.':'Message submitted to the task source.',receipt}; + else if(!result.ok&&receipt.ok===false&&receipt.delivery==='not_sent')next={...reservation,phase:'error',notice:typeof receipt.error==='string'?receipt.error:'The source declined this message. Nothing was sent.'}; + else next={...reservation,phase:'uncertain',notice:uncertainNotice}; + try{return remember(taskId,next);}catch{const locked={...reservation,phase:'uncertain',notice:uncertainNotice};states.set(taskId,locked);return locked;} + }catch{ + if(!reserved)return fail(taskId,'The feed could not verify the current task. Nothing was sent.'); + const locked={phase:'uncertain',notice:uncertainNotice,requestId,inputVersion:version}; + try{return remember(taskId,locked);}catch{states.set(taskId,locked);return locked;} + }finally{active.delete(taskId);} + } + return {state,send}; +} diff --git a/src/feed-client.mjs b/src/feed-client.mjs index 841fc60..f6a0650 100644 --- a/src/feed-client.mjs +++ b/src/feed-client.mjs @@ -1,10 +1,15 @@ import {normalizePr,hasOutstandingPr} from './pr.mjs'; import {taskText} from './task-text.mjs'; const statuses=new Set(['working','idle','blocked','done','offline']); +const measured=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0?value:null; export function feedEnvelope(data){ if(Array.isArray(data))return {agents:data,source:'',relationships:[],handoffs:[]}; if(!data||!Array.isArray(data.agents)) throw new Error('response must be an agents array or { agents: [] }'); - return {agents:data.agents,source:String(data.source||''),relationships:data.relationships||[],handoffs:data.handoffs||[],stale:!!data.stale,errors:data.errors||[]}; + return {agents:data.agents,source:String(data.source||''),relationships:data.relationships||[],handoffs:data.handoffs||[],stale:!!data.stale,errors:data.errors||[],checkedAt:validTime(data.checkedAt),timestamp:validTime(data.timestamp)}; +} +/** Feed outages never rewrite the Hub's last canonical PR observation. */ +export function staleFeedPr(pr,reason){ + return pr?.authority==='hub'?pr:{...pr,stale:true,reason}; } export function validTime(value){ const n=typeof value==='number'?value:Date.parse(value); @@ -17,11 +22,11 @@ export function normalizeCottage(a,i,{town,model,occupancy,now=Date.now()}){ role:town(a.town||a.role),status,parent:a.parent??null,task:taskText(a.task)||'-', originalAsk:taskText(a.originalAsk), taskId:a.taskId?String(a.taskId):null, - taskStartedAt:validTime(a.taskStartedAt),sessionStartedAt:validTime(a.sessionStartedAt), + taskStartedAt:validTime(a.taskStartedAt),sessionStartedAt:validTime(a.sessionStartedAt),turnStartedAt:validTime(a.turnStartedAt), startedAt:validTime(a.startedAt),endedAt:validTime(a.endedAt),updatedAt:validTime(a.updatedAt),terminal:!!a.terminal, worktree:String(a.worktree||''),worktreePath:String(a.worktreePath||''),branch:String(a.branch||''), activity:String(a.activity||''),lastLine:String(a.lastLine||''),result:String(a.result||''), - attention:String(a.attention||''),model:model(a.model),tokens:Number(a.tokens)||0,cost:Number(a.cost)||0, + attention:String(a.attention||''),model:model(a.model),tokens:measured(a.tokens),cost:measured(a.cost), dispatchedBy:String(a.dispatchedBy||a.parent||'unknown'),pr}; // The Hub owns dashboard membership. A historical PR link is still useful // at the desk, but it cannot bring an archived task back into the village. @@ -32,6 +37,7 @@ export function normalizeCottage(a,i,{town,model,occupancy,now=Date.now()}){ } export function isCottageVisible(agent,{showSettled=false,interiorId=null,now=Date.now()}={}){ return showSettled||interiorId===agent.id|| + agent.inventoryScope==='dashboard'|| (agent.inventoryScope!=='history'&&(agent.occupancy!=='settled'||hasOutstandingPr(agent,now))); } export function layoutCottages(agents,options={}){ diff --git a/src/hub.mjs b/src/hub.mjs index 2e7ff41..7603135 100644 --- a/src/hub.mjs +++ b/src/hub.mjs @@ -249,11 +249,9 @@ export function toCottage(row, now = Date.now()) { const attention = attentionText ? String(attentionText).replace(/\s+/g, " ").trim().slice(0, 240) : ""; - const tokens = - (row.input_tokens || 0) + - (row.output_tokens || 0) + - (row.cache_write_tokens || 0) + - (row.cache_read_tokens || 0); + const measured = value => typeof value === 'number' && Number.isFinite(value) && value >= 0; + const tokenValues = [row.input_tokens, row.output_tokens, row.cache_write_tokens, row.cache_read_tokens].filter(measured); + const tokens = tokenValues.length ? tokenValues.reduce((sum, value) => sum + value, 0) : null; const started = saneTime( parseDbTimestampMs(row.started_at) || parseDbTimestampMs(row.queued_at), 0 @@ -276,7 +274,11 @@ export function toCottage(row, now = Date.now()) { originalAskSource: externalSession ? "session" : "task", originalAskTruncated: original.length > 32768, taskStartedAt: externalSession ? null : timestampMs(row.started_at), - sessionStartedAt: timestampMs(ctx.sessionStartedAt || ctx.lifecycle?.sessionStartedAt) || (externalSession ? started || null : null), + // Codex rollout started_at is the latest explicit turn boundary, replaced + // whenever the persistent external session reopens. It is not its creation. + sessionStartedAt: timestampMs(ctx.sessionStartedAt || ctx.lifecycle?.sessionStartedAt) || + (externalSession && (row.source || ctx.source) !== 'codex_rollout' ? started || null : null), + turnStartedAt: externalSession && (row.source || ctx.source) === 'codex_rollout' ? timestampMs(row.started_at) : null, activityUrl: `/agents/${encodeURIComponent(row.id)}/activity`, todos, inputRequest, @@ -307,7 +309,7 @@ export function toCottage(row, now = Date.now()) { endedAt: ended, updatedAt, tokens, - cost: Number(row.total_cost || 0), + cost: measured(row.total_cost) ? row.total_cost : null, lastLine: pickLastLine({ ...row, attention_message: attention }, ctx, result, task), sessionId: row.session_id || null, source: "hub", diff --git a/test/command-center.test.mjs b/test/command-center.test.mjs new file mode 100644 index 0000000..99520e0 --- /dev/null +++ b/test/command-center.test.mjs @@ -0,0 +1,175 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {durationLabel,taskView,fleetView} from '../experiments/command-center/model.mjs'; +import {createScenario} from '../experiments/command-center/scenario.mjs'; +import {createReplyClient,isTerminalForMessage} from '../experiments/command-center/replies.mjs'; +import {inputRequestVersion} from '../src/input-request.mjs'; +const now=Date.parse('2026-11-04T12:00:00Z'); +const endpoint='http://127.0.0.1:8790/agents'; +const task=(extra={})=>({id:'cottage-1',taskId:'task-1',task:'Review fleet inventory',town:'HubTown',status:'working',source:'hub',updatedAt:now,...extra}); +const pr=(extra={})=>({url:'https://example.invalid/fiction/hub/pull/901',state:'open',reviewState:'ready',headSha:'abc',reviewedHeadSha:'abc',checkedAt:now,...extra}); +const question=(extra={})=>task({status:'blocked',inputRequest:{id:'question-1',kind:'question',prompt:'Keep historical records?',options:['Yes','No']},conversation:{available:true,mode:'respond',checkedAt:now,messageUrl:'/agents/cottage-1/messages'},...extra}); +const memory=()=>{const map=new Map();return {getItem:key=>map.get(key)??null,setItem:(key,value)=>map.set(key,value),removeItem:key=>map.delete(key)};}; +const json=(body,status=200)=>new Response(JSON.stringify(body),{status,headers:{'content-type':'application/json'}}); + +test('compact durations preserve unavailable rather than inventing a clock',()=>{ + assert.equal(durationLabel(null),'Unavailable');assert.equal(durationLabel(NaN),'Unavailable');assert.equal(durationLabel(-1),'Unavailable'); + assert.equal(durationLabel(0),'0s');assert.equal(durationLabel(90061_000),'1d 1h');assert.equal(durationLabel(125_000),'2m 5s'); +}); +test('task and session clocks stay separate when task start is missing',()=>{ + const view=taskView(task({sessionStartedAt:now-3600_000}),{now}); + assert.equal(view.taskAgeMs,null);assert.equal(view.sessionAgeMs,3600_000);assert.equal(view.harness,'Unknown'); +}); +test('terminal clocks freeze at end time or remain unavailable without one',()=>{ + const base=task({status:'done',taskStartedAt:now-600_000,sessionStartedAt:now-900_000,endedAt:now-300_000}); + assert.equal(taskView(base,{now}).taskAgeMs,300_000);assert.equal(taskView(base,{now:now+1e6}).sessionAgeMs,600_000); + assert.equal(taskView({...base,endedAt:null},{now}).taskAgeMs,null); + assert.equal(taskView({...base,durationMs:12000},{now}).taskAgeMs,300_000); + assert.equal(taskView({...base,endedAt:null,durationMs:12000},{now}).taskAgeMs,null); +}); +test('observation alone never becomes progress or quiet; explicit old progress is a heuristic',()=>{ + const observed=taskView(task({updatedAt:now-7200_000}),{now});assert.equal(observed.observedAgeMs,7200_000);assert.equal(observed.progressAgeMs,null);assert.equal(observed.quiet,false); + assert.equal(taskView(task({lastProgressAt:now-1200_000}),{now}).quiet,true); + assert.equal(taskView(task({telemetry:{progressAt:now-60_000}}),{now}).quiet,false); + assert.equal(taskView(task({status:'blocked',lastProgressAt:now-1200_000}),{now}).quiet,false); +}); +test('Hub readiness remains authoritative through stale observation; standalone readiness does not',()=>{ + assert.equal(taskView(task({pr:pr({authority:'hub',checkedAt:now-1e8})}),{now,stale:true}).prStage,'ready'); + assert.notEqual(taskView(task({pr:pr()}),{now,stale:true}).prStage,'ready'); + assert.notEqual(taskView(task({pr:pr({checkedAt:now-1e8})}),{now}).prStage,'ready'); +}); +test('shared PR counts deduplicate and equal-time contradictory evidence is unknown',()=>{ + const snapshot={agents:[task({pr:pr()}),task({id:'cottage-2',pr:pr()}),task({id:'old',inventoryScope:'history'})]}; + const fleet=fleetView(snapshot,{now});assert.equal(fleet.counts.tasks,2);assert.equal(fleet.counts.settled,1);assert.equal(fleet.counts.ready,1);assert.equal(fleet.prs[0].taskIds.length,2); + const conflict=fleetView({agents:[task({pr:pr()}),task({id:'cottage-2',pr:pr({reviewState:'blocked'})})]},{now});assert.equal(conflict.counts.ready,0);assert.equal(conflict.prs[0].stage,'unknown'); +}); +test('canonical Hub PR observation outranks conflicting standalone labels',()=>{ + const fleet=fleetView({agents:[task({pr:pr({authority:'hub',checkedAt:now-5000})}),task({id:'cottage-2',pr:pr({reviewState:'blocked'})})]},{now}); + assert.equal(fleet.prs[0].stage,'ready'); +}); +test('graph uses explicit safe issues, parents, PRs and typed relationships only',()=>{ + const fleet=fleetView({agents:[task({id:'parent'}),task({parent:'parent',issues:[{url:'https://example.invalid/fiction/hub/issues/801',title:'Fictional inventory'},{url:'javascript:alert(1)'}],pr:pr(),task:'Mentions #999 and https://example.invalid/no/guess/pull/1'})],relationships:[{from:'task:parent',to:'task:cottage-1',kind:'depends-on'},{from:'task:missing',to:'task:cottage-1',kind:'depends-on'}]},{now}); + assert.equal(fleet.tasks[1].issues.length,1);assert.ok(fleet.edges.some(edge=>edge.from==='task:parent'&&edge.to==='task:cottage-1'&&edge.kind==='parent')); + assert.ok(fleet.edges.some(edge=>edge.from.startsWith('issue:https:')&&edge.to==='task:cottage-1')); + assert.ok(fleet.edges.some(edge=>edge.to.startsWith('pr:')));assert.ok(!JSON.stringify(fleet.edges).includes('999'));assert.ok(!JSON.stringify(fleet.edges).includes('missing')); +}); +test('activity remains public text; events reject thinking; unknown harness stays unknown',()=>{ + const view=taskView(task({model:'opus',dispatchedBy:'codex',activity:'Reading canonical task state',events:[{id:'1',kind:'progress',text:'Verified one route',timestamp:now},{id:'2',kind:'thinking',text:'Private'}],issues:[{url:'file:///etc/passwd'}]}),{now}); + assert.equal(view.harness,'Unknown');assert.equal(view.activity,'Reading canonical task state');assert.equal(view.events.length,1);assert.equal(view.issues.length,0); +}); +test('scenario is marked illustrative and contains all required evidence cases',()=>{ + const snapshot=createScenario(now),fleet=fleetView(snapshot,{now,endpoint});assert.equal(snapshot.illustrative,true);assert.equal(fleet.tasks.length,12); + assert.equal(fleet.projects.length,5);assert.equal(fleet.tasks.filter(t=>t.input).length,2);assert.equal(fleet.counts.answerable,1);assert.ok(fleet.counts.quiet);assert.ok(fleet.counts.ready);assert.ok(fleet.counts.blockedPr);assert.ok(fleet.prs.some(p=>p.taskIds.length>1)); + assert.ok(fleet.tasks.some(t=>t.status==='done'));assert.ok(fleet.tasks.some(t=>t.prStage==='unknown'));assert.ok(fleet.tasks.every(t=>t.raw.illustrative)); +}); + +test('reply preflight refuses stale feeds and a changed question revision without a POST',async()=>{ + for(const snapshot of [{agents:[question()],stale:true},{agents:[question({inputRequest:{id:'question-1',prompt:'Delete history?'}})]},{agents:[question({taskId:'replacement'})]}]){ + let posts=0;const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async(_,options)=>{if(options?.method==='POST')posts++;return json(snapshot);}}); + const result=await client.send(question(),'Yes');assert.equal(result.phase,'error');assert.equal(posts,0); + } +}); +test('reply sends one matching input version and durably records receipt before transport',async()=>{ + const storage=memory();let posts=0;let client; + const fetchImpl=async(url,options)=>{ + if(options?.method!=='POST')return json({agents:[question()]});posts++; + assert.equal(client.state('task-1').phase,'sending'); + const recreated=createReplyClient({endpoint,storage,now:()=>now,fetchImpl});assert.equal(recreated.state('task-1').phase,'uncertain'); + const payload=JSON.parse(options.body);assert.equal(payload.inputRequestVersion,inputRequestVersion(question().inputRequest));assert.equal(payload.inputRequestId,'question-1');assert.equal(payload.taskId,'task-1');assert.ok(payload.requestId); + return json({ok:true,delivery:'accepted',requestId:payload.requestId}); + }; + client=createReplyClient({endpoint,storage,now:()=>now,fetchImpl});assert.equal((await client.send(question(),'Yes')).phase,'sent');assert.equal(posts,1); + assert.equal((await client.send(question(),'Yes')).phase,'sent');assert.equal(posts,1); +}); +test('ambiguous delivery remains locked across client recreation without automatic retry',async()=>{ + const storage=memory();let posts=0;const fetchImpl=async(_,options)=>{if(options?.method==='POST'){posts++;throw new Error('Connection dropped');}return json({agents:[question()]});}; + const first=createReplyClient({endpoint,storage,now:()=>now,fetchImpl});assert.equal((await first.send(question(),'Yes')).phase,'uncertain'); + const second=createReplyClient({endpoint,storage,now:()=>now,fetchImpl});assert.equal(second.state('task-1').phase,'uncertain');assert.equal((await second.send(question(),'Yes')).phase,'uncertain');assert.equal(posts,1); +}); +test('known not-sent rejection is recoverable but malformed or unconfirmed replies lock',async()=>{ + for(const [reply,status,phase] of [[{ok:false,delivery:'not_sent',error:'Question changed'},409,'error'],[{ok:false,delivery:'unconfirmed'},409,'uncertain'],[{ok:true,delivery:'accepted'},200,'uncertain']]){ + const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async(_,opts)=>opts?.method==='POST'?json(reply,status):json({agents:[question()]})});assert.equal((await client.send(question(),'Yes')).phase,phase); + } +}); +test('storage failure prevents send, and unavailable or changed routes never POST',async()=>{ + let posts=0;const failingStorage={getItem:()=>null,setItem:()=>{throw new Error('Quota');}}; + const client=createReplyClient({endpoint,storage:failingStorage,now:()=>now,fetchImpl:async(_,options)=>{if(options?.method==='POST')posts++;return json({agents:[question()]});}}); + assert.equal((await client.send(question(),'Yes')).phase,'error');assert.equal(posts,0); + for(const conversation of [{available:false,reason:'Source only'},{available:true,mode:'respond',checkedAt:now,messageUrl:'https://evil.invalid/messages'},{available:true,mode:'redirect',checkedAt:now,messageUrl:'/agents/cottage-1/messages'}]){ + const next=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async(_,opts)=>{if(opts?.method==='POST')posts++;return json({agents:[question({conversation})]});}});assert.equal((await next.send(question(),'Yes')).phase,'error');assert.equal(posts,0); + } +}); +test('two synchronous sends perform only one POST',async()=>{ + let posts=0;const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async(_,opts)=>{if(opts?.method==='POST'){posts++;return json({ok:false,delivery:'unconfirmed'},409);}return json({agents:[question()]});}}); + await Promise.all([client.send(question(),'Yes'),client.send(question(),'Yes')]);assert.equal(posts,1); +}); +test('explicit recent occupancy is preserved and malformed optional link collections are ignored',()=>{ + const fleet=fleetView({agents:[task({status:'offline',occupancy:'recent',resultLinks:{kind:'issue'}})]},{now});assert.equal(fleet.tasks.length,1);assert.equal(fleet.tasks[0].issues.length,0); +}); +test('a second client reservation that appears during preflight blocks this client POST',async()=>{ + const storage=memory();let posts=0;let second; + const fetchImpl=async(_,opts)=>{if(opts?.method==='POST'){posts++;throw new Error('Ambiguous stub');}return json({agents:[question()]});}; + second=createReplyClient({endpoint,storage,now:()=>now,fetchImpl}); + const first=createReplyClient({endpoint,storage,now:()=>now,fetchImpl:async(_,opts)=>{if(opts?.method==='POST'){posts++;throw new Error('Must not send twice');}await second.send(question(),'Yes');return json({agents:[question()]});}}); + assert.equal((await first.send(question(),'Yes')).phase,'uncertain');assert.equal(posts,1); +}); +test('illustrative replies never enter a live transport',async()=>{ + let requests=0;const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async()=>{requests++;throw new Error('Must stay local');}}); + assert.equal((await client.send(question({illustrative:true}),'Yes')).phase,'error');assert.equal(requests,0); +}); +test('terminal or ended tasks cannot send even when a source supplied an available flag',async()=>{ + let requests=0;const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async()=>{requests++;return json({agents:[question({status:'done'})]});}}); + assert.equal((await client.send(question({status:'done'}),'Yes')).phase,'error');assert.equal(requests,0); +}); +test('fresh terminal and end flags independently prevent a POST despite unchanged working status',async()=>{ + const shown=task({conversation:{available:true,mode:'redirect',checkedAt:now,messageUrl:'/agents/cottage-1/messages'}}); + for(const changed of [{terminal:true},{endedAt:now}]){ + let posts=0;const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async(_,opts)=>{if(opts?.method==='POST'){posts++;return json({ok:false,delivery:'unconfirmed'},409);}return json({agents:[{...shown,...changed}]});}}); + assert.equal((await client.send(shown,'Please check the fixtures.')).phase,'error');assert.equal(posts,0); + } +}); +test('confirmed free-form redirects permit distinct explicit sends with fresh request IDs across reload',async()=>{ + const shown=task({conversation:{available:true,mode:'redirect',checkedAt:now,messageUrl:'/agents/cottage-1/messages'}}),storage=memory(),payloads=[]; + const fetchImpl=async(_,opts)=>{if(opts?.method!=='POST')return json({agents:[shown]});const payload=JSON.parse(opts.body);payloads.push(payload);return json({ok:true,delivery:'submitted',requestId:payload.requestId});}; + const first=createReplyClient({endpoint,storage,now:()=>now,fetchImpl}); + assert.equal((await first.send(shown,'Check the Node fixtures first.')).phase,'sent'); + assert.equal((await first.send(shown,'Also inspect the browser route.')).phase,'sent'); + const recreated=createReplyClient({endpoint,storage,now:()=>now,fetchImpl}); + assert.equal((await recreated.send(shown,'Include the evidence in your report.')).phase,'sent'); + assert.equal(payloads.length,3);assert.equal(new Set(payloads.map(payload=>payload.requestId)).size,3);assert.deepEqual(payloads.map(payload=>payload.message),['Check the Node fixtures first.','Also inspect the browser route.','Include the evidence in your report.']); +}); +test('uncertain free-form redirects still lock distinct amendments across reload',async()=>{ + const shown=task({conversation:{available:true,mode:'redirect',checkedAt:now,messageUrl:'/agents/cottage-1/messages'}}),storage=memory();let posts=0; + const fetchImpl=async(_,opts)=>{if(opts?.method==='POST'){posts++;throw new Error('Stub connection dropped');}return json({agents:[shown]});}; + const first=createReplyClient({endpoint,storage,now:()=>now,fetchImpl});assert.equal((await first.send(shown,'Check the fixtures.')).phase,'uncertain'); + const recreated=createReplyClient({endpoint,storage,now:()=>now,fetchImpl});assert.equal((await recreated.send(shown,'Check the browser too.')).phase,'uncertain');assert.equal(posts,1); +}); +test('explicit current terminal false overrides a historical end timestamp for responding',async()=>{ + const shown=question({terminal:false,endedAt:now-3600_000,executionStatus:'needs_input',conversationTarget:{taskStatus:'needs_input'}}); + let posts=0; + const client=createReplyClient({endpoint,storage:memory(),now:()=>now,fetchImpl:async(_,opts)=>{ + if(opts?.method!=='POST')return json({agents:[shown]}); + posts++;const payload=JSON.parse(opts.body);return json({ok:true,delivery:'accepted',requestId:payload.requestId}); + }}); + assert.equal((await client.send(shown,'Keep 30 days.')).phase,'sent');assert.equal(posts,1); +}); + +test('shared terminal guard honors current flags without reviving terminal statuses',()=>{ + assert.equal(isTerminalForMessage({status:'blocked',terminal:false,endedAt:now-3600_000}),false); + assert.equal(isTerminalForMessage({status:'working',terminal:true}),true); + assert.equal(isTerminalForMessage({status:'working',endedAt:now}),true); + assert.equal(isTerminalForMessage({status:'done',terminal:false}),true); +}); + +test('task wall age includes review and input waits rather than recorded execution duration',()=>{ + for(const [status,executionStatus] of [['working','running'],['idle','ready_for_merge'],['blocked','needs_input']]){ + const view=taskView(task({status,executionStatus,terminal:false,taskStartedAt:now-7200_000,durationMs:600_000}),{now}); + assert.equal(view.taskAgeMs,7200_000);assert.equal(view.recordedExecutionMs,600_000); + } +}); +test('current nonterminal task age ignores a prior attempt end and requires explicit task start',()=>{ + const current=task({terminal:false,taskStartedAt:now-7200_000,sessionStartedAt:now-10800_000,endedAt:now-3600_000,durationMs:600_000}); + const view=taskView(current,{now});assert.equal(view.taskAgeMs,7200_000);assert.equal(view.sessionAgeMs,10800_000); + assert.equal(taskView({...current,taskStartedAt:null},{now}).taskAgeMs,null); + assert.equal(taskView({...current,taskStartedAt:now+60_000},{now}).taskAgeMs,null); +}); diff --git a/test/command-integration.test.mjs b/test/command-integration.test.mjs new file mode 100644 index 0000000..5a19ce4 --- /dev/null +++ b/test/command-integration.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { commandSnapshot, fleetView } from '../src/command-model.mjs'; +import { feedEnvelope, staleFeedPr } from '../src/feed-client.mjs'; + +const now=Date.parse('2026-11-04T12:00:00Z'); +test('command snapshot keeps exact public evidence and the map identity and PR authority',()=>{ + const normalized={id:'one',town:'HubTown',role:'HubTown',model:'gpt',status:'idle',terminal:false,occupancy:'live',originalAsk:'Readable request',pr:{authority:'hub',stage:'blocked',reason:'Canonical blocker'}}; + const raw={id:'one',town:'hub',model:'gpt-6.1-codex',status:'failed',taskStartedAt:now-60000,harness:'Codex',originalAsk:{text:'Readable request'},pr:{stage:'ready'}}; + const joined=commandSnapshot([normalized],{agents:[raw],relationships:[{from:'task:one',to:'task:two',kind:'parent'}]},{stale:true}); + assert.equal(joined.agents[0].model,'gpt-6.1-codex');assert.equal(joined.agents[0].status,'failed'); + assert.equal(joined.agents[0].town,'HubTown');assert.equal(joined.agents[0].terminal,undefined); + assert.equal(joined.agents[0].originalAsk,'Readable request');assert.equal(joined.agents[0].pr.stage,'blocked');assert.equal(joined.stale,true); + assert.equal(joined.relationships.length,1);assert.equal(raw.pr.stage,'ready'); +}); +test('command snapshot does not substitute evidence from another identity',()=>{ + const joined=commandSnapshot([{id:'new',town:'MemTown',task:'New work'}],{agents:[{id:'old',inputRequest:{id:'private-question'},model:'Old model'}]}); + assert.equal(joined.agents.length,1);assert.equal(joined.agents[0].inputRequest,undefined);assert.equal(joined.agents[0].model,undefined); +}); +test('explicit settled toggle includes history without duplicating PR counts',()=>{ + const pr={url:'https://github.com/example/project/pull/1',number:1,authority:'hub',state:'open',reviewState:'ready',headSha:'abc',reviewedHeadSha:'abc',checkedAt:now}; + const agents=[{id:'a',task:'Current',inventoryScope:'dashboard',status:'idle',pr},{id:'b',task:'History',inventoryScope:'history',status:'done',pr}]; + assert.equal(fleetView({agents},{now}).tasks.length,1); + const all=fleetView({agents},{now,includeSettled:true});assert.equal(all.tasks.length,2);assert.equal(all.counts.ready,1); +}); +test('feed envelope preserves source verification timestamps independently',()=>{ + const value=feedEnvelope({agents:[],checkedAt:now-10000,timestamp:now,source:'hub'}); + assert.equal(value.checkedAt,now-10000);assert.equal(value.timestamp,now); +}); + +test('local demo replies are counted only in an explicitly built-in demo context',()=>{ + const snapshot={agents:[{id:'demo-1',source:'demo',status:'blocked',inputRequest:{id:'q1',prompt:'Choose a scope'}}]}; + assert.equal(fleetView(snapshot,{now}).counts.answerable,0); + assert.equal(fleetView(snapshot,{now,builtInDemo:true}).counts.answerable,1); +}); + +test('missing source model and status never inherit renderer defaults',()=>{ + const snapshot=commandSnapshot([{id:'a',model:'sonnet',status:'idle'}],{agents:[{id:'a'}]}); + const view=fleetView(snapshot,{now,includeSettled:true}).tasks[0]; + assert.equal(view.model,'Unknown');assert.equal(view.status,'unknown'); +}); +test('outages preserve Hub verification and blocker reasons but stale standalone observations',()=>{ + const hub={authority:'hub',checkedAt:now,stale:false,reason:'Security review required'}; + assert.equal(staleFeedPr(hub,'Connection interrupted'),hub); + const standalone=staleFeedPr({...hub,authority:'github'},'Connection interrupted'); + assert.equal(standalone.stale,true);assert.equal(standalone.reason,'Connection interrupted'); +}); +test('shared Hub PR observations reconcile CI, attention and project counts before town scoping',()=>{ + const common={url:'https://github.com/example/project/pull/42',state:'open',headSha:'abc',reviewedHeadSha:'abc',checkedAt:now}; + const agents=[{id:'hub',town:'HubTown',status:'working',pr:{...common,authority:'hub',reviewState:'ready',checks:[{name:'unit',status:'completed',conclusion:'success'}]}}, + {id:'other',town:'OtherTown',status:'working',pr:{...common,reviewState:'blocked',checks:[{name:'unit',status:'completed',conclusion:'failure'}]}}]; + const joined=commandSnapshot(agents,{agents}); + assert.equal(joined.agents[1].pr.authority,'hub');assert.equal(joined.agents[1].pr.reviewState,'ready'); + const fleet=fleetView({agents},{now}); + assert.equal(fleet.counts.attention,0);assert.equal(fleet.projects.find(p=>p.name==='OtherTown').attention,0); + for(const task of fleet.tasks){assert.equal(task.prStage,'ready');assert.equal(task.ci.state,'passing');assert.equal(task.attention,false);} + const scoped=fleetView({agents:fleet.tasks.filter(t=>t.town==='OtherTown').map(t=>({...t.raw,pr:t.pr}))},{now}); + assert.equal(scoped.tasks[0].pr.authority,'hub');assert.equal(scoped.counts.ready,1);assert.equal(scoped.tasks[0].ci.state,'passing'); + const blocked=fleetView({agents:agents.map(a=>({...a,pr:{...a.pr,reviewState:a.id==='hub'?'blocked':'ready'}}))},{now}); + assert.equal(blocked.counts.attention,2);assert.equal(blocked.tasks[1].prStage,'blocked'); +}); + +test('conflicting terminal PR observations remain unknown through joins and town scoping in either order',()=>{ + for(const state of ['closed','merged'])for(const reverse of [false,true]){ + const common={url:'https://github.com/example/project/pull/42',authority:'hub',checkedAt:now}; + const agents=[{id:'a',town:'HubTown',status:'working',pr:{...common,state}},{id:'b',town:'OtherTown',status:'working',pr:{...common,state:'open'}}]; + if(reverse)agents.reverse(); + const joined=commandSnapshot(agents,{agents}),fleet=fleetView(joined,{now}); + assert.ok(fleet.tasks.every(t=>t.prStage==='unknown')); + const scoped=fleetView({agents:fleet.tasks.slice(0,1).map(t=>({...t.raw,pr:t.pr}))},{now}); + assert.equal(scoped.tasks[0].prStage,'unknown'); + } +}); diff --git a/test/feed-client.test.mjs b/test/feed-client.test.mjs index 140ce14..5e91d61 100644 --- a/test/feed-client.test.mjs +++ b/test/feed-client.test.mjs @@ -7,7 +7,24 @@ test('bare and wrapped feeds are compatible but missing agents is invalid',()=>{ }); test('missing timestamps never become the current time',()=>{ const a=normalizeCottage({id:'a'},0,{town:x=>x||'Town',model:x=>x||'?',occupancy:()=> 'live'}); - assert.equal(a.startedAt,null);assert.equal(a.originalAsk,'');assert.equal(a.taskId,null); + assert.equal(a.startedAt,null);assert.equal(a.originalAsk,'');assert.equal(a.taskId,null);assert.equal(a.turnStartedAt,null); +}); +test('explicit turn clocks normalize independently of task and session clocks',()=>{ + const start=1700000000000,options={town:x=>x||'Town',model:x=>x||'?',occupancy:()=> 'live'}; + const value=normalizeCottage({turnStartedAt:new Date(start).toISOString()},0,options); + assert.equal(value.turnStartedAt,start);assert.equal(value.taskStartedAt,null);assert.equal(value.sessionStartedAt,null); + assert.equal(normalizeCottage({turnStartedAt:'invalid'},0,options).turnStartedAt,null); +}); +test('missing usage stays unavailable while measured zero and positive values survive',()=>{ + const options={town:x=>x||'Town',model:x=>x||'?',occupancy:()=> 'live'}; + for(const missing of [null,undefined,-1,Infinity,'unknown']){ + const value=normalizeCottage({id:'a',tokens:missing,cost:missing},0,options); + assert.equal(value.tokens,null);assert.equal(value.cost,null); + } + for(const measured of [0,123]){ + const value=normalizeCottage({id:'a',tokens:measured,cost:measured},0,options); + assert.equal(value.tokens,measured);assert.equal(value.cost,measured); + } }); test('known task notifications show a summary and never their transport fields',()=>{ const a=normalizeCottage({id:'a',task:'